Compare commits
332 commits
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 |
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -10,6 +10,7 @@ assignees: ''
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
- Which site: [e.g. dev.phanpy.social OR phanpy.social]
|
- 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]
|
- Which instance: [e.g. mastodon.social]
|
||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -26,4 +26,8 @@ dist-ssr
|
||||||
# Custom
|
# Custom
|
||||||
.env.dev
|
.env.dev
|
||||||
phanpy-dist.zip
|
phanpy-dist.zip
|
||||||
phanpy-dist.tar.gz
|
phanpy-dist.tar.gz
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
.direnv
|
||||||
|
result
|
||||||
|
|
13
.prettierrc
13
.prettierrc
|
@ -3,17 +3,20 @@
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
|
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||||
"importOrder": [
|
"importOrder": [
|
||||||
"^[^.].*.css$",
|
"^[^.].*.css$",
|
||||||
"index.css$",
|
"index.css$",
|
||||||
".css$",
|
".css$",
|
||||||
|
"",
|
||||||
|
"./polyfills",
|
||||||
|
"",
|
||||||
"<THIRD_PARTY_MODULES>",
|
"<THIRD_PARTY_MODULES>",
|
||||||
|
"",
|
||||||
"/assets/",
|
"/assets/",
|
||||||
|
"",
|
||||||
"^../",
|
"^../",
|
||||||
|
"",
|
||||||
"^[./]"
|
"^[./]"
|
||||||
],
|
]
|
||||||
"importOrderSeparation": true,
|
|
||||||
"importOrderSortSpecifiers": true,
|
|
||||||
"importOrderGroupNamespaceSpecifiers": true,
|
|
||||||
"importOrderCaseInsensitive": true
|
|
||||||
}
|
}
|
||||||
|
|
22
README.md
22
README.md
|
@ -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.
|
- **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.
|
- **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.)
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
@ -138,7 +138,7 @@ Download or `git clone` this repository. Use `production` branch for *stable* re
|
||||||
Customization can be done by passing environment variables to the build command. Examples:
|
Customization can be done by passing environment variables to the build command. Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PHANPY_APP_TITLE="Phanpy Dev" \
|
PHANPY_CLIENT_NAME="Phanpy Dev" \
|
||||||
PHANPY_WEBSITE="https://dev.phanpy.social" \
|
PHANPY_WEBSITE="https://dev.phanpy.social" \
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
@ -179,6 +179,13 @@ Available variables:
|
||||||
- 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)
|
- 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 fallback instances hard-coded in `/.env`
|
||||||
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances)
|
- [↗️ 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
|
### Static site hosting
|
||||||
|
|
||||||
|
@ -192,7 +199,7 @@ See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva
|
||||||
|
|
||||||
These are self-hosted by other wonderful folks.
|
These are self-hosted by other wonderful folks.
|
||||||
|
|
||||||
- [ferengi.one](https://m.ferengi.one/) by [@david@collantes.social](https://collantes.social/@david)
|
- [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.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.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.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan)
|
||||||
|
@ -200,6 +207,11 @@ These are self-hosted by other wonderful folks.
|
||||||
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
|
- [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.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.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.
|
> Note: Add yours by creating a pull request.
|
||||||
|
|
||||||
|
@ -235,6 +247,8 @@ And here I am. Building a Mastodon web client.
|
||||||
|
|
||||||
## Alternative web clients
|
## Alternative web clients
|
||||||
|
|
||||||
|
- Phanpy forks ↓
|
||||||
|
- [Agora](https://agorasocial.app/)
|
||||||
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
|
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
|
||||||
- [Semaphore](https://semaphore.social/)
|
- [Semaphore](https://semaphore.social/)
|
||||||
- [Enafore](https://enafore.social/)
|
- [Enafore](https://enafore.social/)
|
||||||
|
@ -250,6 +264,8 @@ And here I am. Building a Mastodon web client.
|
||||||
- [Statuzer](https://statuzer.com/)
|
- [Statuzer](https://statuzer.com/)
|
||||||
- [Tusked](https://tusked.app/)
|
- [Tusked](https://tusked.app/)
|
||||||
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
|
- [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)
|
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
|
||||||
|
|
||||||
## 💁♂️ Notice to all other social media client developers
|
## 💁♂️ Notice to all other social media client developers
|
||||||
|
|
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 ];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
4699
package-lock.json
generated
4699
package-lock.json
generated
File diff suppressed because it is too large
Load diff
57
package.json
57
package.json
|
@ -7,60 +7,63 @@
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
|
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
|
||||||
"sourcemap": "npx source-map-explorer dist/assets/*.js"
|
"sourcemap": "npx source-map-explorer dist/assets/*.js",
|
||||||
|
"bundle-visualizer": "npx vite-bundle-visualizer"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-localematcher": "~0.5.4",
|
"@formatjs/intl-localematcher": "~0.5.4",
|
||||||
"@formatjs/intl-segmenter": "~11.5.5",
|
"@formatjs/intl-segmenter": "~11.5.7",
|
||||||
"@formkit/auto-animate": "~0.8.1",
|
"@formkit/auto-animate": "~0.8.2",
|
||||||
"@github/text-expander-element": "~2.6.1",
|
"@github/text-expander-element": "~2.7.1",
|
||||||
"@iconify-icons/mingcute": "~1.2.9",
|
"@iconify-icons/mingcute": "~1.2.9",
|
||||||
"@justinribeiro/lite-youtube": "~1.5.0",
|
"@justinribeiro/lite-youtube": "~1.5.0",
|
||||||
"@szhsin/react-menu": "~4.1.0",
|
"@szhsin/react-menu": "~4.2.1",
|
||||||
"@uidotdev/usehooks": "~2.4.1",
|
"compare-versions": "~6.1.1",
|
||||||
"compare-versions": "~6.1.0",
|
"dayjs": "~1.11.12",
|
||||||
"dayjs": "~1.11.10",
|
|
||||||
"dayjs-twitter": "~0.5.0",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.4",
|
||||||
"fast-equals": "~5.0.1",
|
"fast-equals": "~5.0.1",
|
||||||
"html-prettify": "^1.0.7",
|
"fuse.js": "~7.0.0",
|
||||||
|
"html-prettify": "~1.0.7",
|
||||||
"idb-keyval": "~6.2.1",
|
"idb-keyval": "~6.2.1",
|
||||||
"just-debounce-it": "~3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"lz-string": "~1.5.0",
|
"lz-string": "~1.5.0",
|
||||||
"masto": "~6.6.4",
|
"masto": "~6.8.0",
|
||||||
"moize": "~6.1.6",
|
"moize": "~6.1.6",
|
||||||
"p-retry": "~6.2.0",
|
"p-retry": "~6.2.0",
|
||||||
"p-throttle": "~6.1.0",
|
"p-throttle": "~6.1.0",
|
||||||
"preact": "~10.19.6",
|
"preact": "~10.23.1",
|
||||||
|
"punycode": "~2.3.1",
|
||||||
"react-hotkeys-hook": "~4.5.0",
|
"react-hotkeys-hook": "~4.5.0",
|
||||||
"react-intersection-observer": "~9.8.1",
|
"react-intersection-observer": "~9.13.0",
|
||||||
"react-quick-pinch-zoom": "~5.1.0",
|
"react-quick-pinch-zoom": "~5.1.0",
|
||||||
"react-router-dom": "6.6.2",
|
"react-router-dom": "6.6.2",
|
||||||
"string-length": "6.0.0",
|
"string-length": "6.0.0",
|
||||||
"swiped-events": "~1.1.9",
|
"swiped-events": "~1.2.0",
|
||||||
|
"tinyld": "~1.3.4",
|
||||||
"toastify-js": "~1.12.0",
|
"toastify-js": "~1.12.0",
|
||||||
"uid": "~2.0.2",
|
"uid": "~2.0.2",
|
||||||
"use-debounce": "~10.0.0",
|
"use-debounce": "~10.0.2",
|
||||||
"use-long-press": "~3.2.0",
|
"use-long-press": "~3.2.0",
|
||||||
"use-resize-observer": "~9.1.0",
|
"use-resize-observer": "~9.1.0",
|
||||||
"valtio": "1.13.2"
|
"valtio": "1.13.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/preset-vite": "~2.8.1",
|
"@ianvs/prettier-plugin-sort-imports": "~4.3.1",
|
||||||
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
"@preact/preset-vite": "~2.9.0",
|
||||||
"postcss": "~8.4.35",
|
"postcss": "~8.4.40",
|
||||||
"postcss-dark-theme-class": "~1.2.1",
|
"postcss-dark-theme-class": "~1.3.0",
|
||||||
"postcss-preset-env": "~9.4.0",
|
"postcss-preset-env": "~10.0.0",
|
||||||
"twitter-text": "~3.1.0",
|
"twitter-text": "~3.1.0",
|
||||||
"vite": "~5.1.5",
|
"vite": "~5.3.5",
|
||||||
"vite-plugin-generate-file": "~0.1.1",
|
"vite-plugin-generate-file": "~0.2.0",
|
||||||
"vite-plugin-html-config": "~1.0.11",
|
"vite-plugin-html-config": "~1.0.11",
|
||||||
"vite-plugin-pwa": "~0.19.2",
|
"vite-plugin-pwa": "~0.20.1",
|
||||||
"vite-plugin-remove-console": "~2.2.0",
|
"vite-plugin-remove-console": "~2.2.0",
|
||||||
"workbox-cacheable-response": "~7.0.0",
|
"workbox-cacheable-response": "~7.1.0",
|
||||||
"workbox-expiration": "~7.0.0",
|
"workbox-expiration": "~7.1.0",
|
||||||
"workbox-routing": "~7.0.0",
|
"workbox-routing": "~7.1.0",
|
||||||
"workbox-strategies": "~7.0.0"
|
"workbox-strategies": "~7.1.0"
|
||||||
},
|
},
|
||||||
"postcss": {
|
"postcss": {
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|
24
public/sw.js
24
public/sw.js
|
@ -62,7 +62,7 @@ const iconsRoute = new Route(
|
||||||
cacheName: 'icons',
|
cacheName: 'icons',
|
||||||
plugins: [
|
plugins: [
|
||||||
new ExpirationPlugin({
|
new ExpirationPlugin({
|
||||||
maxEntries: 50,
|
maxEntries: 300,
|
||||||
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
|
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
|
||||||
purgeOnQuotaError: true,
|
purgeOnQuotaError: true,
|
||||||
}),
|
}),
|
||||||
|
@ -96,6 +96,28 @@ const apiExtendedRoute = new RegExpRoute(
|
||||||
);
|
);
|
||||||
registerRoute(apiExtendedRoute);
|
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(
|
const apiRoute = new RegExpRoute(
|
||||||
// Matches:
|
// Matches:
|
||||||
// - statuses/:id/context - some contexts are really huge
|
// - statuses/:id/context - some contexts are really huge
|
||||||
|
|
579
src/app.css
579
src/app.css
File diff suppressed because it is too large
Load diff
42
src/app.jsx
42
src/app.jsx
|
@ -1,7 +1,6 @@
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
import debounce from 'just-debounce-it';
|
import debounce from 'just-debounce-it';
|
||||||
import { lazy, Suspense } from 'preact/compat';
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
@ -10,7 +9,9 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
|
import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import 'swiped-events';
|
import 'swiped-events';
|
||||||
|
|
||||||
import { subscribe } from 'valtio';
|
import { subscribe } from 'valtio';
|
||||||
|
|
||||||
import BackgroundService from './components/background-service';
|
import BackgroundService from './components/background-service';
|
||||||
|
@ -18,15 +19,16 @@ import ComposeButton from './components/compose-button';
|
||||||
import { ICONS } from './components/ICONS';
|
import { ICONS } from './components/ICONS';
|
||||||
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
|
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
|
||||||
import Loader from './components/loader';
|
import Loader from './components/loader';
|
||||||
// import Modals from './components/modals';
|
import Modals from './components/modals';
|
||||||
import NotificationService from './components/notification-service';
|
import NotificationService from './components/notification-service';
|
||||||
import SearchCommand from './components/search-command';
|
import SearchCommand from './components/search-command';
|
||||||
import Shortcuts from './components/shortcuts';
|
import Shortcuts from './components/shortcuts';
|
||||||
import NotFound from './pages/404';
|
import NotFound from './pages/404';
|
||||||
import AccountStatuses from './pages/account-statuses';
|
import AccountStatuses from './pages/account-statuses';
|
||||||
import Bookmarks from './pages/bookmarks';
|
import Bookmarks from './pages/bookmarks';
|
||||||
// import Catchup from './pages/catchup';
|
import Catchup from './pages/catchup';
|
||||||
import Favourites from './pages/favourites';
|
import Favourites from './pages/favourites';
|
||||||
|
import Filters from './pages/filters';
|
||||||
import FollowedHashtags from './pages/followed-hashtags';
|
import FollowedHashtags from './pages/followed-hashtags';
|
||||||
import Following from './pages/following';
|
import Following from './pages/following';
|
||||||
import Hashtag from './pages/hashtag';
|
import Hashtag from './pages/hashtag';
|
||||||
|
@ -53,11 +55,9 @@ import { getAccessToken } from './utils/auth';
|
||||||
import focusDeck from './utils/focus-deck';
|
import focusDeck from './utils/focus-deck';
|
||||||
import states, { initStates, statusKey } from './utils/states';
|
import states, { initStates, statusKey } from './utils/states';
|
||||||
import store from './utils/store';
|
import store from './utils/store';
|
||||||
import { getCurrentAccount } from './utils/store-utils';
|
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils';
|
||||||
import './utils/toast-alert';
|
|
||||||
|
|
||||||
const Catchup = lazy(() => import('./pages/catchup'));
|
import './utils/toast-alert';
|
||||||
const Modals = lazy(() => import('./components/modals'));
|
|
||||||
|
|
||||||
window.__STATES__ = states;
|
window.__STATES__ = states;
|
||||||
window.__STATES_STATS__ = () => {
|
window.__STATES_STATS__ = () => {
|
||||||
|
@ -129,13 +129,15 @@ setInterval(() => {
|
||||||
// Related: https://github.com/vitejs/vite/issues/10600
|
// Related: https://github.com/vitejs/vite/issues/10600
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
for (const icon in ICONS) {
|
for (const icon in ICONS) {
|
||||||
queueMicrotask(() => {
|
setTimeout(() => {
|
||||||
if (Array.isArray(ICONS[icon])) {
|
if (Array.isArray(ICONS[icon])) {
|
||||||
ICONS[icon][0]?.();
|
ICONS[icon][0]?.();
|
||||||
|
} else if (typeof ICONS[icon] === 'object') {
|
||||||
|
ICONS[icon].module?.();
|
||||||
} else {
|
} else {
|
||||||
ICONS[icon]?.();
|
ICONS[icon]?.();
|
||||||
}
|
}
|
||||||
});
|
}, 1);
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
@ -328,11 +330,11 @@ function App() {
|
||||||
|
|
||||||
const client = initClient({ instance: instanceURL, accessToken });
|
const client = initClient({ instance: instanceURL, accessToken });
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
|
initPreferences(client),
|
||||||
initInstance(client, instanceURL),
|
initInstance(client, instanceURL),
|
||||||
initAccount(client, instanceURL, accessToken, vapidKey),
|
initAccount(client, instanceURL, accessToken, vapidKey),
|
||||||
]);
|
]);
|
||||||
initStates();
|
initStates();
|
||||||
initPreferences(client);
|
|
||||||
|
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
|
@ -341,15 +343,15 @@ function App() {
|
||||||
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
|
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
|
||||||
const account = getCurrentAccount();
|
const account = getCurrentAccount();
|
||||||
if (account) {
|
if (account) {
|
||||||
store.session.set('currentAccount', account.info.id);
|
setCurrentAccountID(account.info.id);
|
||||||
const { client } = api({ account });
|
const { client } = api({ account });
|
||||||
const { instance } = client;
|
const { instance } = client;
|
||||||
// console.log('masto', masto);
|
// console.log('masto', masto);
|
||||||
initStates();
|
initStates();
|
||||||
initPreferences(client);
|
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
await initPreferences(client);
|
||||||
await initInstance(client, instance);
|
await initInstance(client, instance);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -386,9 +388,7 @@ function App() {
|
||||||
)}
|
)}
|
||||||
{isLoggedIn && <ComposeButton />}
|
{isLoggedIn && <ComposeButton />}
|
||||||
{isLoggedIn && <Shortcuts />}
|
{isLoggedIn && <Shortcuts />}
|
||||||
<Suspense>
|
<Modals />
|
||||||
<Modals />
|
|
||||||
</Suspense>
|
|
||||||
{isLoggedIn && <NotificationService />}
|
{isLoggedIn && <NotificationService />}
|
||||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
<BackgroundService isLoggedIn={isLoggedIn} />
|
||||||
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
|
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
|
||||||
|
@ -463,15 +463,9 @@ function SecondaryRoutes({ isLoggedIn }) {
|
||||||
<Route index element={<Lists />} />
|
<Route index element={<Lists />} />
|
||||||
<Route path=":id" element={<List />} />
|
<Route path=":id" element={<List />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/ft" element={<FollowedHashtags />} />
|
<Route path="/fh" element={<FollowedHashtags />} />
|
||||||
<Route
|
<Route path="/ft" element={<Filters />} />
|
||||||
path="/catchup"
|
<Route path="/catchup" element={<Catchup />} />
|
||||||
element={
|
|
||||||
<Suspense>
|
|
||||||
<Catchup />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
||||||
|
|
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 |
|
@ -9,13 +9,17 @@ body.cloak,
|
||||||
.status .content-container,
|
.status .content-container,
|
||||||
.status .content-container *,
|
.status .content-container *,
|
||||||
.status .content-compact > *,
|
.status .content-compact > *,
|
||||||
|
.account-container .actions small,
|
||||||
.account-container :is(header, main > *:not(.actions)),
|
.account-container :is(header, main > *:not(.actions)),
|
||||||
.account-container :is(header, main > *:not(.actions)) *,
|
.account-container :is(header, main > *:not(.actions)) *,
|
||||||
.header-double-lines,
|
.header-double-lines *,
|
||||||
.account-block,
|
.account-block,
|
||||||
.catchup-filters .filter-author *,
|
.catchup-filters .filter-author *,
|
||||||
.post-peek-html *,
|
.post-peek-html *,
|
||||||
.post-peek-content > * {
|
.post-peek-content > *,
|
||||||
|
.request-notifications-account *,
|
||||||
|
.status.compact-thread *,
|
||||||
|
.status .content-compact {
|
||||||
text-decoration-thickness: 1.1em;
|
text-decoration-thickness: 1.1em;
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
/* text-rendering: optimizeSpeed; */
|
/* text-rendering: optimizeSpeed; */
|
||||||
|
@ -49,9 +53,19 @@ body.cloak,
|
||||||
|
|
||||||
body.cloak,
|
body.cloak,
|
||||||
.cloak {
|
.cloak {
|
||||||
|
.header-double-lines *,
|
||||||
|
.account-container .profile-metadata b,
|
||||||
|
.account-container .actions small,
|
||||||
|
.account-container .stats *,
|
||||||
.media-container figcaption,
|
.media-container figcaption,
|
||||||
.media-container figcaption > *,
|
.media-container figcaption > *,
|
||||||
.catchup-filters .filter-author * {
|
.catchup-filters .filter-author *,
|
||||||
|
.request-notifications-account * {
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-container .actions small,
|
||||||
|
.status .content-compact {
|
||||||
|
background-color: currentColor !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,14 @@ export const ICONS = {
|
||||||
'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'),
|
'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'),
|
||||||
transfer: () => import('@iconify-icons/mingcute/transfer-4-line'),
|
transfer: () => import('@iconify-icons/mingcute/transfer-4-line'),
|
||||||
rocket: () => import('@iconify-icons/mingcute/rocket-line'),
|
rocket: () => import('@iconify-icons/mingcute/rocket-line'),
|
||||||
'arrow-left': () => import('@iconify-icons/mingcute/arrow-left-line'),
|
'arrow-left': {
|
||||||
'arrow-right': () => import('@iconify-icons/mingcute/arrow-right-line'),
|
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-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
|
||||||
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
|
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
|
||||||
earth: () => import('@iconify-icons/mingcute/earth-line'),
|
earth: () => import('@iconify-icons/mingcute/earth-line'),
|
||||||
|
@ -16,8 +22,14 @@ export const ICONS = {
|
||||||
'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'),
|
'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'),
|
||||||
'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'),
|
'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'),
|
||||||
message: () => import('@iconify-icons/mingcute/mail-line'),
|
message: () => import('@iconify-icons/mingcute/mail-line'),
|
||||||
comment: () => import('@iconify-icons/mingcute/chat-3-line'),
|
comment: {
|
||||||
comment2: () => import('@iconify-icons/mingcute/comment-2-line'),
|
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'),
|
home: () => import('@iconify-icons/mingcute/home-3-line'),
|
||||||
notification: () => import('@iconify-icons/mingcute/notification-line'),
|
notification: () => import('@iconify-icons/mingcute/notification-line'),
|
||||||
follow: () => import('@iconify-icons/mingcute/user-follow-line'),
|
follow: () => import('@iconify-icons/mingcute/user-follow-line'),
|
||||||
|
@ -31,23 +43,46 @@ export const ICONS = {
|
||||||
gear: () => import('@iconify-icons/mingcute/settings-3-line'),
|
gear: () => import('@iconify-icons/mingcute/settings-3-line'),
|
||||||
more: () => import('@iconify-icons/mingcute/more-3-line'),
|
more: () => import('@iconify-icons/mingcute/more-3-line'),
|
||||||
more2: () => import('@iconify-icons/mingcute/more-1-fill'),
|
more2: () => import('@iconify-icons/mingcute/more-1-fill'),
|
||||||
external: () => import('@iconify-icons/mingcute/external-link-line'),
|
external: {
|
||||||
popout: () => import('@iconify-icons/mingcute/external-link-line'),
|
module: () => import('@iconify-icons/mingcute/external-link-line'),
|
||||||
popin: [() => import('@iconify-icons/mingcute/external-link-line'), '180deg'],
|
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'),
|
plus: () => import('@iconify-icons/mingcute/add-circle-line'),
|
||||||
'chevron-left': () => import('@iconify-icons/mingcute/left-line'),
|
'chevron-left': {
|
||||||
'chevron-right': () => import('@iconify-icons/mingcute/right-line'),
|
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'),
|
'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
|
||||||
reply: [
|
reply: {
|
||||||
() => import('@iconify-icons/mingcute/share-forward-line'),
|
module: () => import('@iconify-icons/mingcute/share-forward-line'),
|
||||||
'180deg',
|
rotate: '180deg',
|
||||||
'horizontal',
|
flip: 'horizontal',
|
||||||
],
|
rtl: true,
|
||||||
|
},
|
||||||
thread: () => import('@iconify-icons/mingcute/route-line'),
|
thread: () => import('@iconify-icons/mingcute/route-line'),
|
||||||
group: () => import('@iconify-icons/mingcute/group-line'),
|
group: {
|
||||||
|
module: () => import('@iconify-icons/mingcute/group-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
bot: () => import('@iconify-icons/mingcute/android-2-line'),
|
bot: () => import('@iconify-icons/mingcute/android-2-line'),
|
||||||
menu: () => import('@iconify-icons/mingcute/rows-4-line'),
|
menu: () => import('@iconify-icons/mingcute/rows-4-line'),
|
||||||
list: () => import('@iconify-icons/mingcute/list-check-line'),
|
list: {
|
||||||
|
module: () => import('@iconify-icons/mingcute/list-check-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
search: () => import('@iconify-icons/mingcute/search-2-line'),
|
search: () => import('@iconify-icons/mingcute/search-2-line'),
|
||||||
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
|
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
|
||||||
info: () => import('@iconify-icons/mingcute/information-line'),
|
info: () => import('@iconify-icons/mingcute/information-line'),
|
||||||
|
@ -62,12 +97,21 @@ export const ICONS = {
|
||||||
share: () => import('@iconify-icons/mingcute/share-2-line'),
|
share: () => import('@iconify-icons/mingcute/share-2-line'),
|
||||||
sparkles: () => import('@iconify-icons/mingcute/sparkles-line'),
|
sparkles: () => import('@iconify-icons/mingcute/sparkles-line'),
|
||||||
sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'),
|
sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'),
|
||||||
exit: () => import('@iconify-icons/mingcute/exit-line'),
|
exit: {
|
||||||
|
module: () => import('@iconify-icons/mingcute/exit-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
translate: () => import('@iconify-icons/mingcute/translate-line'),
|
translate: () => import('@iconify-icons/mingcute/translate-line'),
|
||||||
play: () => import('@iconify-icons/mingcute/play-fill'),
|
play: () => import('@iconify-icons/mingcute/play-fill'),
|
||||||
trash: () => import('@iconify-icons/mingcute/delete-2-line'),
|
trash: () => import('@iconify-icons/mingcute/delete-2-line'),
|
||||||
mute: () => import('@iconify-icons/mingcute/volume-mute-line'),
|
mute: {
|
||||||
unmute: () => import('@iconify-icons/mingcute/volume-line'),
|
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'),
|
block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
|
||||||
unblock: [
|
unblock: [
|
||||||
() => import('@iconify-icons/mingcute/forbid-circle-line'),
|
() => import('@iconify-icons/mingcute/forbid-circle-line'),
|
||||||
|
@ -78,29 +122,57 @@ export const ICONS = {
|
||||||
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
|
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
|
||||||
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
|
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
|
||||||
filter: () => import('@iconify-icons/mingcute/filter-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'),
|
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
|
||||||
react: () => import('@iconify-icons/mingcute/react-line'),
|
react: () => import('@iconify-icons/mingcute/react-line'),
|
||||||
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
|
layout4: {
|
||||||
|
module: () => import('@iconify-icons/mingcute/layout-4-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
|
layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
|
||||||
announce: () => import('@iconify-icons/mingcute/announcement-line'),
|
announce: {
|
||||||
|
module: () => import('@iconify-icons/mingcute/announcement-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
alert: () => import('@iconify-icons/mingcute/alert-line'),
|
alert: () => import('@iconify-icons/mingcute/alert-line'),
|
||||||
round: () => import('@iconify-icons/mingcute/round-fill'),
|
round: () => import('@iconify-icons/mingcute/round-fill'),
|
||||||
'arrow-up-circle': () =>
|
'arrow-up-circle': () =>
|
||||||
import('@iconify-icons/mingcute/arrow-up-circle-line'),
|
import('@iconify-icons/mingcute/arrow-up-circle-line'),
|
||||||
'arrow-down-circle': () =>
|
'arrow-down-circle': () =>
|
||||||
import('@iconify-icons/mingcute/arrow-down-circle-line'),
|
import('@iconify-icons/mingcute/arrow-down-circle-line'),
|
||||||
clipboard: () => import('@iconify-icons/mingcute/clipboard-line'),
|
clipboard: {
|
||||||
|
module: () => import('@iconify-icons/mingcute/clipboard-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
|
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
|
||||||
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
|
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
|
||||||
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
|
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
|
||||||
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
|
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
|
||||||
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
|
month: {
|
||||||
|
module: () => import('@iconify-icons/mingcute/calendar-month-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
media: () => import('@iconify-icons/mingcute/photo-album-line'),
|
media: () => import('@iconify-icons/mingcute/photo-album-line'),
|
||||||
speak: () => import('@iconify-icons/mingcute/radar-line'),
|
speak: () => import('@iconify-icons/mingcute/radar-line'),
|
||||||
building: () => import('@iconify-icons/mingcute/building-5-line'),
|
building: () => import('@iconify-icons/mingcute/building-5-line'),
|
||||||
history2: () => import('@iconify-icons/mingcute/history-2-line'),
|
history2: {
|
||||||
|
module: () => import('@iconify-icons/mingcute/history-2-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
document: () => import('@iconify-icons/mingcute/document-line'),
|
document: () => import('@iconify-icons/mingcute/document-line'),
|
||||||
'arrows-right': () => import('@iconify-icons/mingcute/arrows-right-line'),
|
'arrows-right': {
|
||||||
|
module: () => import('@iconify-icons/mingcute/arrows-right-line'),
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
code: () => import('@iconify-icons/mingcute/code-line'),
|
code: () => import('@iconify-icons/mingcute/code-line'),
|
||||||
copy: () => import('@iconify-icons/mingcute/copy-2-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'),
|
||||||
};
|
};
|
||||||
|
|
|
@ -29,6 +29,8 @@
|
||||||
line-clamp: 1;
|
line-clamp: 1;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
direction: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
|
@ -33,7 +33,7 @@ function AccountBlock({
|
||||||
<span>
|
<span>
|
||||||
<b>████████</b>
|
<b>████████</b>
|
||||||
<br />
|
<br />
|
||||||
<span class="account-block-acct">@██████</span>
|
<span class="account-block-acct">██████</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -62,6 +62,7 @@ function AccountBlock({
|
||||||
group,
|
group,
|
||||||
followersCount,
|
followersCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
locked,
|
||||||
} = account;
|
} = account;
|
||||||
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
||||||
if (accountInstance) {
|
if (accountInstance) {
|
||||||
|
@ -86,7 +87,7 @@ function AccountBlock({
|
||||||
class="account-block"
|
class="account-block"
|
||||||
href={url}
|
href={url}
|
||||||
target={external ? '_blank' : null}
|
target={external ? '_blank' : null}
|
||||||
title={`@${acct}`}
|
title={acct2 ? acct : `@${acct}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (external) return;
|
if (external) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -119,27 +120,31 @@ function AccountBlock({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
<span class="account-block-acct">
|
<span class="account-block-acct bidi-isolate">
|
||||||
@{acct1}
|
{acct2 ? '' : '@'}
|
||||||
|
{acct1}
|
||||||
<wbr />
|
<wbr />
|
||||||
{acct2}
|
{acct2}
|
||||||
|
{locked && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<Icon icon="lock" size="s" alt="Locked" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{showActivity && (
|
{showActivity && (
|
||||||
<>
|
<div class="account-block-stats">
|
||||||
<br />
|
Posts: {shortenNumber(statusesCount)}
|
||||||
<small class="last-status-at insignificant">
|
{!!lastStatusAt && (
|
||||||
Posts: {statusesCount}
|
<>
|
||||||
{!!lastStatusAt && (
|
{' '}
|
||||||
<>
|
· Last posted:{' '}
|
||||||
{' '}
|
{niceDateTime(lastStatusAt, {
|
||||||
· Last posted:{' '}
|
hideTime: true,
|
||||||
{niceDateTime(lastStatusAt, {
|
})}
|
||||||
hideTime: true,
|
</>
|
||||||
})}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</small>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{showStats && (
|
{showStats && (
|
||||||
<div class="account-block-stats">
|
<div class="account-block-stats">
|
||||||
|
|
|
@ -57,7 +57,7 @@
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
|
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to right,
|
var(--to-forward),
|
||||||
var(--original-color) 0%,
|
var(--original-color) 0%,
|
||||||
var(--original-color) calc(var(--originals-percentage) - var(--gap)),
|
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)),
|
||||||
|
@ -181,8 +181,8 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.sheet .account-container .header-banner {
|
.sheet .account-container .header-banner {
|
||||||
border-top-left-radius: 16px;
|
border-start-start-radius: 16px;
|
||||||
border-top-right-radius: 16px;
|
border-start-end-radius: 16px;
|
||||||
}
|
}
|
||||||
.account-container .header-banner.header-is-avatar {
|
.account-container .header-banner.header-is-avatar {
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
|
@ -288,10 +288,17 @@
|
||||||
align-self: center !important;
|
align-self: center !important;
|
||||||
/* clip a dog ear on top right */
|
/* clip a dog ear on top right */
|
||||||
clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%);
|
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 */
|
/* 4x4px square on top right */
|
||||||
background-size: 4px 4px;
|
background-size: 4px 4px;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: top right;
|
background-position: top right;
|
||||||
|
&:dir(rtl) {
|
||||||
|
background-position: top left;
|
||||||
|
}
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
var(--private-note-border-color),
|
var(--private-note-border-color),
|
||||||
|
@ -311,7 +318,7 @@
|
||||||
box-orient: vertical;
|
box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
line-clamp: 2;
|
line-clamp: 2;
|
||||||
text-align: left;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:active) {
|
&:hover:not(:active) {
|
||||||
|
@ -370,7 +377,8 @@
|
||||||
animation: appear 1s both ease-in-out;
|
animation: appear 1s both ease-in-out;
|
||||||
|
|
||||||
> *:not(:first-child) {
|
> *:not(:first-child) {
|
||||||
margin: 0 0 0 -4px;
|
margin: 0;
|
||||||
|
margin-inline-start: -4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -422,15 +430,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:has(+ .account-metadata-box) {
|
&:has(+ .account-metadata-box) {
|
||||||
border-bottom-left-radius: 4px;
|
border-end-start-radius: 4px;
|
||||||
border-bottom-right-radius: 4px;
|
border-end-end-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
+ .account-metadata-box {
|
+ .account-metadata-box {
|
||||||
border-top-left-radius: 4px;
|
border-start-start-radius: 4px;
|
||||||
border-top-right-radius: 4px;
|
border-start-end-radius: 4px;
|
||||||
border-bottom-left-radius: 16px;
|
border-end-start-radius: 16px;
|
||||||
border-bottom-right-radius: 16px;
|
border-end-end-radius: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -781,3 +789,108 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import './account-info.css';
|
import './account-info.css';
|
||||||
|
|
||||||
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
|
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
@ -9,18 +9,22 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode/';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import enhanceContent from '../utils/enhance-content';
|
import enhanceContent from '../utils/enhance-content';
|
||||||
import getHTMLText from '../utils/getHTMLText';
|
import getHTMLText from '../utils/getHTMLText';
|
||||||
import handleContentLinks from '../utils/handle-content-links';
|
import handleContentLinks from '../utils/handle-content-links';
|
||||||
|
import { getLists } from '../utils/lists';
|
||||||
import niceDateTime from '../utils/nice-date-time';
|
import niceDateTime from '../utils/nice-date-time';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
|
import showCompose from '../utils/show-compose';
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states, { hideAllModals } from '../utils/states';
|
import states, { hideAllModals } from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
import { updateAccount } from '../utils/store-utils';
|
import { getCurrentAccountID, updateAccount } from '../utils/store-utils';
|
||||||
|
import supports from '../utils/supports';
|
||||||
|
|
||||||
import AccountBlock from './account-block';
|
import AccountBlock from './account-block';
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
|
@ -29,9 +33,11 @@ import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
import ListAddEdit from './list-add-edit';
|
import ListAddEdit from './list-add-edit';
|
||||||
import Loader from './loader';
|
import Loader from './loader';
|
||||||
import Menu2 from './menu2';
|
|
||||||
import MenuConfirm from './menu-confirm';
|
import MenuConfirm from './menu-confirm';
|
||||||
|
import MenuLink from './menu-link';
|
||||||
|
import Menu2 from './menu2';
|
||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
|
import SubMenu2 from './submenu2';
|
||||||
import TranslationBlock from './translation-block';
|
import TranslationBlock from './translation-block';
|
||||||
|
|
||||||
const MUTE_DURATIONS = [
|
const MUTE_DURATIONS = [
|
||||||
|
@ -181,6 +187,7 @@ function AccountInfo({
|
||||||
memorial,
|
memorial,
|
||||||
moved,
|
moved,
|
||||||
roles,
|
roles,
|
||||||
|
hideCollections,
|
||||||
} = info || {};
|
} = info || {};
|
||||||
let headerIsAvatar = false;
|
let headerIsAvatar = false;
|
||||||
let { header, headerStatic } = info || {};
|
let { header, headerStatic } = info || {};
|
||||||
|
@ -194,10 +201,7 @@ function AccountInfo({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelf = useMemo(
|
const isSelf = useMemo(() => id === getCurrentAccountID(), [id]);
|
||||||
() => id === store.session.get('currentAccount'),
|
|
||||||
[id],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const infoHasEssentials = !!(
|
const infoHasEssentials = !!(
|
||||||
|
@ -227,7 +231,7 @@ function AccountInfo({
|
||||||
|
|
||||||
const accountInstance = useMemo(() => {
|
const accountInstance = useMemo(() => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const domain = new URL(url).hostname;
|
const domain = punycode.toUnicode(URL.parse(url).hostname);
|
||||||
return domain;
|
return domain;
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
|
@ -250,12 +254,13 @@ function AccountInfo({
|
||||||
// On first load, fetch familiar followers, merge to top of results' `value`
|
// On first load, fetch familiar followers, merge to top of results' `value`
|
||||||
// Remove dups on every fetch
|
// Remove dups on every fetch
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
const familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch(
|
let familiarFollowers = [];
|
||||||
{
|
try {
|
||||||
|
familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch({
|
||||||
id: [id],
|
id: [id],
|
||||||
},
|
});
|
||||||
);
|
} catch (e) {}
|
||||||
familiarFollowersCache.current = familiarFollowers[0].accounts;
|
familiarFollowersCache.current = familiarFollowers?.[0]?.accounts || [];
|
||||||
newValue = [
|
newValue = [
|
||||||
...familiarFollowersCache.current,
|
...familiarFollowersCache.current,
|
||||||
...value.filter(
|
...value.filter(
|
||||||
|
@ -340,6 +345,17 @@ function AccountInfo({
|
||||||
[standalone, id, statusesCount],
|
[standalone, id, statusesCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onProfileUpdate = useCallback(
|
||||||
|
(newAccount) => {
|
||||||
|
if (newAccount.id === id) {
|
||||||
|
console.log('Updated account info', newAccount);
|
||||||
|
setInfo(newAccount);
|
||||||
|
states.accounts[`${newAccount.id}@${instance}`] = newAccount;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id, instance],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
|
@ -453,12 +469,15 @@ function AccountInfo({
|
||||||
e.target.classList.add('loaded');
|
e.target.classList.add('loaded');
|
||||||
try {
|
try {
|
||||||
// Get color from four corners of image
|
// Get color from four corners of image
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = window.OffscreenCanvas
|
||||||
|
? new OffscreenCanvas(1, 1)
|
||||||
|
: document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d', {
|
const ctx = canvas.getContext('2d', {
|
||||||
willReadFrequently: true,
|
willReadFrequently: true,
|
||||||
});
|
});
|
||||||
canvas.width = e.target.width;
|
canvas.width = e.target.width;
|
||||||
canvas.height = e.target.height;
|
canvas.height = e.target.height;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.drawImage(e.target, 0, 0);
|
ctx.drawImage(e.target, 0, 0);
|
||||||
// const colors = [
|
// const colors = [
|
||||||
// ctx.getImageData(0, 0, 1, 1).data,
|
// ctx.getImageData(0, 0, 1, 1).data,
|
||||||
|
@ -526,13 +545,66 @@ function AccountInfo({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<header>
|
<header>
|
||||||
<AccountBlock
|
{standalone ? (
|
||||||
account={info}
|
<Menu2
|
||||||
instance={instance}
|
shift={
|
||||||
avatarSize="xxxl"
|
window.matchMedia('(min-width: calc(40em))').matches
|
||||||
external={standalone}
|
? 114
|
||||||
internal={!standalone}
|
: 64
|
||||||
/>
|
}
|
||||||
|
menuButton={
|
||||||
|
<div>
|
||||||
|
<AccountBlock
|
||||||
|
account={info}
|
||||||
|
instance={instance}
|
||||||
|
avatarSize="xxxl"
|
||||||
|
onClick={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="szh-menu__header">
|
||||||
|
<AccountHandleInfo acct={acct} instance={instance} />
|
||||||
|
</div>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const handleWithInstance = acct.includes('@')
|
||||||
|
? `@${acct}`
|
||||||
|
: `@${acct}@${instance}`;
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(handleWithInstance);
|
||||||
|
showToast('Handle copied');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast('Unable to copy handle');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="link" />
|
||||||
|
<span>Copy handle</span>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem href={url} target="_blank">
|
||||||
|
<Icon icon="external" />
|
||||||
|
<span>Go to original profile page</span>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuLink href={info.avatar} target="_blank">
|
||||||
|
<Icon icon="user" />
|
||||||
|
<span>View profile image</span>
|
||||||
|
</MenuLink>
|
||||||
|
<MenuLink href={info.header} target="_blank">
|
||||||
|
<Icon icon="media" />
|
||||||
|
<span>View profile header</span>
|
||||||
|
</MenuLink>
|
||||||
|
</Menu2>
|
||||||
|
) : (
|
||||||
|
<AccountBlock
|
||||||
|
account={info}
|
||||||
|
instance={instance}
|
||||||
|
avatarSize="xxxl"
|
||||||
|
internal
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<div class="faux-header-bg" aria-hidden="true" />
|
<div class="faux-header-bg" aria-hidden="true" />
|
||||||
<main>
|
<main>
|
||||||
|
@ -602,12 +674,16 @@ function AccountInfo({
|
||||||
// states.showAccount = false;
|
// states.showAccount = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
states.showGenericAccounts = {
|
states.showGenericAccounts = {
|
||||||
|
id: 'followers',
|
||||||
heading: 'Followers',
|
heading: 'Followers',
|
||||||
fetchAccounts: fetchFollowers,
|
fetchAccounts: fetchFollowers,
|
||||||
instance,
|
instance,
|
||||||
excludeRelationshipAttrs: isSelf
|
excludeRelationshipAttrs: isSelf
|
||||||
? ['followedBy']
|
? ['followedBy']
|
||||||
: [],
|
: [],
|
||||||
|
blankCopy: hideCollections
|
||||||
|
? 'This user has chosen to not make this information available.'
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}, 0);
|
}, 0);
|
||||||
}}
|
}}
|
||||||
|
@ -643,6 +719,9 @@ function AccountInfo({
|
||||||
fetchAccounts: fetchFollowing,
|
fetchAccounts: fetchFollowing,
|
||||||
instance,
|
instance,
|
||||||
excludeRelationshipAttrs: isSelf ? ['following'] : [],
|
excludeRelationshipAttrs: isSelf ? ['following'] : [],
|
||||||
|
blankCopy: hideCollections
|
||||||
|
? 'This user has chosen to not make this information available.'
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}, 0);
|
}, 0);
|
||||||
}}
|
}}
|
||||||
|
@ -752,45 +831,49 @@ function AccountInfo({
|
||||||
</div>
|
</div>
|
||||||
</LinkOrDiv>
|
</LinkOrDiv>
|
||||||
)}
|
)}
|
||||||
<div class="account-metadata-box">
|
{!moved && (
|
||||||
<div
|
<div class="account-metadata-box">
|
||||||
class="shazam-container no-animation"
|
<div
|
||||||
hidden={!!postingStats}
|
class="shazam-container no-animation"
|
||||||
>
|
hidden={!!postingStats}
|
||||||
<div class="shazam-container-inner">
|
>
|
||||||
<button
|
<div class="shazam-container-inner">
|
||||||
type="button"
|
<button
|
||||||
class="posting-stats-button"
|
type="button"
|
||||||
disabled={postingStatsUIState === 'loading'}
|
class="posting-stats-button"
|
||||||
onClick={() => {
|
disabled={postingStatsUIState === 'loading'}
|
||||||
renderPostingStats();
|
onClick={() => {
|
||||||
}}
|
renderPostingStats();
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={`posting-stats-bar posting-stats-icon ${
|
|
||||||
postingStatsUIState === 'loading' ? 'loading' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
'--originals-percentage': '33%',
|
|
||||||
'--replies-percentage': '66%',
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
View post stats{' '}
|
<div
|
||||||
{/* <Loader
|
class={`posting-stats-bar posting-stats-icon ${
|
||||||
|
postingStatsUIState === 'loading' ? 'loading' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
'--originals-percentage': '33%',
|
||||||
|
'--replies-percentage': '66%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
View post stats{' '}
|
||||||
|
{/* <Loader
|
||||||
abrupt
|
abrupt
|
||||||
hidden={postingStatsUIState !== 'loading'}
|
hidden={postingStatsUIState !== 'loading'}
|
||||||
/> */}
|
/> */}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<RelatedActions
|
<RelatedActions
|
||||||
info={info}
|
info={info}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
|
standalone={standalone}
|
||||||
authenticated={authenticated}
|
authenticated={authenticated}
|
||||||
onRelationshipChange={onRelationshipChange}
|
onRelationshipChange={onRelationshipChange}
|
||||||
|
onProfileUpdate={onProfileUpdate}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
@ -805,8 +888,10 @@ const FAMILIAR_FOLLOWERS_LIMIT = 3;
|
||||||
function RelatedActions({
|
function RelatedActions({
|
||||||
info,
|
info,
|
||||||
instance,
|
instance,
|
||||||
|
standalone,
|
||||||
authenticated,
|
authenticated,
|
||||||
onRelationshipChange = () => {},
|
onRelationshipChange = () => {},
|
||||||
|
onProfileUpdate = () => {},
|
||||||
}) {
|
}) {
|
||||||
if (!info) return null;
|
if (!info) return null;
|
||||||
const {
|
const {
|
||||||
|
@ -841,9 +926,11 @@ function RelatedActions({
|
||||||
const [currentInfo, setCurrentInfo] = useState(null);
|
const [currentInfo, setCurrentInfo] = useState(null);
|
||||||
const [isSelf, setIsSelf] = useState(false);
|
const [isSelf, setIsSelf] = useState(false);
|
||||||
|
|
||||||
|
const acctWithInstance = acct.includes('@') ? acct : `${acct}@${instance}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (info) {
|
if (info) {
|
||||||
const currentAccount = store.session.get('currentAccount');
|
const currentAccount = getCurrentAccountID();
|
||||||
let currentID;
|
let currentID;
|
||||||
(async () => {
|
(async () => {
|
||||||
if (sameInstance && authenticated) {
|
if (sameInstance && authenticated) {
|
||||||
|
@ -878,7 +965,7 @@ function RelatedActions({
|
||||||
|
|
||||||
accountID.current = currentID;
|
accountID.current = currentID;
|
||||||
|
|
||||||
if (moved) return;
|
// if (moved) return;
|
||||||
|
|
||||||
setRelationshipUIState('loading');
|
setRelationshipUIState('loading');
|
||||||
|
|
||||||
|
@ -917,6 +1004,7 @@ function RelatedActions({
|
||||||
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
|
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
|
||||||
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
|
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
|
||||||
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
|
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
|
||||||
|
const [showEditProfile, setShowEditProfile] = useState(false);
|
||||||
const [lists, setLists] = useState([]);
|
const [lists, setLists] = useState([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -998,11 +1086,11 @@ function RelatedActions({
|
||||||
<>
|
<>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
states.showCompose = {
|
showCompose({
|
||||||
draftStatus: {
|
draftStatus: {
|
||||||
status: `@${currentInfo?.acct || acct} `,
|
status: `@${currentInfo?.acct || acct} `,
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="at" />
|
<Icon icon="at" />
|
||||||
|
@ -1016,16 +1104,82 @@ function RelatedActions({
|
||||||
<Icon icon="translate" />
|
<Icon icon="translate" />
|
||||||
<span>Translate bio</span>
|
<span>Translate bio</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
{supports('@mastodon/profile-private-note') && (
|
||||||
onClick={() => {
|
<MenuItem
|
||||||
setShowPrivateNoteModal(true);
|
onClick={() => {
|
||||||
}}
|
setShowPrivateNoteModal(true);
|
||||||
>
|
}}
|
||||||
<Icon icon="pencil" />
|
>
|
||||||
<span>
|
<Icon icon="pencil" />
|
||||||
{privateNote ? 'Edit private note' : 'Add private note'}
|
<span>
|
||||||
</span>
|
{privateNote ? 'Edit private note' : 'Add private note'}
|
||||||
</MenuItem>
|
</span>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{following && !!relationship && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rel = await currentMasto.v1.accounts
|
||||||
|
.$select(accountID.current)
|
||||||
|
.follow({
|
||||||
|
notify: !notifying,
|
||||||
|
});
|
||||||
|
if (rel) setRelationship(rel);
|
||||||
|
setRelationshipUIState('default');
|
||||||
|
showToast(
|
||||||
|
rel.notifying
|
||||||
|
? `Notifications enabled for @${username}'s posts.`
|
||||||
|
: ` Notifications disabled for @${username}'s posts.`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="notification" />
|
||||||
|
<span>
|
||||||
|
{notifying
|
||||||
|
? 'Disable notifications'
|
||||||
|
: 'Enable notifications'}
|
||||||
|
</span>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rel = await currentMasto.v1.accounts
|
||||||
|
.$select(accountID.current)
|
||||||
|
.follow({
|
||||||
|
reblogs: !showingReblogs,
|
||||||
|
});
|
||||||
|
if (rel) setRelationship(rel);
|
||||||
|
setRelationshipUIState('default');
|
||||||
|
showToast(
|
||||||
|
rel.showingReblogs
|
||||||
|
? `Boosts from @${username} enabled.`
|
||||||
|
: `Boosts from @${username} disabled.`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="rocket" />
|
||||||
|
<span>
|
||||||
|
{showingReblogs ? 'Disable boosts' : 'Enable boosts'}
|
||||||
|
</span>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Add/remove from lists is only possible if following the account */}
|
{/* Add/remove from lists is only possible if following the account */}
|
||||||
{following && (
|
{following && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
@ -1055,7 +1209,7 @@ function RelatedActions({
|
||||||
)}
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const handle = `@${currentInfo?.acct || acct}`;
|
const handle = `@${currentInfo?.acct || acctWithInstance}`;
|
||||||
try {
|
try {
|
||||||
navigator.clipboard.writeText(handle);
|
navigator.clipboard.writeText(handle);
|
||||||
showToast('Handle copied');
|
showToast('Handle copied');
|
||||||
|
@ -1069,8 +1223,8 @@ function RelatedActions({
|
||||||
<small>
|
<small>
|
||||||
Copy handle
|
Copy handle
|
||||||
<br />
|
<br />
|
||||||
<span class="more-insignificant">
|
<span class="more-insignificant bidi-isolate">
|
||||||
@{currentInfo?.acct || acct}
|
@{currentInfo?.acct || acctWithInstance}
|
||||||
</span>
|
</span>
|
||||||
</small>
|
</small>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
@ -1144,7 +1298,7 @@ function RelatedActions({
|
||||||
<span>Unmute @{username}</span>
|
<span>Unmute @{username}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : (
|
) : (
|
||||||
<SubMenu
|
<SubMenu2
|
||||||
menuClassName="menu-blur"
|
menuClassName="menu-blur"
|
||||||
openTrigger="clickOnly"
|
openTrigger="clickOnly"
|
||||||
direction="bottom"
|
direction="bottom"
|
||||||
|
@ -1198,7 +1352,44 @@ function RelatedActions({
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SubMenu>
|
</SubMenu2>
|
||||||
|
)}
|
||||||
|
{followedBy && (
|
||||||
|
<MenuConfirm
|
||||||
|
subMenu
|
||||||
|
menuItemClassName="danger"
|
||||||
|
confirmLabel={
|
||||||
|
<>
|
||||||
|
<Icon icon="user-x" />
|
||||||
|
<span>Remove @{username} from followers?</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setRelationshipUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const newRelationship = await currentMasto.v1.accounts
|
||||||
|
.$select(currentInfo?.id || id)
|
||||||
|
.removeFromFollowers();
|
||||||
|
console.log(
|
||||||
|
'removing from followers',
|
||||||
|
newRelationship,
|
||||||
|
);
|
||||||
|
setRelationship(newRelationship);
|
||||||
|
setRelationshipUIState('default');
|
||||||
|
showToast(`@${username} removed from followers`);
|
||||||
|
states.reloadGenericAccounts.id = 'followers';
|
||||||
|
states.reloadGenericAccounts.counter++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setRelationshipUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="user-x" />
|
||||||
|
<span>Remove follower…</span>
|
||||||
|
</MenuConfirm>
|
||||||
)}
|
)}
|
||||||
<MenuConfirm
|
<MenuConfirm
|
||||||
subMenu
|
subMenu
|
||||||
|
@ -1273,6 +1464,22 @@ function RelatedActions({
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{currentAuthenticated &&
|
||||||
|
isSelf &&
|
||||||
|
standalone &&
|
||||||
|
supports('@mastodon/profile-edit') && (
|
||||||
|
<>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditProfile(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="pencil" />
|
||||||
|
<span>Edit profile</span>
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{import.meta.env.DEV && currentAuthenticated && isSelf && (
|
{import.meta.env.DEV && currentAuthenticated && isSelf && (
|
||||||
<>
|
<>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
@ -1298,7 +1505,7 @@ function RelatedActions({
|
||||||
{!relationship && relationshipUIState === 'loading' && (
|
{!relationship && relationshipUIState === 'loading' && (
|
||||||
<Loader abrupt />
|
<Loader abrupt />
|
||||||
)}
|
)}
|
||||||
{!!relationship && (
|
{!!relationship && !moved && (
|
||||||
<MenuConfirm
|
<MenuConfirm
|
||||||
confirm={following || requested}
|
confirm={following || requested}
|
||||||
confirmLabel={
|
confirmLabel={
|
||||||
|
@ -1414,6 +1621,22 @@ function RelatedActions({
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{!!showEditProfile && (
|
||||||
|
<Modal
|
||||||
|
onClose={() => {
|
||||||
|
setShowEditProfile(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditProfileSheet
|
||||||
|
onClose={({ state, account } = {}) => {
|
||||||
|
setShowEditProfile(false);
|
||||||
|
if (state === 'success' && account) {
|
||||||
|
onProfileUpdate(account);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1436,12 +1659,12 @@ function lightenRGB([r, g, b]) {
|
||||||
|
|
||||||
function niceAccountURL(url) {
|
function niceAccountURL(url) {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
const urlObj = new URL(url);
|
const urlObj = URL.parse(url);
|
||||||
const { host, pathname } = urlObj;
|
const { host, pathname } = urlObj;
|
||||||
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
|
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span class="more-insignificant">{host}/</span>
|
<span class="more-insignificant">{punycode.toUnicode(host)}/</span>
|
||||||
<wbr />
|
<wbr />
|
||||||
<span>{path}</span>
|
<span>{path}</span>
|
||||||
</>
|
</>
|
||||||
|
@ -1491,13 +1714,12 @@ function AddRemoveListsSheet({ accountID, onClose }) {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const lists = await masto.v1.lists.list();
|
const lists = await getLists();
|
||||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
setLists(lists);
|
||||||
const listsContainingAccount = await masto.v1.accounts
|
const listsContainingAccount = await masto.v1.accounts
|
||||||
.$select(accountID)
|
.$select(accountID)
|
||||||
.lists.list();
|
.lists.list();
|
||||||
console.log({ lists, listsContainingAccount });
|
console.log({ lists, listsContainingAccount });
|
||||||
setLists(lists);
|
|
||||||
setListsContainingAccount(listsContainingAccount);
|
setListsContainingAccount(listsContainingAccount);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -1675,6 +1897,7 @@ function PrivateNoteSheet({
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
name="note"
|
name="note"
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
|
dir="auto"
|
||||||
>
|
>
|
||||||
{initialNote}
|
{initialNote}
|
||||||
</textarea>
|
</textarea>
|
||||||
|
@ -1702,4 +1925,217 @@ function PrivateNoteSheet({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditProfileSheet({ onClose = () => {} }) {
|
||||||
|
const { masto } = api();
|
||||||
|
const [uiState, setUIState] = useState('loading');
|
||||||
|
const [account, setAccount] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const acc = await masto.v1.accounts.verifyCredentials();
|
||||||
|
setAccount(acc);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
console.log('EditProfileSheet', account);
|
||||||
|
const { displayName, source } = account || {};
|
||||||
|
const { note, fields } = source || {};
|
||||||
|
const fieldsAttributesRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sheet" id="edit-profile-container">
|
||||||
|
{!!onClose && (
|
||||||
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<header>
|
||||||
|
<b>Edit profile</b>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{uiState === 'loading' ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
<Loader abrupt />
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const displayName = formData.get('display_name');
|
||||||
|
const note = formData.get('note');
|
||||||
|
const fieldsAttributesFields =
|
||||||
|
fieldsAttributesRef.current.querySelectorAll(
|
||||||
|
'input[name^="fields_attributes"]',
|
||||||
|
);
|
||||||
|
const fieldsAttributes = [];
|
||||||
|
fieldsAttributesFields.forEach((field) => {
|
||||||
|
const name = field.name;
|
||||||
|
const [_, index, key] =
|
||||||
|
name.match(/fields_attributes\[(\d+)\]\[(.+)\]/) || [];
|
||||||
|
const value = field.value ? field.value.trim() : '';
|
||||||
|
if (index && key && value) {
|
||||||
|
if (!fieldsAttributes[index]) fieldsAttributes[index] = {};
|
||||||
|
fieldsAttributes[index][key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Fill in the blanks
|
||||||
|
fieldsAttributes.forEach((field) => {
|
||||||
|
if (field.name && !field.value) {
|
||||||
|
field.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const newAccount = await masto.v1.accounts.updateCredentials({
|
||||||
|
displayName,
|
||||||
|
note,
|
||||||
|
fieldsAttributes,
|
||||||
|
});
|
||||||
|
console.log('updated account', newAccount);
|
||||||
|
onClose?.({
|
||||||
|
state: 'success',
|
||||||
|
account: newAccount,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e?.message || 'Unable to update profile.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
Name{' '}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="display_name"
|
||||||
|
defaultValue={displayName}
|
||||||
|
maxLength={30}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
Bio
|
||||||
|
<textarea
|
||||||
|
defaultValue={note}
|
||||||
|
name="note"
|
||||||
|
maxLength={500}
|
||||||
|
rows="5"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
{/* Table for fields; name and values are in fields, min 4 rows */}
|
||||||
|
<p>Extra fields</p>
|
||||||
|
<table ref={fieldsAttributesRef}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Content</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: Math.max(4, fields.length) }).map(
|
||||||
|
(_, i) => {
|
||||||
|
const { name = '', value = '' } = fields[i] || {};
|
||||||
|
return (
|
||||||
|
<FieldsAttributesRow
|
||||||
|
key={i}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
index={i}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<footer>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={uiState === 'loading'}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldsAttributesRow({ name, value, disabled, index: i }) {
|
||||||
|
const [hasValue, setHasValue] = useState(!!value);
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`fields_attributes[${i}][name]`}
|
||||||
|
defaultValue={name}
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={255}
|
||||||
|
required={hasValue}
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`fields_attributes[${i}][value]`}
|
||||||
|
defaultValue={value}
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={255}
|
||||||
|
onChange={(e) => setHasValue(!!e.currentTarget.value)}
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountHandleInfo({ acct, instance }) {
|
||||||
|
// acct = username or username@server
|
||||||
|
let [username, server] = acct.split('@');
|
||||||
|
if (!server) server = instance;
|
||||||
|
return (
|
||||||
|
<div class="handle-info">
|
||||||
|
<span class="handle-handle">
|
||||||
|
<b class="handle-username">{username}</b>
|
||||||
|
<span class="handle-at">@</span>
|
||||||
|
<b class="handle-server">{server}</b>
|
||||||
|
</span>
|
||||||
|
<div class="handle-legend">
|
||||||
|
<span class="ib">
|
||||||
|
<span class="handle-legend-icon username" /> username
|
||||||
|
</span>{' '}
|
||||||
|
<span class="ib">
|
||||||
|
<span class="handle-legend-icon server" /> server domain name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default AccountInfo;
|
export default AccountInfo;
|
||||||
|
|
|
@ -58,7 +58,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
|
||||||
if (result.accounts.length) {
|
if (result.accounts.length) {
|
||||||
return result.accounts[0];
|
return result.accounts[0];
|
||||||
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
|
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
|
||||||
const accountURL = new URL(account);
|
const accountURL = URL.parse(account);
|
||||||
const { hostname, pathname } = accountURL;
|
const { hostname, pathname } = accountURL;
|
||||||
const acct =
|
const acct =
|
||||||
pathname.replace(/^\//, '').replace(/\/$/, '') +
|
pathname.replace(/^\//, '').replace(/\/$/, '') +
|
||||||
|
|
|
@ -21,6 +21,7 @@ const canvas = window.OffscreenCanvas
|
||||||
const ctx = canvas.getContext('2d', {
|
const ctx = canvas.getContext('2d', {
|
||||||
willReadFrequently: true,
|
willReadFrequently: true,
|
||||||
});
|
});
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
function Avatar({ url, size, alt = '', squircle, ...props }) {
|
function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||||
size = SIZES[size] || size || SIZES.m;
|
size = SIZES[size] || size || SIZES.m;
|
||||||
|
@ -62,7 +63,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||||
if (avatarRef.current) avatarRef.current.dataset.loaded = true;
|
if (avatarRef.current) avatarRef.current.dataset.loaded = true;
|
||||||
if (alphaCache[url] !== undefined) return;
|
if (alphaCache[url] !== undefined) return;
|
||||||
if (isMissing) return;
|
if (isMissing) return;
|
||||||
queueMicrotask(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
// Check if image has alpha channel
|
// Check if image has alpha channel
|
||||||
const { width, height } = e.target;
|
const { width, height } = e.target;
|
||||||
|
@ -87,7 +88,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||||
// Silent fail
|
// Silent fail
|
||||||
alphaCache[url] = false;
|
alphaCache[url] = false;
|
||||||
}
|
}
|
||||||
});
|
}, 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import useInterval from '../utils/useInterval';
|
||||||
import usePageVisibility from '../utils/usePageVisibility';
|
import usePageVisibility from '../utils/usePageVisibility';
|
||||||
|
|
||||||
const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds
|
const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds
|
||||||
const POLL_INTERVAL = 15_000; // 15 seconds
|
const POLL_INTERVAL = 20_000; // 20 seconds
|
||||||
|
|
||||||
export default memo(function BackgroundService({ isLoggedIn }) {
|
export default memo(function BackgroundService({ isLoggedIn }) {
|
||||||
// Notifications service
|
// Notifications service
|
||||||
|
@ -46,6 +46,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let sub;
|
let sub;
|
||||||
|
let streamTimeout;
|
||||||
let pollNotifications;
|
let pollNotifications;
|
||||||
if (isLoggedIn && visible) {
|
if (isLoggedIn && visible) {
|
||||||
const { masto, streaming, instance } = api();
|
const { masto, streaming, instance } = api();
|
||||||
|
@ -56,7 +57,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
|
||||||
let hasStreaming = false;
|
let hasStreaming = false;
|
||||||
// 2. Start streaming
|
// 2. Start streaming
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
pollNotifications = setTimeout(() => {
|
streamTimeout = setTimeout(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
hasStreaming = true;
|
hasStreaming = true;
|
||||||
|
@ -94,7 +95,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
|
||||||
return () => {
|
return () => {
|
||||||
sub?.unsubscribe?.();
|
sub?.unsubscribe?.();
|
||||||
sub = null;
|
sub = null;
|
||||||
clearTimeout(pollNotifications);
|
clearTimeout(streamTimeout);
|
||||||
clearInterval(pollNotifications);
|
clearInterval(pollNotifications);
|
||||||
};
|
};
|
||||||
}, [visible, isLoggedIn]);
|
}, [visible, isLoggedIn]);
|
||||||
|
|
|
@ -39,6 +39,8 @@ function Columns() {
|
||||||
if (!Component) return null;
|
if (!Component) return null;
|
||||||
// Don't show Search column with no query, for now
|
// Don't show Search column with no query, for now
|
||||||
if (type === 'search' && !params.query) return null;
|
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 (
|
return (
|
||||||
<Component key={type + JSON.stringify(params)} {...params} columnMode />
|
<Component key={type + JSON.stringify(params)} {...params} columnMode />
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,12 +1,22 @@
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import openCompose from '../utils/open-compose';
|
import openCompose from '../utils/open-compose';
|
||||||
|
import openOSK from '../utils/open-osk';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
|
||||||
export default function ComposeButton() {
|
export default function ComposeButton() {
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
|
||||||
function handleButton(e) {
|
function handleButton(e) {
|
||||||
|
if (snapStates.composerState.minimized) {
|
||||||
|
states.composerState.minimized = false;
|
||||||
|
openOSK();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
const newWin = openCompose();
|
const newWin = openCompose();
|
||||||
|
|
||||||
|
@ -14,6 +24,7 @@ export default function ComposeButton() {
|
||||||
states.showCompose = true;
|
states.showCompose = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
openOSK();
|
||||||
states.showCompose = true;
|
states.showCompose = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +37,14 @@ export default function ComposeButton() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" id="compose-button" onClick={handleButton}>
|
<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" />
|
<Icon icon="quill" size="xl" alt="Compose" />
|
||||||
</button>
|
</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 {
|
#compose-container .compose-top {
|
||||||
text-align: right;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
@ -62,7 +61,7 @@
|
||||||
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
|
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
|
||||||
}
|
}
|
||||||
#compose-container .status-preview:has(.status-badge:not(:empty)) {
|
#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) {
|
#compose-container .status-preview :is(.content-container, .time) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
@ -95,6 +94,10 @@
|
||||||
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
|
||||||
0 1px 10px var(--bg-color);
|
0 1px 10px var(--bg-color);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--red-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#_compose-container .status-preview-legend.reply-to {
|
#_compose-container .status-preview-legend.reply-to {
|
||||||
color: var(--reply-to-color);
|
color: var(--reply-to-color);
|
||||||
|
@ -107,8 +110,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#compose-container form {
|
#compose-container form {
|
||||||
--form-padding-inline: 12px;
|
--form-padding-inline: 8px;
|
||||||
--form-padding-block: 8px;
|
--form-padding-block: 0;
|
||||||
/* border-radius: 16px; */
|
/* border-radius: 16px; */
|
||||||
padding: var(--form-padding-block) var(--form-padding-inline);
|
padding: var(--form-padding-block) var(--form-padding-inline);
|
||||||
background-color: var(--bg-blur-color);
|
background-color: var(--bg-blur-color);
|
||||||
|
@ -204,7 +207,7 @@
|
||||||
left: -100vw !important;
|
left: -100vw !important;
|
||||||
}
|
}
|
||||||
#compose-container .toolbar-button select {
|
#compose-container .toolbar-button select {
|
||||||
background-color: transparent;
|
background-color: inherit;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0 0 0 8px;
|
padding: 0 0 0 8px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -212,8 +215,8 @@
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
}
|
}
|
||||||
#compose-container .toolbar-button:not(.show-field) select {
|
#compose-container .toolbar-button:not(.show-field) select {
|
||||||
right: 0;
|
inset-inline-end: 0;
|
||||||
left: auto !important;
|
inset-inline-start: auto !important;
|
||||||
}
|
}
|
||||||
#compose-container
|
#compose-container
|
||||||
.toolbar-button:not(:disabled):is(
|
.toolbar-button:not(:disabled):is(
|
||||||
|
@ -294,19 +297,28 @@
|
||||||
height: 2.2em;
|
height: 2.2em;
|
||||||
}
|
}
|
||||||
#compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) {
|
#compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) {
|
||||||
color: var(--bg-color);
|
background-color: var(--link-bg-color);
|
||||||
background-color: var(--link-color);
|
|
||||||
}
|
|
||||||
#compose-container
|
|
||||||
.text-expander-menu:hover
|
|
||||||
li[aria-selected]:not(:hover, :focus) {
|
|
||||||
color: var(--text-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 {
|
#compose-container .form-visibility-direct {
|
||||||
--yellow-stripes: repeating-linear-gradient(
|
--yellow-stripes: repeating-linear-gradient(
|
||||||
-45deg,
|
135deg,
|
||||||
var(--reply-to-faded-color),
|
var(--reply-to-faded-color),
|
||||||
var(--reply-to-faded-color) 10px,
|
var(--reply-to-faded-color) 10px,
|
||||||
var(--reply-to-faded-color) 10px,
|
var(--reply-to-faded-color) 10px,
|
||||||
|
@ -330,6 +342,21 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: stretch;
|
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 {
|
#compose-container .media-preview {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
@ -469,14 +496,14 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-left: 1px solid var(--outline-color);
|
border-inline-start: 1px solid var(--outline-color);
|
||||||
padding-left: 8px;
|
padding-inline-start: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#compose-container .expires-in {
|
#compose-container .expires-in {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
border-left: 1px solid var(--outline-color);
|
border-inline-start: 1px solid var(--outline-color);
|
||||||
padding-left: 8px;
|
padding-inline-start: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
@ -496,8 +523,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
#compose-container button[type='submit'] {
|
||||||
#compose-container button[type='submit'] {
|
border-radius: 8px;
|
||||||
|
@media (min-width: 480px) {
|
||||||
padding-inline: 24px;
|
padding-inline: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -590,44 +618,194 @@
|
||||||
} */
|
} */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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 {
|
#custom-emojis-sheet {
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
max-height: 50dvh;
|
max-height: 50dvh;
|
||||||
}
|
|
||||||
#custom-emojis-sheet main {
|
header {
|
||||||
mask-image: none;
|
.loader-container {
|
||||||
}
|
margin: 0;
|
||||||
#custom-emojis-sheet .custom-emojis-list .section-header {
|
}
|
||||||
font-size: 80%;
|
|
||||||
text-transform: uppercase;
|
form {
|
||||||
color: var(--text-insignificant-color);
|
margin: 8px 0 0;
|
||||||
padding: 8px 0 4px;
|
|
||||||
position: sticky;
|
input {
|
||||||
top: 0;
|
width: 100%;
|
||||||
background-color: var(--bg-blur-color);
|
min-width: 0;
|
||||||
backdrop-filter: blur(1px);
|
}
|
||||||
}
|
}
|
||||||
#custom-emojis-sheet .custom-emojis-list section {
|
}
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
main {
|
||||||
}
|
mask-image: none;
|
||||||
#custom-emojis-sheet .custom-emojis-list button {
|
min-height: 40vh;
|
||||||
border-radius: 8px;
|
padding-bottom: 88px;
|
||||||
background-image: radial-gradient(
|
}
|
||||||
closest-side,
|
|
||||||
var(--img-bg-color),
|
.custom-emojis-matches {
|
||||||
transparent
|
margin: 0;
|
||||||
);
|
padding: 0;
|
||||||
}
|
list-style: none;
|
||||||
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
|
display: flex;
|
||||||
filter: none;
|
flex-wrap: wrap;
|
||||||
background-color: var(--bg-faded-color);
|
}
|
||||||
}
|
|
||||||
#custom-emojis-sheet .custom-emojis-list button img {
|
.custom-emojis-list {
|
||||||
transition: transform 0.1s ease-out;
|
.section-header {
|
||||||
}
|
font-size: 80%;
|
||||||
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
|
text-transform: uppercase;
|
||||||
transform: scale(1.5);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-field-container {
|
.compose-field-container {
|
||||||
|
@ -723,3 +901,165 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes gif-shake {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(-5deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gif-picker-button {
|
||||||
|
span {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 11.5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:is(:hover, :focus) {
|
||||||
|
span {
|
||||||
|
animation: gif-shake 0.3s 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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);
|
background-color: var(--bg-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: 1px solid var(--link-faded-color);
|
border: 1px solid var(--link-faded-color);
|
||||||
text-align: left;
|
text-align: start;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
button.draft-item:is(:hover, :focus) {
|
button.draft-item:is(:hover, :focus) {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
import CustomEmoji from './custom-emoji';
|
||||||
|
|
||||||
function EmojiText({ text, emojis }) {
|
function EmojiText({ text, emojis }) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
if (!emojis?.length) return text;
|
if (!emojis?.length) return text;
|
||||||
|
@ -12,21 +14,7 @@ function EmojiText({ text, emojis }) {
|
||||||
const emoji = emojis.find((e) => e.shortcode === word);
|
const emoji = emojis.find((e) => e.shortcode === word);
|
||||||
if (emoji) {
|
if (emoji) {
|
||||||
const { url, staticUrl } = emoji;
|
const { url, staticUrl } = emoji;
|
||||||
return (
|
return <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />;
|
||||||
<picture>
|
|
||||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
|
||||||
<img
|
|
||||||
key={word}
|
|
||||||
src={url}
|
|
||||||
alt={word}
|
|
||||||
class="shortcode-emoji emoji"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return word;
|
return word;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,39 @@
|
||||||
#generic-accounts-container {
|
#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 {
|
.accounts-list {
|
||||||
--list-gap: 16px;
|
--list-gap: 16px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
@ -27,13 +62,13 @@
|
||||||
border-top: var(--hairline-width) solid var(--divider-color);
|
border-top: var(--hairline-width) solid var(--divider-color);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(-1 * var(--list-gap) / 2);
|
bottom: calc(-1 * var(--list-gap) / 2);
|
||||||
left: 40px;
|
inset-inline-start: 40px;
|
||||||
right: 0;
|
inset-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:has(.reactions-block):before {
|
&:has(.reactions-block):before {
|
||||||
/* avatar + reactions + gap */
|
/* avatar + reactions + gap */
|
||||||
left: calc(40px + 16px + 8px);
|
inset-inline-start: calc(40px + 16px + 8px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,12 +11,16 @@ import useLocationChange from '../utils/useLocationChange';
|
||||||
|
|
||||||
import AccountBlock from './account-block';
|
import AccountBlock from './account-block';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
import Link from './link';
|
||||||
import Loader from './loader';
|
import Loader from './loader';
|
||||||
|
import Status from './status';
|
||||||
|
|
||||||
export default function GenericAccounts({
|
export default function GenericAccounts({
|
||||||
instance,
|
instance,
|
||||||
excludeRelationshipAttrs = [],
|
excludeRelationshipAttrs = [],
|
||||||
|
postID,
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
|
blankCopy = 'Nothing to show',
|
||||||
}) {
|
}) {
|
||||||
const { masto, instance: currentInstance } = api();
|
const { masto, instance: currentInstance } = api();
|
||||||
const isCurrentInstance = instance ? instance === currentInstance : true;
|
const isCurrentInstance = instance ? instance === currentInstance : true;
|
||||||
|
@ -129,6 +133,8 @@ export default function GenericAccounts({
|
||||||
}
|
}
|
||||||
}, [snapStates.reloadGenericAccounts.counter]);
|
}, [snapStates.reloadGenericAccounts.counter]);
|
||||||
|
|
||||||
|
const post = states.statuses[postID];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="generic-accounts-container" class="sheet" tabindex="-1">
|
<div id="generic-accounts-container" class="sheet" tabindex="-1">
|
||||||
<button type="button" class="sheet-close" onClick={onClose}>
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
|
@ -138,6 +144,14 @@ export default function GenericAccounts({
|
||||||
<h2>{heading || 'Accounts'}</h2>
|
<h2>{heading || 'Accounts'}</h2>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
{post && (
|
||||||
|
<Link
|
||||||
|
to={`/${instance || currentInstance}/s/${post.id}`}
|
||||||
|
class="post-preview"
|
||||||
|
>
|
||||||
|
<Status status={post} size="s" readOnly />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{accounts.length > 0 ? (
|
{accounts.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<ul class="accounts-list">
|
<ul class="accounts-list">
|
||||||
|
@ -208,7 +222,7 @@ export default function GenericAccounts({
|
||||||
) : uiState === 'error' ? (
|
) : uiState === 'error' ? (
|
||||||
<p class="ui-state">Error loading accounts</p>
|
<p class="ui-state">Error loading accounts</p>
|
||||||
) : (
|
) : (
|
||||||
<p class="ui-state insignificant">Nothing to show</p>
|
<p class="ui-state insignificant">{blankCopy}</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -53,9 +53,14 @@ function Icon({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rotate, flip;
|
let rotate,
|
||||||
|
flip,
|
||||||
|
rtl = false;
|
||||||
if (Array.isArray(iconBlock)) {
|
if (Array.isArray(iconBlock)) {
|
||||||
[iconBlock, rotate, flip] = iconBlock;
|
[iconBlock, rotate, flip] = iconBlock;
|
||||||
|
} else if (typeof iconBlock === 'object') {
|
||||||
|
({ rotate, flip, rtl } = iconBlock);
|
||||||
|
iconBlock = iconBlock.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [iconData, setIconData] = useState(ICONDATA[icon]);
|
const [iconData, setIconData] = useState(ICONDATA[icon]);
|
||||||
|
@ -72,13 +77,14 @@ function Icon({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
class={`icon ${className}`}
|
class={`icon ${className} ${rtl ? 'rtl-flip' : ''}`}
|
||||||
title={title || alt}
|
title={title || alt}
|
||||||
style={{
|
style={{
|
||||||
width: `${iconSize}px`,
|
width: `${iconSize}px`,
|
||||||
height: `${iconSize}px`,
|
height: `${iconSize}px`,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
|
data-icon={icon}
|
||||||
>
|
>
|
||||||
{iconData && (
|
{iconData && (
|
||||||
// <svg
|
// <svg
|
||||||
|
|
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 />
|
||||||
|
);
|
||||||
|
}
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -22,15 +22,13 @@ const Link = forwardRef((props, ref) => {
|
||||||
|
|
||||||
// Handle encodeURIComponent of searchParams values
|
// Handle encodeURIComponent of searchParams values
|
||||||
if (!!hash && hash !== '/' && hash.includes('?')) {
|
if (!!hash && hash !== '/' && hash.includes('?')) {
|
||||||
try {
|
const parsedHash = URL.parse(hash, location.origin); // Fake base URL
|
||||||
const parsedHash = new URL(hash, location.origin); // Fake base URL
|
if (parsedHash?.searchParams?.size) {
|
||||||
if (parsedHash.searchParams.size) {
|
const searchParamsStr = Array.from(parsedHash.searchParams.entries())
|
||||||
const searchParamsStr = Array.from(parsedHash.searchParams.entries())
|
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
.join('&');
|
||||||
.join('&');
|
hash = parsedHash.pathname + '?' + searchParamsStr;
|
||||||
hash = parsedHash.pathname + '?' + searchParamsStr;
|
}
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActive = hash === to || decodeURIComponent(hash) === to;
|
const isActive = hash === to || decodeURIComponent(hash) === to;
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
to right,
|
var(--to-forward),
|
||||||
transparent,
|
transparent,
|
||||||
black 16px,
|
black 16px,
|
||||||
black calc(100% - 16px),
|
black calc(100% - 16px),
|
||||||
|
@ -20,6 +20,9 @@
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
max-width: calc(320px * 3.3);
|
max-width: calc(320px * 3.3);
|
||||||
transform: translateX(calc(-50% + var(--main-width) / 2));
|
transform: translateX(calc(-50% + var(--main-width) / 2));
|
||||||
|
&:dir(rtl) {
|
||||||
|
transform: translateX(calc(50% - var(--main-width) / 2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,12 +41,16 @@
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
left: 0;
|
inset-inline-start: 0;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
transform: rotate(-90deg) translateX(-100%);
|
transform: rotate(-90deg) translateX(-100%);
|
||||||
|
&:dir(rtl) {
|
||||||
|
transform-origin: top right;
|
||||||
|
transform: rotate(90deg) translateX(100%);
|
||||||
|
}
|
||||||
user-select: none;
|
user-select: none;
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to left,
|
var(--to-backward),
|
||||||
var(--text-color),
|
var(--text-color),
|
||||||
var(--link-color)
|
var(--link-color)
|
||||||
);
|
);
|
||||||
|
@ -95,6 +102,29 @@
|
||||||
filter: brightness(0.8);
|
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 {
|
article {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { addListStore, deleteListStore, updateListStore } from '../utils/lists';
|
||||||
import supports from '../utils/supports';
|
import supports from '../utils/supports';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
@ -75,6 +76,14 @@ function ListAddEdit({ list, onClose }) {
|
||||||
state: 'success',
|
state: 'success',
|
||||||
list: listResult,
|
list: listResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editMode) {
|
||||||
|
updateListStore(listResult);
|
||||||
|
} else {
|
||||||
|
addListStore(listResult);
|
||||||
|
}
|
||||||
|
}, 1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
|
@ -146,6 +155,9 @@ function ListAddEdit({ list, onClose }) {
|
||||||
onClose?.({
|
onClose?.({
|
||||||
state: 'deleted',
|
state: 'deleted',
|
||||||
});
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
deleteListStore(list.id);
|
||||||
|
}, 1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
|
|
|
@ -10,14 +10,15 @@ import {
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
|
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
|
||||||
|
import isRTL from '../utils/is-rtl';
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
import Media from './media';
|
import Media from './media';
|
||||||
import Menu2 from './menu2';
|
|
||||||
import MenuLink from './menu-link';
|
import MenuLink from './menu-link';
|
||||||
|
import Menu2 from './menu2';
|
||||||
|
|
||||||
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
|
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
|
||||||
|
|
||||||
|
@ -54,7 +55,7 @@ function MediaModal({
|
||||||
const differentStatusID = prevStatusID.current !== statusID;
|
const differentStatusID = prevStatusID.current !== statusID;
|
||||||
if (differentStatusID) prevStatusID.current = statusID;
|
if (differentStatusID) prevStatusID.current = statusID;
|
||||||
carouselRef.current.scrollTo({
|
carouselRef.current.scrollTo({
|
||||||
left: scrollLeft,
|
left: scrollLeft * (isRTL() ? -1 : 1),
|
||||||
behavior: differentStatusID ? 'auto' : 'smooth',
|
behavior: differentStatusID ? 'auto' : 'smooth',
|
||||||
});
|
});
|
||||||
carouselRef.current.focus();
|
carouselRef.current.focus();
|
||||||
|
@ -91,7 +92,7 @@ function MediaModal({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let handleScroll = () => {
|
let handleScroll = () => {
|
||||||
const { clientWidth, scrollLeft } = carouselRef.current;
|
const { clientWidth, scrollLeft } = carouselRef.current;
|
||||||
const index = Math.round(scrollLeft / clientWidth);
|
const index = Math.round(Math.abs(scrollLeft) / clientWidth);
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
};
|
};
|
||||||
if (carouselRef.current) {
|
if (carouselRef.current) {
|
||||||
|
@ -178,7 +179,7 @@ function MediaModal({
|
||||||
? {
|
? {
|
||||||
backgroundAttachment: 'local',
|
backgroundAttachment: 'local',
|
||||||
backgroundImage: `linear-gradient(
|
backgroundImage: `linear-gradient(
|
||||||
to right, ${mediaAccentGradient})`,
|
to ${isRTL() ? 'left' : 'right'}, ${mediaAccentGradient})`,
|
||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
|
@ -257,7 +258,8 @@ function MediaModal({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
carouselRef.current.scrollTo({
|
carouselRef.current.scrollTo({
|
||||||
left: carouselRef.current.clientWidth * i,
|
left:
|
||||||
|
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1),
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
carouselRef.current.focus();
|
carouselRef.current.focus();
|
||||||
|
@ -368,7 +370,10 @@ function MediaModal({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
carouselRef.current.focus();
|
carouselRef.current.focus();
|
||||||
carouselRef.current.scrollTo({
|
carouselRef.current.scrollTo({
|
||||||
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
left:
|
||||||
|
carouselRef.current.clientWidth *
|
||||||
|
(currentIndex - 1) *
|
||||||
|
(isRTL() ? -1 : 1),
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
@ -384,7 +389,10 @@ function MediaModal({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
carouselRef.current.focus();
|
carouselRef.current.focus();
|
||||||
carouselRef.current.scrollTo({
|
carouselRef.current.scrollTo({
|
||||||
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
left:
|
||||||
|
carouselRef.current.clientWidth *
|
||||||
|
(currentIndex + 1) *
|
||||||
|
(isRTL() ? -1 : 1),
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
inset-inline-start: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: var(--bg-blur-color);
|
background-color: var(--bg-blur-color);
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
|
|
|
@ -8,6 +8,7 @@ import FilterContext from '../utils/filter-context';
|
||||||
import { isFiltered } from '../utils/filters';
|
import { isFiltered } from '../utils/filters';
|
||||||
import states, { statusKey } from '../utils/states';
|
import states, { statusKey } from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
import { getCurrentAccountID } from '../utils/store-utils';
|
||||||
|
|
||||||
import Media from './media';
|
import Media from './media';
|
||||||
|
|
||||||
|
@ -88,7 +89,7 @@ function MediaPost({
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentAccount = useMemo(() => {
|
const currentAccount = useMemo(() => {
|
||||||
return store.session.get('currentAccount');
|
return getCurrentAccountID();
|
||||||
}, []);
|
}, []);
|
||||||
const isSelf = useMemo(() => {
|
const isSelf = useMemo(() => {
|
||||||
return currentAccount && currentAccount === accountId;
|
return currentAccount && currentAccount === accountId;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
import { Fragment } from 'preact';
|
import { Fragment } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
@ -9,12 +10,12 @@ import {
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
||||||
|
|
||||||
|
import formatDuration from '../utils/format-duration';
|
||||||
import mem from '../utils/mem';
|
import mem from '../utils/mem';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
import { formatDuration } from './status';
|
|
||||||
|
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||||
|
|
||||||
|
@ -74,7 +75,7 @@ function Media({
|
||||||
altIndex,
|
altIndex,
|
||||||
onClick = () => {},
|
onClick = () => {},
|
||||||
}) {
|
}) {
|
||||||
const {
|
let {
|
||||||
blurhash,
|
blurhash,
|
||||||
description,
|
description,
|
||||||
meta,
|
meta,
|
||||||
|
@ -84,15 +85,27 @@ function Media({
|
||||||
url,
|
url,
|
||||||
type,
|
type,
|
||||||
} = media;
|
} = media;
|
||||||
|
if (/no\-preview\./i.test(previewUrl)) {
|
||||||
|
previewUrl = null;
|
||||||
|
}
|
||||||
const { original = {}, small, focus } = meta || {};
|
const { original = {}, small, focus } = meta || {};
|
||||||
|
|
||||||
const width = showOriginal ? original?.width : small?.width;
|
const width = showOriginal
|
||||||
const height = showOriginal ? original?.height : small?.height;
|
? original?.width
|
||||||
|
: small?.width || original?.width;
|
||||||
|
const height = showOriginal
|
||||||
|
? original?.height
|
||||||
|
: small?.height || original?.height;
|
||||||
const mediaURL = showOriginal ? url : previewUrl || url;
|
const mediaURL = showOriginal ? url : previewUrl || url;
|
||||||
const remoteMediaURL = showOriginal
|
const remoteMediaURL = showOriginal
|
||||||
? remoteUrl
|
? remoteUrl
|
||||||
: previewRemoteUrl || 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 rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||||
|
|
||||||
|
@ -133,7 +146,8 @@ function Media({
|
||||||
enabled: pinchZoomEnabled,
|
enabled: pinchZoomEnabled,
|
||||||
draggableUnZoomed: false,
|
draggableUnZoomed: false,
|
||||||
inertiaFriction: 0.9,
|
inertiaFriction: 0.9,
|
||||||
doubleTapZoomOutOnMaxScale: true,
|
tapZoomFactor: 2,
|
||||||
|
doubleTapToggleZoom: true,
|
||||||
containerProps: {
|
containerProps: {
|
||||||
className: 'media-zoom',
|
className: 'media-zoom',
|
||||||
style: {
|
style: {
|
||||||
|
@ -153,7 +167,7 @@ function Media({
|
||||||
[to],
|
[to],
|
||||||
);
|
);
|
||||||
|
|
||||||
const remoteMediaURLObj = remoteMediaURL ? new URL(remoteMediaURL) : null;
|
const remoteMediaURLObj = remoteMediaURL ? getURLObj(remoteMediaURL) : null;
|
||||||
const isVideoMaybe =
|
const isVideoMaybe =
|
||||||
type === 'unknown' &&
|
type === 'unknown' &&
|
||||||
remoteMediaURLObj &&
|
remoteMediaURLObj &&
|
||||||
|
@ -235,6 +249,8 @@ function Media({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [hasNaturalAspectRatio, setHasNaturalAspectRatio] = useState(undefined);
|
||||||
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
// Note: type: unknown might not have width/height
|
// Note: type: unknown might not have width/height
|
||||||
quickPinchZoomProps.containerProps.style.display = 'inherit';
|
quickPinchZoomProps.containerProps.style.display = 'inherit';
|
||||||
|
@ -259,7 +275,8 @@ function Media({
|
||||||
class={`media media-image ${className}`}
|
class={`media media-image ${className}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
data-has-alt={!showInlineDesc}
|
data-has-alt={!showInlineDesc || undefined}
|
||||||
|
data-has-natural-aspect-ratio={hasNaturalAspectRatio || undefined}
|
||||||
style={
|
style={
|
||||||
showOriginal
|
showOriginal
|
||||||
? {
|
? {
|
||||||
|
@ -290,7 +307,11 @@ function Media({
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const { src } = e.target;
|
const { src } = e.target;
|
||||||
if (src === mediaURL && mediaURL !== remoteMediaURL) {
|
if (
|
||||||
|
src === mediaURL &&
|
||||||
|
remoteMediaURL &&
|
||||||
|
mediaURL !== remoteMediaURL
|
||||||
|
) {
|
||||||
e.target.src = remoteMediaURL;
|
e.target.src = remoteMediaURL;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -321,6 +342,48 @@ function Media({
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
// e.target.closest('.media-image').style.backgroundImage = '';
|
// e.target.closest('.media-image').style.backgroundImage = '';
|
||||||
e.target.dataset.loaded = true;
|
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) => {
|
onError={(e) => {
|
||||||
const { src } = e.target;
|
const { src } = e.target;
|
||||||
|
@ -338,6 +401,7 @@ function Media({
|
||||||
</Figure>
|
</Figure>
|
||||||
);
|
);
|
||||||
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
|
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
|
||||||
|
const hasDuration = original.duration > 0;
|
||||||
const shortDuration = original.duration < 31;
|
const shortDuration = original.duration < 31;
|
||||||
const isGIF = type === 'gifv' && shortDuration;
|
const isGIF = type === 'gifv' && shortDuration;
|
||||||
// If GIF is too long, treat it as a video
|
// If GIF is too long, treat it as a video
|
||||||
|
@ -347,28 +411,43 @@ function Media({
|
||||||
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
|
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
|
||||||
const showProgress = original.duration > 5;
|
const showProgress = original.duration > 5;
|
||||||
|
|
||||||
const videoHTML = `
|
// This string is only for autoplay + muted to work on Mobile Safari
|
||||||
<video
|
const gifHTML = `
|
||||||
src="${url}"
|
<video
|
||||||
poster="${previewUrl}"
|
src="${url}"
|
||||||
width="${width}"
|
poster="${previewUrl}"
|
||||||
height="${height}"
|
width="${width}"
|
||||||
data-orientation="${orientation}"
|
height="${height}"
|
||||||
preload="auto"
|
data-orientation="${orientation}"
|
||||||
autoplay
|
preload="auto"
|
||||||
muted="${isGIF}"
|
autoplay
|
||||||
${isGIF ? '' : 'controls'}
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
loop="${loopable}"
|
${loopable ? 'loop' : ''}
|
||||||
${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''}
|
ondblclick="this.paused ? this.play() : this.pause()"
|
||||||
${
|
${
|
||||||
isGIF && showProgress
|
showProgress
|
||||||
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
|
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
></video>
|
></video>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const videoHTML = `
|
||||||
|
<video
|
||||||
|
src="${url}"
|
||||||
|
poster="${previewUrl}"
|
||||||
|
width="${width}"
|
||||||
|
height="${height}"
|
||||||
|
data-orientation="${orientation}"
|
||||||
|
preload="auto"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
${loopable ? 'loop' : ''}
|
||||||
|
controls
|
||||||
|
></video>
|
||||||
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Figure>
|
<Figure>
|
||||||
<Parent
|
<Parent
|
||||||
|
@ -379,8 +458,10 @@ function Media({
|
||||||
data-formatted-duration={
|
data-formatted-duration={
|
||||||
!showOriginal ? formattedDuration : undefined
|
!showOriginal ? formattedDuration : undefined
|
||||||
}
|
}
|
||||||
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
data-label={
|
||||||
data-has-alt={!showInlineDesc}
|
isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : undefined
|
||||||
|
}
|
||||||
|
data-has-alt={!showInlineDesc || undefined}
|
||||||
// style={{
|
// style={{
|
||||||
// backgroundColor:
|
// backgroundColor:
|
||||||
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||||
|
@ -429,17 +510,22 @@ function Media({
|
||||||
<div
|
<div
|
||||||
ref={mediaRef}
|
ref={mediaRef}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: videoHTML,
|
__html: gifHTML,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</QuickPinchZoom>
|
</QuickPinchZoom>
|
||||||
) : (
|
) : isGIF ? (
|
||||||
<div
|
<div
|
||||||
class="video-container"
|
class="video-container"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: videoHTML,
|
__html: gifHTML,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
class="video-container"
|
||||||
|
dangerouslySetInnerHTML={{ __html: videoHTML }}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
) : isGIF ? (
|
) : isGIF ? (
|
||||||
<video
|
<video
|
||||||
|
@ -473,14 +559,61 @@ function Media({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<img
|
{previewUrl ? (
|
||||||
src={previewUrl}
|
<img
|
||||||
alt={showInlineDesc ? '' : description}
|
src={previewUrl}
|
||||||
width={width}
|
alt={showInlineDesc ? '' : description}
|
||||||
height={height}
|
width={width}
|
||||||
data-orientation={orientation}
|
height={height}
|
||||||
loading="lazy"
|
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">
|
<div class="media-play">
|
||||||
<Icon icon="play" size="xl" />
|
<Icon icon="play" size="xl" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -501,12 +634,12 @@ function Media({
|
||||||
data-formatted-duration={
|
data-formatted-duration={
|
||||||
!showOriginal ? formattedDuration : undefined
|
!showOriginal ? formattedDuration : undefined
|
||||||
}
|
}
|
||||||
data-has-alt={!showInlineDesc}
|
data-has-alt={!showInlineDesc || undefined}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={!showOriginal && mediaStyles}
|
style={!showOriginal && mediaStyles}
|
||||||
>
|
>
|
||||||
{showOriginal ? (
|
{showOriginal ? (
|
||||||
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
<audio src={remoteUrl || url} preload="none" controls autoPlay />
|
||||||
) : previewUrl ? (
|
) : previewUrl ? (
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
|
@ -539,4 +672,19 @@ function Media({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Menu, MenuItem, SubMenu } from '@szhsin/react-menu';
|
import { MenuItem } from '@szhsin/react-menu';
|
||||||
import { cloneElement } from 'preact';
|
import { cloneElement } from 'preact';
|
||||||
import { useRef } from 'preact/hooks';
|
|
||||||
|
|
||||||
import Menu2 from './menu2';
|
import Menu2 from './menu2';
|
||||||
|
import SubMenu2 from './submenu2';
|
||||||
|
|
||||||
function MenuConfirm({
|
function MenuConfirm({
|
||||||
subMenu = false,
|
subMenu = false,
|
||||||
|
@ -10,6 +10,7 @@ function MenuConfirm({
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
menuItemClassName,
|
menuItemClassName,
|
||||||
menuFooter,
|
menuFooter,
|
||||||
|
menuExtras,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const { children, onClick, ...restProps } = props;
|
const { children, onClick, ...restProps } = props;
|
||||||
|
@ -22,11 +23,9 @@ function MenuConfirm({
|
||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
const Parent = subMenu ? SubMenu : Menu2;
|
const Parent = subMenu ? SubMenu2 : Menu2;
|
||||||
const menuRef = useRef();
|
|
||||||
return (
|
return (
|
||||||
<Parent
|
<Parent
|
||||||
instanceRef={menuRef}
|
|
||||||
openTrigger="clickOnly"
|
openTrigger="clickOnly"
|
||||||
direction="bottom"
|
direction="bottom"
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
|
@ -36,23 +35,11 @@ function MenuConfirm({
|
||||||
{...restProps}
|
{...restProps}
|
||||||
menuButton={subMenu ? undefined : children}
|
menuButton={subMenu ? undefined : children}
|
||||||
label={subMenu ? children : undefined}
|
label={subMenu ? children : undefined}
|
||||||
// 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?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MenuItem className={menuItemClassName} onClick={onClick}>
|
<MenuItem className={menuItemClassName} onClick={onClick}>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{menuExtras}
|
||||||
{menuFooter}
|
{menuFooter}
|
||||||
</Parent>
|
</Parent>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,21 +1,33 @@
|
||||||
import { Menu } from '@szhsin/react-menu';
|
import { Menu } from '@szhsin/react-menu';
|
||||||
import { useWindowSize } from '@uidotdev/usehooks';
|
|
||||||
import { useRef } from 'preact/hooks';
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
|
import isRTL from '../utils/is-rtl';
|
||||||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
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.
|
// It's like Menu but with sensible defaults, bug fixes and improvements.
|
||||||
function Menu2(props) {
|
function Menu2(props) {
|
||||||
const { containerProps, instanceRef: _instanceRef } = props;
|
const { containerProps, instanceRef: _instanceRef, align } = props;
|
||||||
const size = useWindowSize();
|
const size = useWindowSize();
|
||||||
const instanceRef = _instanceRef?.current ? _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 (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
boundingBoxPadding={safeBoundingBoxPadding()}
|
boundingBoxPadding={safeBoundingBoxPadding()}
|
||||||
repositionFlag={`${size.width}x${size.height}`}
|
repositionFlag={`${size.width}x${size.height}`}
|
||||||
unmountOnClose
|
unmountOnClose
|
||||||
{...props}
|
{...props}
|
||||||
|
align={rtlAlign}
|
||||||
instanceRef={instanceRef}
|
instanceRef={instanceRef}
|
||||||
containerProps={{
|
containerProps={{
|
||||||
onClick: (e) => {
|
onClick: (e) => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
#modal-container > div {
|
#modal-container > div {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
inset-inline-end: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
@ -10,17 +10,65 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: var(--backdrop-color);
|
background-color: var(--backdrop-color);
|
||||||
animation: appear 0.5s var(--timing-function) both;
|
animation: appear 0.5s var(--timing-function) both;
|
||||||
|
transition: all 0.5s var(--timing-function);
|
||||||
|
|
||||||
&.solid {
|
&.solid {
|
||||||
background-color: var(--backdrop-solid-color);
|
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 {
|
.sheet {
|
||||||
transition: transform 0.3s var(--timing-function);
|
transition: transform 0.3s var(--timing-function);
|
||||||
transform-origin: center bottom;
|
transform-origin: 80% 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:has(~ div) .sheet {
|
&:has(~ div) .sheet {
|
||||||
transform: scale(0.975);
|
transform: scale(0.975);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import useCloseWatcher from '../utils/useCloseWatcher';
|
||||||
|
|
||||||
const $modalContainer = document.getElementById('modal-container');
|
const $modalContainer = document.getElementById('modal-container');
|
||||||
|
|
||||||
function Modal({ children, onClose, onClick, class: className }) {
|
function Modal({ children, onClose, onClick, class: className, minimized }) {
|
||||||
if (!children) return null;
|
if (!children) return null;
|
||||||
|
|
||||||
const modalRef = useRef();
|
const modalRef = useRef();
|
||||||
|
@ -41,6 +41,33 @@ function Modal({ children, onClose, onClick, class: className }) {
|
||||||
);
|
);
|
||||||
useCloseWatcher(onClose, [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 = (
|
const Modal = (
|
||||||
<div
|
<div
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
|
@ -54,7 +81,8 @@ function Modal({ children, onClose, onClick, class: className }) {
|
||||||
onClose?.(e);
|
onClose?.(e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex="-1"
|
tabIndex={minimized ? 0 : '-1'}
|
||||||
|
inert={minimized}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
try {
|
try {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { subscribe, useSnapshot } from 'valtio';
|
import { subscribe, useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -8,7 +9,7 @@ import showToast from '../utils/show-toast';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import AccountSheet from './account-sheet';
|
import AccountSheet from './account-sheet';
|
||||||
import Compose from './compose';
|
import ComposeSuspense, { preload } from './compose-suspense';
|
||||||
import Drafts from './drafts';
|
import Drafts from './drafts';
|
||||||
import EmbedModal from './embed-modal';
|
import EmbedModal from './embed-modal';
|
||||||
import GenericAccounts from './generic-accounts';
|
import GenericAccounts from './generic-accounts';
|
||||||
|
@ -32,11 +33,18 @@ export default function Modals() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(preload, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!!snapStates.showCompose && (
|
{!!snapStates.showCompose && (
|
||||||
<Modal class="solid">
|
<Modal
|
||||||
<Compose
|
class={`solid ${snapStates.composerState.minimized ? 'min' : ''}`}
|
||||||
|
minimized={!!snapStates.composerState.minimized}
|
||||||
|
>
|
||||||
|
<ComposeSuspense
|
||||||
replyToStatus={
|
replyToStatus={
|
||||||
typeof snapStates.showCompose !== 'boolean'
|
typeof snapStates.showCompose !== 'boolean'
|
||||||
? snapStates.showCompose.replyToStatus
|
? snapStates.showCompose.replyToStatus
|
||||||
|
@ -179,7 +187,9 @@ export default function Modals() {
|
||||||
excludeRelationshipAttrs={
|
excludeRelationshipAttrs={
|
||||||
snapStates.showGenericAccounts.excludeRelationshipAttrs
|
snapStates.showGenericAccounts.excludeRelationshipAttrs
|
||||||
}
|
}
|
||||||
|
postID={snapStates.showGenericAccounts.postID}
|
||||||
onClose={() => (states.showGenericAccounts = false)}
|
onClose={() => (states.showGenericAccounts = false)}
|
||||||
|
blankCopy={snapStates.showGenericAccounts.blankCopy}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -5,9 +5,14 @@
|
||||||
unicode-bidi: isolate;
|
unicode-bidi: isolate;
|
||||||
|
|
||||||
b {
|
b {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
unicode-bidi: isolate;
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-variant-numeric: slashed-zero;
|
||||||
|
font-feature-settings: 'ss01';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.name-text.show-acct {
|
.name-text.show-acct {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -21,6 +26,9 @@ a.name-text.short:is(:hover, :focus) i {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
.name-text i.instance {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
.name-text .avatar {
|
.name-text .avatar {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
|
@ -2,6 +2,7 @@ import './name-text.css';
|
||||||
|
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
import { api } from '../utils/api';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
|
@ -20,42 +21,60 @@ function NameText({
|
||||||
external,
|
external,
|
||||||
onClick,
|
onClick,
|
||||||
}) {
|
}) {
|
||||||
const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } =
|
const {
|
||||||
account;
|
acct,
|
||||||
let { username } = account;
|
avatar,
|
||||||
|
avatarStatic,
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
displayName,
|
||||||
|
emojis,
|
||||||
|
bot,
|
||||||
|
username,
|
||||||
|
} = account;
|
||||||
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
|
||||||
|
|
||||||
|
if (!instance) instance = api().instance;
|
||||||
|
|
||||||
const trimmedUsername = username.toLowerCase().trim();
|
const trimmedUsername = username.toLowerCase().trim();
|
||||||
const trimmedDisplayName = (displayName || '').toLowerCase().trim();
|
const trimmedDisplayName = (displayName || '').toLowerCase().trim();
|
||||||
const shortenedDisplayName = trimmedDisplayName
|
const shortenedDisplayName = trimmedDisplayName
|
||||||
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
|
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
|
||||||
.replace(/\s+/g, ''); // E.g. "My name" === "myname"
|
.replace(/\s+/g, ''); // E.g. "My name" === "myname"
|
||||||
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
|
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
|
||||||
/[^a-z0-9]/gi,
|
/[^a-z0-9@\.]/gi,
|
||||||
'',
|
'',
|
||||||
); // Remove non-alphanumeric characters
|
); // Remove non-alphanumeric characters
|
||||||
|
|
||||||
if (
|
const hideUsername =
|
||||||
!short &&
|
(!short &&
|
||||||
(trimmedUsername === trimmedDisplayName ||
|
(trimmedUsername === trimmedDisplayName ||
|
||||||
trimmedUsername === shortenedDisplayName ||
|
trimmedUsername === shortenedDisplayName ||
|
||||||
trimmedUsername === shortenedAlphaNumericDisplayName ||
|
trimmedUsername === shortenedAlphaNumericDisplayName ||
|
||||||
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)
|
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)) ||
|
||||||
) {
|
shortenedAlphaNumericDisplayName === acct.toLowerCase();
|
||||||
username = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
|
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
|
||||||
href={url}
|
href={url}
|
||||||
target={external ? '_blank' : null}
|
target={external ? '_blank' : null}
|
||||||
title={`${displayName ? `${displayName} ` : ''}@${acct}`}
|
title={
|
||||||
|
displayName
|
||||||
|
? `${displayName} (${acct2 ? '' : '@'}${acct})`
|
||||||
|
: `${acct2 ? '' : '@'}${acct}`
|
||||||
|
}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (external) return;
|
if (external) return;
|
||||||
|
if (e.shiftKey) return; // Save link? 🤷♂️
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onClick) return onClick(e);
|
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 = {
|
states.showAccount = {
|
||||||
account,
|
account,
|
||||||
instance,
|
instance,
|
||||||
|
@ -69,15 +88,16 @@ function NameText({
|
||||||
)}
|
)}
|
||||||
{displayName && !short ? (
|
{displayName && !short ? (
|
||||||
<>
|
<>
|
||||||
<b>
|
<b dir="auto">
|
||||||
<EmojiText text={displayName} emojis={emojis} />
|
<EmojiText text={displayName} emojis={emojis} />
|
||||||
</b>
|
</b>
|
||||||
{!showAcct && username && (
|
{!showAcct && !hideUsername ? (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
<i>@{username}</i>
|
<i class="bidi-isolate">@{username}</i>
|
||||||
</>
|
</>
|
||||||
)}
|
) : ' '}
|
||||||
|
<i class="instance">{acct2}</i>
|
||||||
</>
|
</>
|
||||||
) : short ? (
|
) : short ? (
|
||||||
<i>{username}</i>
|
<i>{username}</i>
|
||||||
|
@ -87,9 +107,10 @@ function NameText({
|
||||||
{showAcct && (
|
{showAcct && (
|
||||||
<>
|
<>
|
||||||
<br />
|
<br />
|
||||||
<i>
|
<i class="bidi-isolate">
|
||||||
@{acct1}
|
{acct2 ? '' : '@'}
|
||||||
<span class="ib">{acct2}</span>
|
{acct1}
|
||||||
|
{!!acct2 && <span class="ib">{acct2}</span>}
|
||||||
</i>
|
</i>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -35,11 +35,15 @@
|
||||||
}
|
}
|
||||||
.nav-menu section:last-child {
|
.nav-menu section:last-child {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to right,
|
var(--to-forward),
|
||||||
var(--divider-color) 1px,
|
var(--divider-color) 1px,
|
||||||
transparent 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);
|
url(../assets/phanpy-bg.svg);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
/* background-size: auto, auto, 200%; */
|
/* background-size: auto, auto, 200%; */
|
||||||
|
@ -49,8 +53,8 @@
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
animation: phanpying 0.2s ease-in-out both;
|
animation: phanpying 0.2s ease-in-out both;
|
||||||
border-top-right-radius: inherit;
|
border-start-end-radius: inherit;
|
||||||
border-bottom-right-radius: inherit;
|
border-end-end-radius: inherit;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -88,3 +92,7 @@
|
||||||
.sparkle-icon {
|
.sparkle-icon {
|
||||||
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
|
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-submenu {
|
||||||
|
max-width: 14em;
|
||||||
|
}
|
||||||
|
|
|
@ -1,39 +1,35 @@
|
||||||
import './nav-menu.css';
|
import './nav-menu.css';
|
||||||
|
|
||||||
import {
|
import { ControlledMenu, FocusableItem, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||||
ControlledMenu,
|
|
||||||
MenuDivider,
|
|
||||||
MenuItem,
|
|
||||||
SubMenu,
|
|
||||||
} from '@szhsin/react-menu';
|
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useLongPress } from 'use-long-press';
|
import { useLongPress } from 'use-long-press';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { getLists } from '../utils/lists';
|
||||||
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
import { getCurrentAccountID } from '../utils/store-utils';
|
||||||
|
import supports from '../utils/supports';
|
||||||
|
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import MenuLink from './menu-link';
|
import MenuLink from './menu-link';
|
||||||
|
import SubMenu2 from './submenu2';
|
||||||
|
import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
|
||||||
|
|
||||||
function NavMenu(props) {
|
function NavMenu(props) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const { masto, instance, authenticated } = api();
|
const { masto, instance, authenticated } = api();
|
||||||
|
|
||||||
const [currentAccount, setCurrentAccount] = useState();
|
const [currentAccount, moreThanOneAccount] = useMemo(() => {
|
||||||
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const accounts = store.local.getJSON('accounts') || [];
|
const accounts = store.local.getJSON('accounts') || [];
|
||||||
const acc = accounts.find(
|
const acc =
|
||||||
(account) => account.info.id === store.session.get('currentAccount'),
|
accounts.find((account) => account.info.id === getCurrentAccountID()) ||
|
||||||
);
|
accounts[0];
|
||||||
if (acc) setCurrentAccount(acc);
|
return [acc, accounts.length > 1];
|
||||||
setMoreThanOneAccount(accounts.length > 1);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Home = Following
|
// Home = Following
|
||||||
|
@ -89,6 +85,15 @@ function NavMenu(props) {
|
||||||
return results;
|
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();
|
const buttonClickTS = useRef();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -97,7 +102,7 @@ function NavMenu(props) {
|
||||||
type="button"
|
type="button"
|
||||||
class={`button plain nav-menu-button ${
|
class={`button plain nav-menu-button ${
|
||||||
moreThanOneAccount ? 'with-avatar' : ''
|
moreThanOneAccount ? 'with-avatar' : ''
|
||||||
} ${open ? 'active' : ''}`}
|
} ${menuState === 'open' ? 'active' : ''}`}
|
||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
buttonClickTS.current = Date.now();
|
buttonClickTS.current = Date.now();
|
||||||
|
@ -185,9 +190,11 @@ function NavMenu(props) {
|
||||||
<Icon icon="history2" size="l" />
|
<Icon icon="history2" size="l" />
|
||||||
<span>Catch-up</span>
|
<span>Catch-up</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<MenuLink to="/mentions">
|
{supports('@mastodon/mentions') && (
|
||||||
<Icon icon="at" size="l" /> <span>Mentions</span>
|
<MenuLink to="/mentions">
|
||||||
</MenuLink>
|
<Icon icon="at" size="l" /> <span>Mentions</span>
|
||||||
|
</MenuLink>
|
||||||
|
)}
|
||||||
<MenuLink to="/notifications">
|
<MenuLink to="/notifications">
|
||||||
<Icon icon="notification" size="l" /> <span>Notifications</span>
|
<Icon icon="notification" size="l" /> <span>Notifications</span>
|
||||||
{snapStates.notificationsShowNew && (
|
{snapStates.notificationsShowNew && (
|
||||||
|
@ -203,13 +210,50 @@ function NavMenu(props) {
|
||||||
<Icon icon="user" size="l" /> <span>Profile</span>
|
<Icon icon="user" size="l" /> <span>Profile</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
)}
|
)}
|
||||||
<MenuLink to="/l">
|
{currentAccount && accountsIsDtth(currentAccount) &&
|
||||||
<Icon icon="list" size="l" /> <span>Lists</span>
|
<FocusableItem title="Takes you to DTTHDon settings">
|
||||||
</MenuLink>
|
<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">
|
<MenuLink to="/b">
|
||||||
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<SubMenu
|
<SubMenu2
|
||||||
|
menuClassName="nav-submenu"
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
gap={-8}
|
gap={-8}
|
||||||
label={
|
label={
|
||||||
|
@ -223,11 +267,17 @@ function NavMenu(props) {
|
||||||
<MenuLink to="/f">
|
<MenuLink to="/f">
|
||||||
<Icon icon="heart" size="l" /> <span>Likes</span>
|
<Icon icon="heart" size="l" /> <span>Likes</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<MenuLink to="/ft">
|
<MenuLink to="/fh">
|
||||||
<Icon icon="hashtag" size="l" />{' '}
|
<Icon icon="hashtag" size="l" />{' '}
|
||||||
<span>Followed Hashtags</span>
|
<span>Followed Hashtags</span>
|
||||||
</MenuLink>
|
</MenuLink>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
{supports('@mastodon/filters') && (
|
||||||
|
<MenuLink to="/ft">
|
||||||
|
<Icon icon="filters" size="l" />
|
||||||
|
Filters
|
||||||
|
</MenuLink>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
states.showGenericAccounts = {
|
states.showGenericAccounts = {
|
||||||
|
@ -253,7 +303,7 @@ function NavMenu(props) {
|
||||||
<Icon icon="block" size="l" />
|
<Icon icon="block" size="l" />
|
||||||
Blocked users…
|
Blocked users…
|
||||||
</MenuItem>{' '}
|
</MenuItem>{' '}
|
||||||
</SubMenu>
|
</SubMenu2>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
@ -2,11 +2,13 @@ import { Fragment } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import states from '../utils/states';
|
import states, { statusKey } from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
import { getCurrentAccountID } from '../utils/store-utils';
|
||||||
import useTruncated from '../utils/useTruncated';
|
import useTruncated from '../utils/useTruncated';
|
||||||
|
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
|
import CustomEmoji from './custom-emoji';
|
||||||
import FollowRequestButtons from './follow-request-buttons';
|
import FollowRequestButtons from './follow-request-buttons';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
|
@ -25,6 +27,10 @@ const NOTIFICATION_ICONS = {
|
||||||
update: 'pencil',
|
update: 'pencil',
|
||||||
'admin.signup': 'account-edit',
|
'admin.signup': 'account-edit',
|
||||||
'admin.report': 'account-warning',
|
'admin.report': 'account-warning',
|
||||||
|
severed_relationships: 'heart-break',
|
||||||
|
moderation_warning: 'alert',
|
||||||
|
emoji_reaction: 'emoji2',
|
||||||
|
'pleroma:emoji_reaction': 'emoji2',
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -40,8 +46,28 @@ poll = A poll you have voted in or created has ended
|
||||||
update = A status you interacted with has been edited
|
update = A status you interacted with has been edited
|
||||||
admin.sign_up = Someone signed up (optionally sent to admins)
|
admin.sign_up = Someone signed up (optionally sent to admins)
|
||||||
admin.report = A new report has been filed
|
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 = {
|
const contentText = {
|
||||||
mention: 'mentioned you in their post.',
|
mention: 'mentioned you in their post.',
|
||||||
status: 'published a post.',
|
status: 'published a post.',
|
||||||
|
@ -63,9 +89,50 @@ const contentText = {
|
||||||
'favourite+reblog_reply': 'boosted & liked your reply.',
|
'favourite+reblog_reply': 'boosted & liked your reply.',
|
||||||
'admin.sign_up': 'signed up.',
|
'admin.sign_up': 'signed up.',
|
||||||
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
|
'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,
|
||||||
};
|
};
|
||||||
|
|
||||||
const AVATARS_LIMIT = 50;
|
// 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({
|
function Notification({
|
||||||
notification,
|
notification,
|
||||||
|
@ -73,14 +140,28 @@ function Notification({
|
||||||
isStatic,
|
isStatic,
|
||||||
disableContextMenu,
|
disableContextMenu,
|
||||||
}) {
|
}) {
|
||||||
const { id, status, account, report, _accounts, _statuses } = notification;
|
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;
|
let { type } = notification;
|
||||||
|
|
||||||
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
||||||
const actualStatus = status?.reblog || status;
|
const actualStatus = status?.reblog || status;
|
||||||
const actualStatusID = actualStatus?.id;
|
const actualStatusID = actualStatus?.id;
|
||||||
|
|
||||||
const currentAccount = store.session.get('currentAccount');
|
const currentAccount = getCurrentAccountID();
|
||||||
const isSelf = currentAccount === account?.id;
|
const isSelf = currentAccount === account?.id;
|
||||||
const isVoted = status?.poll?.voted;
|
const isVoted = status?.poll?.voted;
|
||||||
const isReplyToOthers =
|
const isReplyToOthers =
|
||||||
|
@ -91,12 +172,14 @@ function Notification({
|
||||||
let favsCount = 0;
|
let favsCount = 0;
|
||||||
let reblogsCount = 0;
|
let reblogsCount = 0;
|
||||||
if (type === 'favourite+reblog') {
|
if (type === 'favourite+reblog') {
|
||||||
for (const account of _accounts) {
|
if (_accounts) {
|
||||||
if (account._types?.includes('favourite')) {
|
for (const account of _accounts) {
|
||||||
favsCount++;
|
if (account._types?.includes('favourite')) {
|
||||||
}
|
favsCount++;
|
||||||
if (account._types?.includes('reblog')) {
|
}
|
||||||
reblogsCount++;
|
if (account._types?.includes('reblog')) {
|
||||||
|
reblogsCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!reblogsCount && favsCount) type = 'favourite';
|
if (!reblogsCount && favsCount) type = 'favourite';
|
||||||
|
@ -128,13 +211,30 @@ function Notification({
|
||||||
|
|
||||||
if (typeof text === 'function') {
|
if (typeof text === 'function') {
|
||||||
const count = _statuses?.length || _accounts?.length;
|
const count = _statuses?.length || _accounts?.length;
|
||||||
if (count) {
|
if (type === 'admin.report') {
|
||||||
text = text(count);
|
|
||||||
} else if (type === 'admin.report') {
|
|
||||||
const targetAccount = report?.targetAccount;
|
const targetAccount = report?.targetAccount;
|
||||||
if (targetAccount) {
|
if (targetAccount) {
|
||||||
text = text(<NameText account={targetAccount} showAvatar />);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,6 +259,7 @@ function Notification({
|
||||||
accounts: _accounts,
|
accounts: _accounts,
|
||||||
showReactions: type === 'favourite+reblog',
|
showReactions: type === 'favourite+reblog',
|
||||||
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
|
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
|
||||||
|
postID: statusKey(actualStatusID, instance),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -167,7 +268,7 @@ function Notification({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`notification notification-${type}`}
|
class={`notification notification-${type}`}
|
||||||
data-notification-id={id}
|
data-notification-id={_ids || id}
|
||||||
tabIndex="0"
|
tabIndex="0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -191,7 +292,7 @@ function Notification({
|
||||||
{type !== 'mention' && (
|
{type !== 'mention' && (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
{!/poll|update/i.test(type) && (
|
{!/poll|update|severed_relationships/i.test(type) && (
|
||||||
<>
|
<>
|
||||||
{_accounts?.length > 1 ? (
|
{_accounts?.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
|
@ -202,10 +303,21 @@ function Notification({
|
||||||
people
|
people
|
||||||
</b>{' '}
|
</b>{' '}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : notificationsCount > 1 ? (
|
||||||
<>
|
<>
|
||||||
<NameText account={account} showAvatar />{' '}
|
<b>
|
||||||
|
<span title={notificationsCount}>
|
||||||
|
{shortenNumber(notificationsCount)}
|
||||||
|
</span>{' '}
|
||||||
|
people
|
||||||
|
</b>{' '}
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
account && (
|
||||||
|
<>
|
||||||
|
<NameText account={account} showAvatar />{' '}
|
||||||
|
</>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -224,6 +336,37 @@ function Notification({
|
||||||
{type === 'follow_request' && (
|
{type === 'follow_request' && (
|
||||||
<FollowRequestButtons accountID={account.id} />
|
<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 && (
|
{_accounts?.length > 1 && (
|
||||||
|
@ -247,11 +390,7 @@ function Notification({
|
||||||
? 'xxl'
|
? 'xxl'
|
||||||
: _accounts.length < 20
|
: _accounts.length < 20
|
||||||
? 'xl'
|
? 'xl'
|
||||||
: _accounts.length < 30
|
: 'l'
|
||||||
? 'l'
|
|
||||||
: _accounts.length < 40
|
|
||||||
? 'm'
|
|
||||||
: 's' // My god, this person is popular!
|
|
||||||
}
|
}
|
||||||
key={account.id}
|
key={account.id}
|
||||||
alt={`${account.displayName} @${account.acct}`}
|
alt={`${account.displayName} @${account.acct}`}
|
||||||
|
@ -282,6 +421,54 @@ function Notification({
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{!_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 && (
|
{_statuses?.length > 1 && (
|
||||||
<ul class="notification-group-statuses">
|
<ul class="notification-group-statuses">
|
||||||
{_statuses.map((status) => (
|
{_statuses.map((status) => (
|
||||||
|
|
|
@ -187,9 +187,6 @@ export default function Poll({
|
||||||
type="button"
|
type="button"
|
||||||
class="plain small"
|
class="plain small"
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
style={{
|
|
||||||
marginLeft: -8,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
|
|
|
@ -8,7 +8,7 @@ import dayjs from 'dayjs';
|
||||||
import dayjsTwitter from 'dayjs-twitter';
|
import dayjsTwitter from 'dayjs-twitter';
|
||||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { useMemo } from 'preact/hooks';
|
import { useEffect, useMemo, useReducer } from 'preact/hooks';
|
||||||
|
|
||||||
dayjs.extend(dayjsTwitter);
|
dayjs.extend(dayjsTwitter);
|
||||||
dayjs.extend(localizedFormat);
|
dayjs.extend(localizedFormat);
|
||||||
|
@ -18,22 +18,51 @@ const dtf = new Intl.DateTimeFormat();
|
||||||
|
|
||||||
export default function RelativeTime({ datetime, format }) {
|
export default function RelativeTime({ datetime, format }) {
|
||||||
if (!datetime) return null;
|
if (!datetime) return null;
|
||||||
|
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
|
||||||
const date = useMemo(() => dayjs(datetime), [datetime]);
|
const date = useMemo(() => dayjs(datetime), [datetime]);
|
||||||
const dateStr = useMemo(() => {
|
const [dateStr, dt, title] = useMemo(() => {
|
||||||
|
if (!date.isValid()) return ['' + datetime, '', ''];
|
||||||
|
let str;
|
||||||
if (format === 'micro') {
|
if (format === 'micro') {
|
||||||
// If date <= 1 day ago or day is within this year
|
// If date <= 1 day ago or day is within this year
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
const dayDiff = now.diff(date, 'day');
|
const dayDiff = now.diff(date, 'day');
|
||||||
if (dayDiff <= 1 || now.year() === date.year()) {
|
if (dayDiff <= 1 || now.year() === date.year()) {
|
||||||
return date.twitter();
|
str = date.twitter();
|
||||||
} else {
|
} else {
|
||||||
return dtf.format(date.toDate());
|
str = dtf.format(date.toDate());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return date.fromNow();
|
if (!str) str = date.fromNow();
|
||||||
}, [date, format]);
|
return [str, date.toISOString(), date.format('LLLL')];
|
||||||
const dt = useMemo(() => date.toISOString(), [date]);
|
}, [date, format, renderCount]);
|
||||||
const title = useMemo(() => date.format('LLLL'), [date]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!date.isValid()) return;
|
||||||
|
let timeout;
|
||||||
|
let raf;
|
||||||
|
function rafRerender() {
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
rerender();
|
||||||
|
scheduleRerender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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(timeout);
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<time datetime={dt} title={title}>
|
<time datetime={dt} title={title}>
|
||||||
|
|
|
@ -26,6 +26,8 @@
|
||||||
background-color: var(--bg-blur-color);
|
background-color: var(--bg-blur-color);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
padding: 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;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
@ -41,6 +43,8 @@
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding: 0 16px 16px;
|
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;
|
/* display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px; */
|
gap: 16px; */
|
||||||
|
@ -88,7 +92,7 @@
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 32px;
|
inset-inline-end: 32px;
|
||||||
margin-top: -48px;
|
margin-top: -48px;
|
||||||
animation: rubber-stamp 0.3s ease-in both;
|
animation: rubber-stamp 0.3s ease-in both;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -144,7 +148,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-rules {
|
.report-rules {
|
||||||
margin-left: 1.75em;
|
margin-inline-start: 1.75em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -232,8 +232,8 @@ function ReportModal({ account, post, onClose }) {
|
||||||
disabled={uiState === 'loading'}
|
disabled={uiState === 'loading'}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
{!!domain && domain !== currentDomain && (
|
||||||
{domain !== currentDomain && (
|
<section>
|
||||||
<p>
|
<p>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
@ -247,8 +247,8 @@ function ReportModal({ account, post, onClose }) {
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</p>
|
||||||
)}
|
</section>
|
||||||
</section>
|
)}
|
||||||
<footer>
|
<footer>
|
||||||
<button type="submit" disabled={uiState === 'loading'}>
|
<button type="submit" disabled={uiState === 'loading'}>
|
||||||
Send Report
|
Send Report
|
||||||
|
|
|
@ -73,7 +73,7 @@ const SearchForm = forwardRef((props, ref) => {
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
spellcheck="false"
|
spellCheck="false"
|
||||||
onSearch={(e) => {
|
onSearch={(e) => {
|
||||||
if (!e.target.value) {
|
if (!e.target.value) {
|
||||||
setSearchParams({});
|
setSearchParams({});
|
||||||
|
@ -273,6 +273,7 @@ const SearchForm = forwardRef((props, ref) => {
|
||||||
class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
|
class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
|
||||||
// hidden={hidden}
|
// hidden={hidden}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
console.log('onClick', e);
|
||||||
props?.onSubmit?.(e);
|
props?.onSubmit?.(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -18,8 +18,8 @@
|
||||||
counter-increment: index;
|
counter-increment: index;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1.2em;
|
width: 1.2em;
|
||||||
text-align: right;
|
text-align: end;
|
||||||
margin-right: 8px;
|
margin-inline-end: 8px;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
@ -55,12 +55,12 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
#shortcuts-settings-container .shortcuts-view-mode label:first-child {
|
#shortcuts-settings-container .shortcuts-view-mode label:first-child {
|
||||||
border-top-left-radius: 16px;
|
border-start-start-radius: 16px;
|
||||||
border-bottom-left-radius: 16px;
|
border-end-start-radius: 16px;
|
||||||
}
|
}
|
||||||
#shortcuts-settings-container .shortcuts-view-mode label:last-child {
|
#shortcuts-settings-container .shortcuts-view-mode label:last-child {
|
||||||
border-top-right-radius: 16px;
|
border-start-end-radius: 16px;
|
||||||
border-bottom-right-radius: 16px;
|
border-end-end-radius: 16px;
|
||||||
}
|
}
|
||||||
#shortcuts-settings-container .shortcuts-view-mode label img {
|
#shortcuts-settings-container .shortcuts-view-mode label img {
|
||||||
max-height: 64px;
|
max-height: 64px;
|
||||||
|
@ -114,7 +114,7 @@
|
||||||
}
|
}
|
||||||
#shortcut-settings-form label > span:first-child {
|
#shortcut-settings-form label > span:first-child {
|
||||||
flex-basis: 5em;
|
flex-basis: 5em;
|
||||||
text-align: right;
|
text-align: end;
|
||||||
}
|
}
|
||||||
#shortcut-settings-form :is(input[type='text'], select) {
|
#shortcut-settings-form :is(input[type='text'], select) {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -185,8 +185,8 @@
|
||||||
counter-increment: index;
|
counter-increment: index;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1.2em;
|
width: 1.2em;
|
||||||
text-align: right;
|
text-align: end;
|
||||||
margin-right: 8px;
|
margin-inline-end: 8px;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
|
@ -14,10 +14,12 @@ import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { fetchFollowedTags } from '../utils/followed-tags';
|
import { fetchFollowedTags } from '../utils/followed-tags';
|
||||||
|
import { getLists, getListTitle } from '../utils/lists';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
import { getCurrentAccountID } from '../utils/store-utils';
|
||||||
|
|
||||||
import AsyncText from './AsyncText';
|
import AsyncText from './AsyncText';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
@ -43,7 +45,7 @@ const TYPES = [
|
||||||
const TYPE_TEXT = {
|
const TYPE_TEXT = {
|
||||||
following: 'Home / Following',
|
following: 'Home / Following',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
list: 'List',
|
list: 'Lists',
|
||||||
public: 'Public (Local / Federated)',
|
public: 'Public (Local / Federated)',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
'account-statuses': 'Account',
|
'account-statuses': 'Account',
|
||||||
|
@ -58,6 +60,7 @@ const TYPE_PARAMS = {
|
||||||
{
|
{
|
||||||
text: 'List ID',
|
text: 'List ID',
|
||||||
name: 'id',
|
name: 'id',
|
||||||
|
notRequired: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
public: [
|
public: [
|
||||||
|
@ -122,10 +125,6 @@ const TYPE_PARAMS = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const fetchListTitle = pmem(async ({ id }) => {
|
|
||||||
const list = await api().masto.v1.lists.$select(id).fetch();
|
|
||||||
return list.title;
|
|
||||||
});
|
|
||||||
const fetchAccountTitle = pmem(async ({ id }) => {
|
const fetchAccountTitle = pmem(async ({ id }) => {
|
||||||
const account = await api().masto.v1.accounts.$select(id).fetch();
|
const account = await api().masto.v1.accounts.$select(id).fetch();
|
||||||
return account.username || account.acct || account.displayName;
|
return account.username || account.acct || account.displayName;
|
||||||
|
@ -150,10 +149,11 @@ export const SHORTCUTS_META = {
|
||||||
icon: 'notification',
|
icon: 'notification',
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
id: 'list',
|
id: ({ id }) => (id ? 'list' : 'lists'),
|
||||||
title: fetchListTitle,
|
title: ({ id }) => (id ? getListTitle(id) : 'Lists'),
|
||||||
path: ({ id }) => `/l/${id}`,
|
path: ({ id }) => (id ? `/l/${id}` : '/l'),
|
||||||
icon: 'list',
|
icon: 'list',
|
||||||
|
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
|
||||||
},
|
},
|
||||||
public: {
|
public: {
|
||||||
id: 'public',
|
id: 'public',
|
||||||
|
@ -496,18 +496,8 @@ function ShortcutsSettings({ onClose }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
|
|
||||||
const fetchLists = pmem(
|
|
||||||
() => {
|
|
||||||
const { masto } = api();
|
|
||||||
return masto.v1.lists.list();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
maxAge: FETCH_MAX_AGE,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const FORM_NOTES = {
|
const FORM_NOTES = {
|
||||||
|
list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
|
||||||
search: `For multi-column mode, search term is required, else the column will not be shown.`,
|
search: `For multi-column mode, search term is required, else the column will not be shown.`,
|
||||||
hashtag: 'Multiple hashtags are supported. Space-separated.',
|
hashtag: 'Multiple hashtags are supported. Space-separated.',
|
||||||
};
|
};
|
||||||
|
@ -532,8 +522,7 @@ function ShortcutForm({
|
||||||
if (currentType !== 'list') return;
|
if (currentType !== 'list') return;
|
||||||
try {
|
try {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
const lists = await fetchLists();
|
const lists = await getLists();
|
||||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
|
||||||
setLists(lists);
|
setLists(lists);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -623,6 +612,7 @@ function ShortcutForm({
|
||||||
}}
|
}}
|
||||||
defaultValue={editMode ? shortcut.type : undefined}
|
defaultValue={editMode ? shortcut.type : undefined}
|
||||||
name="type"
|
name="type"
|
||||||
|
dir="auto"
|
||||||
>
|
>
|
||||||
<option></option>
|
<option></option>
|
||||||
{TYPES.map((type) => (
|
{TYPES.map((type) => (
|
||||||
|
@ -643,7 +633,9 @@ function ShortcutForm({
|
||||||
required={!notRequired}
|
required={!notRequired}
|
||||||
disabled={disabled || uiState === 'loading'}
|
disabled={disabled || uiState === 'loading'}
|
||||||
defaultValue={editMode ? shortcut.id : undefined}
|
defaultValue={editMode ? shortcut.id : undefined}
|
||||||
|
dir="auto"
|
||||||
>
|
>
|
||||||
|
<option value=""></option>
|
||||||
{lists.map((list) => (
|
{lists.map((list) => (
|
||||||
<option value={list.id}>{list.title}</option>
|
<option value={list.id}>{list.title}</option>
|
||||||
))}
|
))}
|
||||||
|
@ -671,8 +663,9 @@ function ShortcutForm({
|
||||||
}
|
}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
spellcheck={false}
|
spellCheck={false}
|
||||||
pattern={pattern}
|
pattern={pattern}
|
||||||
|
dir="auto"
|
||||||
/>
|
/>
|
||||||
{currentType === 'hashtag' &&
|
{currentType === 'hashtag' &&
|
||||||
followedHashtags.length > 0 && (
|
followedHashtags.length > 0 && (
|
||||||
|
@ -790,6 +783,7 @@ function ImportExport({ shortcuts, onClose }) {
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
setImportShortcutStr(e.target.value);
|
setImportShortcutStr(e.target.value);
|
||||||
}}
|
}}
|
||||||
|
dir="auto"
|
||||||
/>
|
/>
|
||||||
{states.settings.shortcutSettingsCloudImportExport && (
|
{states.settings.shortcutSettingsCloudImportExport && (
|
||||||
<button
|
<button
|
||||||
|
@ -798,7 +792,7 @@ function ImportExport({ shortcuts, onClose }) {
|
||||||
disabled={importUIState === 'cloud-downloading'}
|
disabled={importUIState === 'cloud-downloading'}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setImportUIState('cloud-downloading');
|
setImportUIState('cloud-downloading');
|
||||||
const currentAccount = store.session.get('currentAccount');
|
const currentAccount = getCurrentAccountID();
|
||||||
showToast(
|
showToast(
|
||||||
'Downloading saved shortcuts from instance server…',
|
'Downloading saved shortcuts from instance server…',
|
||||||
);
|
);
|
||||||
|
@ -1006,6 +1000,7 @@ function ImportExport({ shortcuts, onClose }) {
|
||||||
showToast('Unable to copy shortcuts');
|
showToast('Unable to copy shortcuts');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
dir="auto"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
@ -1054,7 +1049,7 @@ function ImportExport({ shortcuts, onClose }) {
|
||||||
disabled={importUIState === 'cloud-uploading'}
|
disabled={importUIState === 'cloud-uploading'}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setImportUIState('cloud-uploading');
|
setImportUIState('cloud-uploading');
|
||||||
const currentAccount = store.session.get('currentAccount');
|
const currentAccount = getCurrentAccountID();
|
||||||
try {
|
try {
|
||||||
const relationships =
|
const relationships =
|
||||||
await masto.v1.accounts.relationships.fetch({
|
await masto.v1.accounts.relationships.fetch({
|
||||||
|
@ -1065,16 +1060,16 @@ function ImportExport({ shortcuts, onClose }) {
|
||||||
const { note = '' } = relationship;
|
const { note = '' } = relationship;
|
||||||
// const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`;
|
// const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`;
|
||||||
let newNote = '';
|
let newNote = '';
|
||||||
|
const settingsJSON = JSON.stringify({
|
||||||
|
v: '1', // version
|
||||||
|
dt: Date.now(), // datetime stamp
|
||||||
|
data: shortcutsStr, // shortcuts settings string
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
|
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
|
||||||
note,
|
note,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const settingsJSON = JSON.stringify({
|
|
||||||
v: '1', // version
|
|
||||||
dt: Date.now(), // datetime stamp
|
|
||||||
data: shortcutsStr, // shortcuts settings string
|
|
||||||
});
|
|
||||||
newNote = note.replace(
|
newNote = note.replace(
|
||||||
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
|
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
|
||||||
`<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`,
|
`<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`,
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
bottom: max(16px, env(safe-area-inset-bottom));
|
bottom: max(16px, env(safe-area-inset-bottom));
|
||||||
left: 16px;
|
inset-inline-start: 16px;
|
||||||
left: max(16px, env(safe-area-inset-left));
|
inset-inline-start: max(16px, env(safe-area-inset-left));
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: var(--bg-faded-blur-color);
|
background-color: var(--bg-faded-blur-color);
|
||||||
z-index: 101;
|
z-index: 101;
|
||||||
|
@ -34,9 +34,9 @@
|
||||||
|
|
||||||
@media (min-width: calc(40em + 56px + 8px)) {
|
@media (min-width: calc(40em + 56px + 8px)) {
|
||||||
#shortcuts-button {
|
#shortcuts-button {
|
||||||
right: 16px;
|
inset-inline-end: 16px;
|
||||||
right: max(16px, env(safe-area-inset-right));
|
inset-inline-end: max(16px, env(safe-area-inset-right));
|
||||||
left: auto;
|
inset-inline-start: auto;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
top: max(16px, env(safe-area-inset-top));
|
top: max(16px, env(safe-area-inset-top));
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
|
|
|
@ -1,21 +1,23 @@
|
||||||
import './shortcuts.css';
|
import './shortcuts.css';
|
||||||
|
|
||||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
import { MenuDivider } from '@szhsin/react-menu';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useMemo, useRef } from 'preact/hooks';
|
import { useRef, useState } from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import { SHORTCUTS_META } from '../components/shortcuts-settings';
|
import { SHORTCUTS_META } from '../components/shortcuts-settings';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { getLists } from '../utils/lists';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
|
|
||||||
import AsyncText from './AsyncText';
|
import AsyncText from './AsyncText';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
import Menu2 from './menu2';
|
|
||||||
import MenuLink from './menu-link';
|
import MenuLink from './menu-link';
|
||||||
|
import Menu2 from './menu2';
|
||||||
|
import SubMenu2 from './submenu2';
|
||||||
|
|
||||||
function Shortcuts() {
|
function Shortcuts() {
|
||||||
const { instance } = api();
|
const { instance } = api();
|
||||||
|
@ -34,47 +36,48 @@ function Shortcuts() {
|
||||||
|
|
||||||
const menuRef = useRef();
|
const menuRef = useRef();
|
||||||
|
|
||||||
const formattedShortcuts = useMemo(
|
const hasLists = useRef(false);
|
||||||
() =>
|
const formattedShortcuts = shortcuts
|
||||||
shortcuts
|
.map((pin, i) => {
|
||||||
.map((pin, i) => {
|
const { type, ...data } = pin;
|
||||||
const { type, ...data } = pin;
|
if (!SHORTCUTS_META[type]) return null;
|
||||||
if (!SHORTCUTS_META[type]) return null;
|
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
|
||||||
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
|
|
||||||
|
|
||||||
if (typeof id === 'function') {
|
if (typeof id === 'function') {
|
||||||
id = id(data, i);
|
id = id(data, i);
|
||||||
}
|
}
|
||||||
if (typeof path === 'function') {
|
if (typeof path === 'function') {
|
||||||
path = path(
|
path = path(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
instance: data.instance || instance,
|
instance: data.instance || instance,
|
||||||
},
|
},
|
||||||
i,
|
i,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (typeof title === 'function') {
|
if (typeof title === 'function') {
|
||||||
title = title(data, i);
|
title = title(data, i);
|
||||||
}
|
}
|
||||||
if (typeof subtitle === 'function') {
|
if (typeof subtitle === 'function') {
|
||||||
subtitle = subtitle(data, i);
|
subtitle = subtitle(data, i);
|
||||||
}
|
}
|
||||||
if (typeof icon === 'function') {
|
if (typeof icon === 'function') {
|
||||||
icon = icon(data, i);
|
icon = icon(data, i);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (id === 'lists') {
|
||||||
id,
|
hasLists.current = true;
|
||||||
path,
|
}
|
||||||
title,
|
|
||||||
subtitle,
|
return {
|
||||||
icon,
|
id,
|
||||||
};
|
path,
|
||||||
})
|
title,
|
||||||
.filter(Boolean),
|
subtitle,
|
||||||
[shortcuts],
|
icon,
|
||||||
);
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
|
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
|
||||||
|
@ -88,6 +91,8 @@ function Shortcuts() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [lists, setLists] = useState([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="shortcuts">
|
<div id="shortcuts">
|
||||||
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
|
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
|
||||||
|
@ -147,6 +152,11 @@ function Shortcuts() {
|
||||||
menuClassName="glass-menu shortcuts-menu"
|
menuClassName="glass-menu shortcuts-menu"
|
||||||
gap={8}
|
gap={8}
|
||||||
position="anchor"
|
position="anchor"
|
||||||
|
onMenuChange={(e) => {
|
||||||
|
if (e.open && hasLists.current) {
|
||||||
|
getLists().then(setLists);
|
||||||
|
}
|
||||||
|
}}
|
||||||
menuButton={
|
menuButton={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -171,6 +181,35 @@ function Shortcuts() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formattedShortcuts.map(({ id, 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 (
|
return (
|
||||||
<MenuLink
|
<MenuLink
|
||||||
to={path}
|
to={path}
|
||||||
|
|
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?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,11 @@
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'preact/hooks';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
@ -7,8 +13,10 @@ import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import FilterContext from '../utils/filter-context';
|
import FilterContext from '../utils/filter-context';
|
||||||
import { filteredItems, isFiltered } from '../utils/filters';
|
import { filteredItems, isFiltered } from '../utils/filters';
|
||||||
|
import isRTL from '../utils/is-rtl';
|
||||||
import states, { statusKey } from '../utils/states';
|
import states, { statusKey } from '../utils/states';
|
||||||
import statusPeek from '../utils/status-peek';
|
import statusPeek from '../utils/status-peek';
|
||||||
|
import { isMediaFirstInstance } from '../utils/store-utils';
|
||||||
import { groupBoosts, groupContext } from '../utils/timeline-utils';
|
import { groupBoosts, groupContext } from '../utils/timeline-utils';
|
||||||
import useInterval from '../utils/useInterval';
|
import useInterval from '../utils/useInterval';
|
||||||
import usePageVisibility from '../utils/usePageVisibility';
|
import usePageVisibility from '../utils/usePageVisibility';
|
||||||
|
@ -48,10 +56,11 @@ function Timeline({
|
||||||
filterContext,
|
filterContext,
|
||||||
showFollowedTags,
|
showFollowedTags,
|
||||||
showReplyParent,
|
showReplyParent,
|
||||||
|
clearWhenRefresh,
|
||||||
}) {
|
}) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('start');
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [visible, setVisible] = useState(true);
|
const [visible, setVisible] = useState(true);
|
||||||
|
@ -59,15 +68,20 @@ function Timeline({
|
||||||
|
|
||||||
console.debug('RENDER Timeline', id, refresh);
|
console.debug('RENDER Timeline', id, refresh);
|
||||||
|
|
||||||
|
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
|
||||||
|
|
||||||
const allowGrouping = view !== 'media';
|
const allowGrouping = view !== 'media';
|
||||||
|
const loadItemsTS = useRef(0); // Ensures only one loadItems at a time
|
||||||
const loadItems = useDebouncedCallback(
|
const loadItems = useDebouncedCallback(
|
||||||
(firstLoad) => {
|
(firstLoad) => {
|
||||||
setShowNew(false);
|
setShowNew(false);
|
||||||
if (uiState === 'loading') return;
|
// if (uiState === 'loading') return;
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
const ts = (loadItemsTS.current = Date.now());
|
||||||
let { done, value } = await fetchItems(firstLoad);
|
let { done, value } = await fetchItems(firstLoad);
|
||||||
|
if (ts !== loadItemsTS.current) return;
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
// Avoid grouping for pinned posts
|
// Avoid grouping for pinned posts
|
||||||
const [pinnedPosts, otherPosts] = value.reduce(
|
const [pinnedPosts, otherPosts] = value.reduce(
|
||||||
|
@ -111,10 +125,10 @@ function Timeline({
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
1500,
|
1_000,
|
||||||
{
|
{
|
||||||
leading: true,
|
leading: true,
|
||||||
trailing: false,
|
// trailing: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -200,8 +214,8 @@ function Timeline({
|
||||||
|
|
||||||
const oRef = useHotkeys(['enter', 'o'], () => {
|
const oRef = useHotkeys(['enter', 'o'], () => {
|
||||||
// open active status
|
// open active status
|
||||||
const activeItem = document.activeElement.closest(itemsSelector);
|
const activeItem = document.activeElement;
|
||||||
if (activeItem) {
|
if (activeItem?.matches(itemsSelector)) {
|
||||||
activeItem.click();
|
activeItem.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -209,17 +223,13 @@ function Timeline({
|
||||||
const showNewPostsIndicator =
|
const showNewPostsIndicator =
|
||||||
items.length > 0 && uiState !== 'loading' && showNew;
|
items.length > 0 && uiState !== 'loading' && showNew;
|
||||||
const handleLoadNewPosts = useCallback(() => {
|
const handleLoadNewPosts = useCallback(() => {
|
||||||
loadItems(true);
|
if (showNewPostsIndicator) loadItems(true);
|
||||||
scrollableRef.current?.scrollTo({
|
scrollableRef.current?.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}, [loadItems]);
|
}, [loadItems, showNewPostsIndicator]);
|
||||||
const dotRef = useHotkeys('.', () => {
|
const dotRef = useHotkeys('.', handleLoadNewPosts);
|
||||||
if (showNewPostsIndicator) {
|
|
||||||
handleLoadNewPosts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// const {
|
// const {
|
||||||
// scrollDirection,
|
// scrollDirection,
|
||||||
|
@ -268,9 +278,18 @@ function Timeline({
|
||||||
scrollableRef.current?.scrollTo({ top: 0 });
|
scrollableRef.current?.scrollTo({ top: 0 });
|
||||||
loadItems(true);
|
loadItems(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
const firstLoad = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (firstLoad.current) {
|
||||||
|
firstLoad.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clearWhenRefresh && items?.length) {
|
||||||
|
loadItems.cancel?.();
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
loadItems(true);
|
loadItems(true);
|
||||||
}, [refresh]);
|
}, [clearWhenRefresh, refresh]);
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// if (reachStart) {
|
// if (reachStart) {
|
||||||
|
@ -359,14 +378,28 @@ function Timeline({
|
||||||
<FilterContext.Provider value={filterContext}>
|
<FilterContext.Provider value={filterContext}>
|
||||||
<div
|
<div
|
||||||
id={`${id}-page`}
|
id={`${id}-page`}
|
||||||
class="deck-container"
|
class={`deck-container ${
|
||||||
|
mediaFirst ? 'deck-container-media-first' : ''
|
||||||
|
}`}
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
scrollableRef.current = node;
|
scrollableRef.current = node;
|
||||||
jRef.current = node;
|
jRef.current = node;
|
||||||
kRef.current = node;
|
kRef.current = node;
|
||||||
oRef.current = node;
|
oRef.current = node;
|
||||||
|
dotRef.current = node;
|
||||||
}}
|
}}
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
// If click on timeline item, unhide header
|
||||||
|
if (
|
||||||
|
headerRef.current &&
|
||||||
|
e.target.closest('.timeline-item, .timeline-item-alt')
|
||||||
|
) {
|
||||||
|
setTimeout(() => {
|
||||||
|
headerRef.current.hidden = false;
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="timeline-deck deck">
|
<div class="timeline-deck deck">
|
||||||
<header
|
<header
|
||||||
|
@ -435,6 +468,7 @@ function Timeline({
|
||||||
view={view}
|
view={view}
|
||||||
showFollowedTags={showFollowedTags}
|
showFollowedTags={showFollowedTags}
|
||||||
showReplyParent={showReplyParent}
|
showReplyParent={showReplyParent}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{showMore &&
|
{showMore &&
|
||||||
|
@ -446,14 +480,14 @@ function Timeline({
|
||||||
height: '20vh',
|
height: '20vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Status skeleton />
|
<Status skeleton mediaFirst={mediaFirst} />
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
style={{
|
style={{
|
||||||
height: '25vh',
|
height: '25vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Status skeleton />
|
<Status skeleton mediaFirst={mediaFirst} />
|
||||||
</li>
|
</li>
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
|
@ -493,13 +527,14 @@ function Timeline({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<li key={i}>
|
<li key={i}>
|
||||||
<Status skeleton />
|
<Status skeleton mediaFirst={mediaFirst} />
|
||||||
</li>
|
</li>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
|
uiState !== 'error' &&
|
||||||
|
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
|
||||||
)}
|
)}
|
||||||
{uiState === 'error' && (
|
{uiState === 'error' && (
|
||||||
<p class="ui-state">
|
<p class="ui-state">
|
||||||
|
@ -527,6 +562,7 @@ const TimelineItem = memo(
|
||||||
view,
|
view,
|
||||||
showFollowedTags,
|
showFollowedTags,
|
||||||
showReplyParent,
|
showReplyParent,
|
||||||
|
mediaFirst,
|
||||||
}) => {
|
}) => {
|
||||||
console.debug('RENDER TimelineItem', status.id);
|
console.debug('RENDER TimelineItem', status.id);
|
||||||
const { id: statusID, reblog, items, type, _pinned } = status;
|
const { id: statusID, reblog, items, type, _pinned } = status;
|
||||||
|
@ -535,16 +571,18 @@ const TimelineItem = memo(
|
||||||
const url = instance
|
const url = instance
|
||||||
? `/${instance}/s/${actualStatusID}`
|
? `/${instance}/s/${actualStatusID}`
|
||||||
: `/s/${actualStatusID}`;
|
: `/s/${actualStatusID}`;
|
||||||
let title = '';
|
|
||||||
if (type === 'boosts') {
|
|
||||||
title = `${items.length} Boosts`;
|
|
||||||
} else if (type === 'pinned') {
|
|
||||||
title = 'Pinned posts';
|
|
||||||
}
|
|
||||||
const isCarousel = type === 'boosts' || type === 'pinned';
|
|
||||||
if (items) {
|
if (items) {
|
||||||
const fItems = filteredItems(items, filterContext);
|
let fItems = filteredItems(items, filterContext);
|
||||||
|
let title = '';
|
||||||
|
if (type === 'boosts') {
|
||||||
|
title = `${fItems.length} Boosts`;
|
||||||
|
} else if (type === 'pinned') {
|
||||||
|
title = 'Pinned posts';
|
||||||
|
}
|
||||||
|
const isCarousel = type === 'boosts' || type === 'pinned';
|
||||||
if (isCarousel) {
|
if (isCarousel) {
|
||||||
|
const filteredItemsIDs = new Set();
|
||||||
// Here, we don't hide filtered posts, but we sort them last
|
// Here, we don't hide filtered posts, but we sort them last
|
||||||
fItems.sort((a, b) => {
|
fItems.sort((a, b) => {
|
||||||
// if (a._filtered && !b._filtered) {
|
// if (a._filtered && !b._filtered) {
|
||||||
|
@ -555,6 +593,8 @@ const TimelineItem = memo(
|
||||||
// }
|
// }
|
||||||
const aFiltered = isFiltered(a.filtered, filterContext);
|
const aFiltered = isFiltered(a.filtered, filterContext);
|
||||||
const bFiltered = isFiltered(b.filtered, filterContext);
|
const bFiltered = isFiltered(b.filtered, filterContext);
|
||||||
|
if (aFiltered) filteredItemsIDs.add(a.id);
|
||||||
|
if (bFiltered) filteredItemsIDs.add(b.id);
|
||||||
if (aFiltered && !bFiltered) {
|
if (aFiltered && !bFiltered) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
@ -563,11 +603,69 @@ const TimelineItem = memo(
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (filteredItemsIDs.size >= 2) {
|
||||||
|
const GROUP_SIZE = 5;
|
||||||
|
// If 2 or more, group filtered items into one, limit to GROUP_SIZE in a group
|
||||||
|
const unfiltered = [];
|
||||||
|
const filtered = [];
|
||||||
|
fItems.forEach((item) => {
|
||||||
|
if (filteredItemsIDs.has(item.id)) {
|
||||||
|
filtered.push(item);
|
||||||
|
} else {
|
||||||
|
unfiltered.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const filteredItems = [];
|
||||||
|
for (let i = 0; i < filtered.length; i += GROUP_SIZE) {
|
||||||
|
filteredItems.push({
|
||||||
|
_grouped: true,
|
||||||
|
posts: filtered.slice(i, i + GROUP_SIZE),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fItems = unfiltered.concat(filteredItems);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={`timeline-${statusID}`} class="timeline-item-carousel">
|
<li key={`timeline-${statusID}`} class="timeline-item-carousel">
|
||||||
<StatusCarousel title={title} class={`${type}-carousel`}>
|
<StatusCarousel title={title} class={`${type}-carousel`}>
|
||||||
{fItems.map((item) => {
|
{fItems.map((item) => {
|
||||||
const { id: statusID, reblog, _pinned } = item;
|
const { id: statusID, reblog, _pinned, _grouped } = item;
|
||||||
|
if (_grouped) {
|
||||||
|
return (
|
||||||
|
<li key={statusID} class="timeline-item-carousel-group">
|
||||||
|
{item.posts.map((item) => {
|
||||||
|
const { id: statusID, reblog, _pinned } = item;
|
||||||
|
const actualStatusID = reblog?.id || statusID;
|
||||||
|
const url = instance
|
||||||
|
? `/${instance}/s/${actualStatusID}`
|
||||||
|
: `/s/${actualStatusID}`;
|
||||||
|
if (_pinned) useItemID = false;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
class="status-carousel-link timeline-item-alt"
|
||||||
|
to={url}
|
||||||
|
>
|
||||||
|
{useItemID ? (
|
||||||
|
<Status
|
||||||
|
statusID={statusID}
|
||||||
|
instance={instance}
|
||||||
|
size="s"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Status
|
||||||
|
status={item}
|
||||||
|
instance={instance}
|
||||||
|
size="s"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const actualStatusID = reblog?.id || statusID;
|
const actualStatusID = reblog?.id || statusID;
|
||||||
const url = instance
|
const url = instance
|
||||||
? `/${instance}/s/${actualStatusID}`
|
? `/${instance}/s/${actualStatusID}`
|
||||||
|
@ -587,6 +685,7 @@ const TimelineItem = memo(
|
||||||
contentTextWeight
|
contentTextWeight
|
||||||
enableCommentHint
|
enableCommentHint
|
||||||
// allowFilters={allowFilters}
|
// allowFilters={allowFilters}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Status
|
<Status
|
||||||
|
@ -596,6 +695,7 @@ const TimelineItem = memo(
|
||||||
contentTextWeight
|
contentTextWeight
|
||||||
enableCommentHint
|
enableCommentHint
|
||||||
// allowFilters={allowFilters}
|
// allowFilters={allowFilters}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -632,7 +732,11 @@ const TimelineItem = memo(
|
||||||
>
|
>
|
||||||
<Link class="status-link timeline-item" to={url}>
|
<Link class="status-link timeline-item" to={url}>
|
||||||
{showCompact ? (
|
{showCompact ? (
|
||||||
<TimelineStatusCompact status={item} instance={instance} />
|
<TimelineStatusCompact
|
||||||
|
status={item}
|
||||||
|
instance={instance}
|
||||||
|
filterContext={filterContext}
|
||||||
|
/>
|
||||||
) : useItemID ? (
|
) : useItemID ? (
|
||||||
<Status
|
<Status
|
||||||
statusID={statusID}
|
statusID={statusID}
|
||||||
|
@ -691,6 +795,7 @@ const TimelineItem = memo(
|
||||||
showFollowedTags={showFollowedTags}
|
showFollowedTags={showFollowedTags}
|
||||||
showReplyParent={showReplyParent}
|
showReplyParent={showReplyParent}
|
||||||
// allowFilters={allowFilters}
|
// allowFilters={allowFilters}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Status
|
<Status
|
||||||
|
@ -700,6 +805,7 @@ const TimelineItem = memo(
|
||||||
showFollowedTags={showFollowedTags}
|
showFollowedTags={showFollowedTags}
|
||||||
showReplyParent={showReplyParent}
|
showReplyParent={showReplyParent}
|
||||||
// allowFilters={allowFilters}
|
// allowFilters={allowFilters}
|
||||||
|
mediaFirst={mediaFirst}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -759,8 +865,11 @@ function StatusCarousel({ title, class: className, children }) {
|
||||||
class="small plain2"
|
class="small plain2"
|
||||||
// disabled={reachStart}
|
// disabled={reachStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const left =
|
||||||
|
Math.min(320, carouselRef.current?.offsetWidth) *
|
||||||
|
(isRTL() ? 1 : -1);
|
||||||
carouselRef.current?.scrollBy({
|
carouselRef.current?.scrollBy({
|
||||||
left: -Math.min(320, carouselRef.current?.offsetWidth),
|
left,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
@ -773,8 +882,11 @@ function StatusCarousel({ title, class: className, children }) {
|
||||||
class="small plain2"
|
class="small plain2"
|
||||||
// disabled={reachEnd}
|
// disabled={reachEnd}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const left =
|
||||||
|
Math.min(320, carouselRef.current?.offsetWidth) *
|
||||||
|
(isRTL() ? -1 : 1);
|
||||||
carouselRef.current?.scrollBy({
|
carouselRef.current?.scrollBy({
|
||||||
left: Math.min(320, carouselRef.current?.offsetWidth),
|
left,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
@ -804,11 +916,12 @@ function StatusCarousel({ title, class: className, children }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineStatusCompact({ status, instance }) {
|
function TimelineStatusCompact({ status, instance, filterContext }) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const { id, visibility, language } = status;
|
const { id, visibility, language } = status;
|
||||||
const statusPeekText = statusPeek(status);
|
const statusPeekText = statusPeek(status);
|
||||||
const sKey = statusKey(id, instance);
|
const sKey = statusKey(id, instance);
|
||||||
|
const filterInfo = isFiltered(status.filtered, filterContext);
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
class={`status compact-thread ${
|
class={`status compact-thread ${
|
||||||
|
@ -834,13 +947,24 @@ function TimelineStatusCompact({ status, instance }) {
|
||||||
lang={language}
|
lang={language}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
>
|
>
|
||||||
{statusPeekText}
|
{!!filterInfo ? (
|
||||||
{status.sensitive && status.spoilerText && (
|
<b
|
||||||
|
class="status-filtered-badge badge-meta horizontal"
|
||||||
|
title={filterInfo?.titlesStr || ''}
|
||||||
|
>
|
||||||
|
<span>Filtered</span>: <span>{filterInfo?.titlesStr || ''}</span>
|
||||||
|
</b>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{statusPeekText}
|
||||||
<span class="spoiler-badge">
|
{status.sensitive && status.spoilerText && (
|
||||||
<Icon icon="eye-close" size="s" />
|
<>
|
||||||
</span>
|
{' '}
|
||||||
|
<span class="spoiler-badge">
|
||||||
|
<Icon icon="eye-close" size="s" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to top left,
|
to top var(--backward),
|
||||||
var(--bg-color) 50%,
|
var(--bg-color) 50%,
|
||||||
var(--bg-faded-blur-color)
|
var(--bg-faded-blur-color)
|
||||||
);
|
);
|
||||||
|
@ -44,12 +44,13 @@
|
||||||
.status-translation-block .translated-block {
|
.status-translation-block .translated-block {
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
border-radius: 0 8px 8px 8px;
|
border-radius: 8px;
|
||||||
|
border-start-start-radius: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to bottom right,
|
to bottom var(--forward),
|
||||||
var(--bg-color),
|
var(--bg-color),
|
||||||
var(--bg-faded-blur-color)
|
var(--bg-faded-blur-color)
|
||||||
);
|
);
|
||||||
|
|
|
@ -10,6 +10,7 @@ import localeCode2Text from '../utils/localeCode2Text';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
|
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
|
import LazyShazam from './lazy-shazam';
|
||||||
import Loader from './loader';
|
import Loader from './loader';
|
||||||
|
|
||||||
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
|
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
|
||||||
|
@ -76,6 +77,7 @@ function TranslationBlock({
|
||||||
onTranslate,
|
onTranslate,
|
||||||
text = '',
|
text = '',
|
||||||
mini,
|
mini,
|
||||||
|
autoDetected,
|
||||||
}) {
|
}) {
|
||||||
const targetLang = getTranslateTargetLanguage(true);
|
const targetLang = getTranslateTargetLanguage(true);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
@ -142,23 +144,21 @@ function TranslationBlock({
|
||||||
detectedLang !== targetLangText
|
detectedLang !== targetLangText
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div class="shazam-container">
|
<LazyShazam>
|
||||||
<div class="shazam-container-inner">
|
<div class="status-translation-block-mini">
|
||||||
<div class="status-translation-block-mini">
|
<Icon
|
||||||
<Icon
|
icon="translate"
|
||||||
icon="translate"
|
alt={`Auto-translated from ${sourceLangText}`}
|
||||||
alt={`Auto-translated from ${sourceLangText}`}
|
/>
|
||||||
/>
|
<output
|
||||||
<output
|
lang={targetLang}
|
||||||
lang={targetLang}
|
dir="auto"
|
||||||
dir="auto"
|
title={pronunciationContent || ''}
|
||||||
title={pronunciationContent || ''}
|
>
|
||||||
>
|
{translatedContent}
|
||||||
{translatedContent}
|
</output>
|
||||||
</output>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</LazyShazam>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -188,7 +188,9 @@ function TranslationBlock({
|
||||||
{uiState === 'loading'
|
{uiState === 'loading'
|
||||||
? 'Translating…'
|
? 'Translating…'
|
||||||
: sourceLanguage && sourceLangText && !detectedLang
|
: sourceLanguage && sourceLangText && !detectedLang
|
||||||
? `Translate from ${sourceLangText}`
|
? autoDetected
|
||||||
|
? `Translate from ${sourceLangText} (auto-detected)`
|
||||||
|
: `Translate from ${sourceLangText}`
|
||||||
: `Translate`}
|
: `Translate`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
import './polyfills';
|
||||||
|
|
||||||
import { render } from 'preact';
|
import { render } from 'preact';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
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';
|
import useTitle from './utils/useTitle';
|
||||||
|
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
|
@ -27,6 +28,10 @@ function App() {
|
||||||
: 'Compose',
|
: 'Compose',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initStates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (uiState === 'closed') {
|
if (uiState === 'closed') {
|
||||||
try {
|
try {
|
||||||
|
@ -57,7 +62,7 @@ function App() {
|
||||||
console.debug('OPEN COMPOSE');
|
console.debug('OPEN COMPOSE');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Compose
|
<ComposeSuspense
|
||||||
editStatus={editStatus}
|
editStatus={editStatus}
|
||||||
replyToStatus={replyToStatus}
|
replyToStatus={replyToStatus}
|
||||||
draftStatus={draftStatus}
|
draftStatus={draftStatus}
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
{
|
{
|
||||||
"@mastodon/edit-media-attributes": ">=4.1",
|
"@mastodon/edit-media-attributes": ">=4.1",
|
||||||
"@mastodon/list-exclusive": ">=4.2"
|
"@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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
--sai-left: env(safe-area-inset-left);
|
--sai-left: env(safe-area-inset-left);
|
||||||
|
|
||||||
--text-size: 16px;
|
--text-size: 16px;
|
||||||
--main-width: 40em;
|
--main-width: max(60dvw, 40em);
|
||||||
text-size-adjust: none;
|
text-size-adjust: none;
|
||||||
--hairline-width: 1px;
|
--hairline-width: 1px;
|
||||||
--monospace-font: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
|
--monospace-font: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
|
||||||
|
@ -16,6 +16,12 @@
|
||||||
|
|
||||||
--blue-color: royalblue;
|
--blue-color: royalblue;
|
||||||
--purple-color: blueviolet;
|
--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;
|
--green-color: darkgreen;
|
||||||
--orange-color: darkorange;
|
--orange-color: darkorange;
|
||||||
--orange-light-bg-color: color-mix(
|
--orange-light-bg-color: color-mix(
|
||||||
|
@ -23,7 +29,18 @@
|
||||||
var(--orange-color) 20%,
|
var(--orange-color) 20%,
|
||||||
transparent
|
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-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);
|
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
|
||||||
--bg-color: #fff;
|
--bg-color: #fff;
|
||||||
--bg-faded-color: #f0f2f5;
|
--bg-faded-color: #f0f2f5;
|
||||||
|
@ -91,6 +108,19 @@
|
||||||
|
|
||||||
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
||||||
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
--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) {
|
@media (min-resolution: 2dppx) {
|
||||||
|
@ -227,7 +257,7 @@ button[hidden] {
|
||||||
}
|
}
|
||||||
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
|
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
filter: brightness(1.2);
|
filter: brightness(1.05);
|
||||||
}
|
}
|
||||||
:is(button, .button):not(:disabled, .disabled):active {
|
:is(button, .button):not(:disabled, .disabled):active {
|
||||||
filter: brightness(0.8);
|
filter: brightness(0.8);
|
||||||
|
@ -328,6 +358,7 @@ button[hidden] {
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='text'],
|
input[type='text'],
|
||||||
|
input[type='search'],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
@ -337,6 +368,7 @@ select {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
input[type='text']:focus,
|
input[type='text']:focus,
|
||||||
|
input[type='search']:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
border-color: var(--outline-color);
|
border-color: var(--outline-color);
|
||||||
|
@ -352,16 +384,22 @@ textarea:disabled {
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(input[type='text'], textarea, select).block {
|
:is(input[type='text'], input[type='search'], textarea, select).block {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.small {
|
:is(button, .button).small {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.centered {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
select.plain {
|
select.plain {
|
||||||
border: 0;
|
border: 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
@ -415,6 +453,11 @@ kbd {
|
||||||
display: initial;
|
display: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bidi-isolate {
|
||||||
|
direction: initial;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
/* KEYFRAMES */
|
/* KEYFRAMES */
|
||||||
|
|
||||||
@keyframes appear {
|
@keyframes appear {
|
||||||
|
@ -526,3 +569,9 @@ kbd {
|
||||||
.shazam-container-horizontal[hidden] {
|
.shazam-container-horizontal[hidden] {
|
||||||
grid-template-columns: 0fr;
|
grid-template-columns: 0fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
17
src/main.jsx
17
src/main.jsx
|
@ -1,10 +1,10 @@
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
import './cloak-mode.css';
|
import './cloak-mode.css';
|
||||||
|
import './polyfills';
|
||||||
|
|
||||||
// Polyfill needed for Firefox < 122
|
// Polyfill needed for Firefox < 122
|
||||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
|
||||||
import '@formatjs/intl-segmenter/polyfill';
|
// import '@formatjs/intl-segmenter/polyfill';
|
||||||
import { render } from 'preact';
|
import { render } from 'preact';
|
||||||
import { HashRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -14,19 +14,6 @@ if (import.meta.env.DEV) {
|
||||||
import('preact/debug');
|
import('preact/debug');
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbortSignal.timeout polyfill
|
|
||||||
// Temporary fix from https://github.com/mo/abortcontroller-polyfill/issues/73#issuecomment-1541180943
|
|
||||||
// Incorrect implementation, but should be good enough for now
|
|
||||||
if ('AbortSignal' in window) {
|
|
||||||
AbortSignal.timeout =
|
|
||||||
AbortSignal.timeout ||
|
|
||||||
((duration) => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
setTimeout(() => controller.abort(), duration);
|
|
||||||
return controller.signal;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode/';
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -18,8 +19,8 @@ import Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import { saveStatus } from '../utils/states';
|
import { isMediaFirstInstance } from '../utils/store-utils';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
@ -67,6 +68,8 @@ function AccountStatuses() {
|
||||||
searchOffsetRef.current = 0;
|
searchOffsetRef.current = 0;
|
||||||
}, allSearchParams);
|
}, allSearchParams);
|
||||||
|
|
||||||
|
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
|
||||||
|
|
||||||
const sameCurrentInstance = useMemo(
|
const sameCurrentInstance = useMemo(
|
||||||
() => instance === currentInstance,
|
() => instance === currentInstance,
|
||||||
[instance, currentInstance],
|
[instance, currentInstance],
|
||||||
|
@ -150,7 +153,7 @@ function AccountStatuses() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
let results = [];
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
const { value } = await masto.v1.accounts
|
const { value } = await masto.v1.accounts
|
||||||
.$select(id)
|
.$select(id)
|
||||||
|
@ -185,12 +188,32 @@ function AccountStatuses() {
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
exclude_replies: excludeReplies,
|
exclude_replies: excludeReplies,
|
||||||
exclude_reblogs: excludeBoosts,
|
exclude_reblogs: excludeBoosts,
|
||||||
only_media: media,
|
only_media: media || undefined,
|
||||||
tagged,
|
tagged,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { value, done } = await accountStatusesIterator.current.next();
|
const { value, done } = await accountStatusesIterator.current.next();
|
||||||
if (value?.length) {
|
if (value?.length) {
|
||||||
|
// Check if value is same as pinned post (results)
|
||||||
|
// If the index for every post is the same, means API might not support pinned posts
|
||||||
|
if (results.length) {
|
||||||
|
let pinnedStatusesIds = [];
|
||||||
|
if (results[0]?.type === 'pinned') {
|
||||||
|
pinnedStatusesIds = results[0].id;
|
||||||
|
} else {
|
||||||
|
pinnedStatusesIds = results
|
||||||
|
.filter((status) => status._pinned)
|
||||||
|
.map((status) => status.id);
|
||||||
|
}
|
||||||
|
const containsAllPinned = pinnedStatusesIds.every((postId) =>
|
||||||
|
value.some((status) => status.id === postId),
|
||||||
|
);
|
||||||
|
if (containsAllPinned) {
|
||||||
|
// Remove pinned posts
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
results.push(...value);
|
results.push(...value);
|
||||||
|
|
||||||
value.forEach((item) => {
|
value.forEach((item) => {
|
||||||
|
@ -206,8 +229,12 @@ function AccountStatuses() {
|
||||||
const [featuredTags, setFeaturedTags] = useState([]);
|
const [featuredTags, setFeaturedTags] = useState([]);
|
||||||
useTitle(
|
useTitle(
|
||||||
account?.acct
|
account?.acct
|
||||||
? `${account?.displayName ? account.displayName + ' ' : ''}@${
|
? `${
|
||||||
account.acct
|
account?.displayName
|
||||||
|
? `${account.displayName} (${/@/.test(account.acct) ? '' : '@'}${
|
||||||
|
account.acct
|
||||||
|
})`
|
||||||
|
: `${/@/.test(account.acct) ? '' : '@'}${account.acct}`
|
||||||
}${
|
}${
|
||||||
!excludeReplies
|
!excludeReplies
|
||||||
? ' (+ Replies)'
|
? ' (+ Replies)'
|
||||||
|
@ -245,17 +272,21 @@ function AccountStatuses() {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
try {
|
// No need, because the whole filter bar is hidden
|
||||||
const featuredTags = await masto.v1.accounts
|
// TODO: Revisit this
|
||||||
.$select(id)
|
if (!mediaFirst) {
|
||||||
.featuredTags.list();
|
try {
|
||||||
console.log({ featuredTags });
|
const featuredTags = await masto.v1.accounts
|
||||||
setFeaturedTags(featuredTags);
|
.$select(id)
|
||||||
} catch (e) {
|
.featuredTags.list();
|
||||||
console.error(e);
|
console.log({ featuredTags });
|
||||||
|
setFeaturedTags(featuredTags);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [id]);
|
}, [id, mediaFirst]);
|
||||||
|
|
||||||
const { displayName, acct, emojis } = account || {};
|
const { displayName, acct, emojis } = account || {};
|
||||||
|
|
||||||
|
@ -274,95 +305,126 @@ function AccountStatuses() {
|
||||||
authenticated={authenticated}
|
authenticated={authenticated}
|
||||||
standalone
|
standalone
|
||||||
/>
|
/>
|
||||||
<div
|
{!mediaFirst && (
|
||||||
class="filter-bar"
|
<div
|
||||||
ref={filterBarRef}
|
class="filter-bar"
|
||||||
style={{
|
ref={filterBarRef}
|
||||||
position: 'relative',
|
style={{
|
||||||
}}
|
position: 'relative',
|
||||||
>
|
}}
|
||||||
{filtered ? (
|
>
|
||||||
|
{filtered ? (
|
||||||
|
<Link
|
||||||
|
to={`/${instance}/a/${id}`}
|
||||||
|
class="insignificant filter-clear"
|
||||||
|
title="Clear filters"
|
||||||
|
key="clear-filters"
|
||||||
|
>
|
||||||
|
<Icon icon="x" size="l" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Icon icon="filter" class="insignificant" size="l" />
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
to={`/${instance}/a/${id}`}
|
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
|
||||||
class="insignificant filter-clear"
|
|
||||||
title="Clear filters"
|
|
||||||
key="clear-filters"
|
|
||||||
>
|
|
||||||
<Icon icon="x" size="l" />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Icon icon="filter" class="insignificant" size="l" />
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (excludeReplies) {
|
|
||||||
showToast('Showing post with replies');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class={excludeReplies ? '' : 'is-active'}
|
|
||||||
>
|
|
||||||
+ Replies
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (!excludeBoosts) {
|
|
||||||
showToast('Showing posts without boosts');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class={!excludeBoosts ? '' : 'is-active'}
|
|
||||||
>
|
|
||||||
- Boosts
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (!media) {
|
|
||||||
showToast('Showing posts with media');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class={media ? 'is-active' : ''}
|
|
||||||
>
|
|
||||||
Media
|
|
||||||
</Link>
|
|
||||||
{featuredTags.map((tag) => (
|
|
||||||
<Link
|
|
||||||
key={tag.id}
|
|
||||||
to={`/${instance}/a/${id}${
|
|
||||||
tagged === tag.name
|
|
||||||
? ''
|
|
||||||
: `?tagged=${encodeURIComponent(tag.name)}`
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (tagged !== tag.name) {
|
if (excludeReplies) {
|
||||||
showToast(`Showing posts tagged with #${tag.name}`);
|
showToast('Showing post with replies');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
class={tagged === tag.name ? 'is-active' : ''}
|
class={excludeReplies ? '' : 'is-active'}
|
||||||
>
|
>
|
||||||
<span>
|
+ Replies
|
||||||
<span class="more-insignificant">#</span>
|
|
||||||
{tag.name}
|
|
||||||
</span>
|
|
||||||
{
|
|
||||||
// The count differs based on instance 😅
|
|
||||||
}
|
|
||||||
{/* <span class="filter-count">{tag.statusesCount}</span> */}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
<Link
|
||||||
{searchEnabled &&
|
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
|
||||||
(supportsInputMonth ? (
|
onClick={() => {
|
||||||
<label class={`filter-field ${month ? 'is-active' : ''}`}>
|
if (!excludeBoosts) {
|
||||||
<Icon icon="month" size="l" />
|
showToast('Showing posts without boosts');
|
||||||
<input
|
}
|
||||||
type="month"
|
}}
|
||||||
|
class={!excludeBoosts ? '' : 'is-active'}
|
||||||
|
>
|
||||||
|
- Boosts
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!media) {
|
||||||
|
showToast('Showing posts with media');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class={media ? 'is-active' : ''}
|
||||||
|
>
|
||||||
|
Media
|
||||||
|
</Link>
|
||||||
|
{featuredTags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag.id}
|
||||||
|
to={`/${instance}/a/${id}${
|
||||||
|
tagged === tag.name
|
||||||
|
? ''
|
||||||
|
: `?tagged=${encodeURIComponent(tag.name)}`
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (tagged !== tag.name) {
|
||||||
|
showToast(`Showing posts tagged with #${tag.name}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class={tagged === tag.name ? 'is-active' : ''}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="more-insignificant">#</span>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
{
|
||||||
|
// The count differs based on instance 😅
|
||||||
|
}
|
||||||
|
{/* <span class="filter-count">{tag.statusesCount}</span> */}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{searchEnabled &&
|
||||||
|
(supportsInputMonth ? (
|
||||||
|
<label class={`filter-field ${month ? 'is-active' : ''}`}>
|
||||||
|
<Icon icon="month" size="l" />
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
disabled={!account?.acct}
|
||||||
|
value={month || ''}
|
||||||
|
min={MIN_YEAR_MONTH}
|
||||||
|
max={new Date().toISOString().slice(0, 7)}
|
||||||
|
onInput={(e) => {
|
||||||
|
const { value, validity } = e.currentTarget;
|
||||||
|
if (!validity.valid) return;
|
||||||
|
setSearchParams(
|
||||||
|
value
|
||||||
|
? {
|
||||||
|
month: value,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
);
|
||||||
|
const [year, month] = value.split('-');
|
||||||
|
const monthIndex = parseInt(month, 10) - 1;
|
||||||
|
const date = new Date(year, monthIndex);
|
||||||
|
showToast(
|
||||||
|
`Showing posts in ${date.toLocaleString('default', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
// Fallback to <select> for month and <input type="number"> for year
|
||||||
|
<MonthPicker
|
||||||
|
class={`filter-field ${month ? 'is-active' : ''}`}
|
||||||
disabled={!account?.acct}
|
disabled={!account?.acct}
|
||||||
value={month || ''}
|
value={month || ''}
|
||||||
min={MIN_YEAR_MONTH}
|
min={MIN_YEAR_MONTH}
|
||||||
max={new Date().toISOString().slice(0, 7)}
|
max={new Date().toISOString().slice(0, 7)}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const { value, validity } = e.currentTarget;
|
const { value, validity } = e;
|
||||||
if (!validity.valid) return;
|
if (!validity.valid) return;
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
value
|
value
|
||||||
|
@ -371,40 +433,11 @@ function AccountStatuses() {
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
);
|
);
|
||||||
const [year, month] = value.split('-');
|
|
||||||
const monthIndex = parseInt(month, 10) - 1;
|
|
||||||
const date = new Date(year, monthIndex);
|
|
||||||
showToast(
|
|
||||||
`Showing posts in ${date.toLocaleString('default', {
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})}`,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
))}
|
||||||
) : (
|
</div>
|
||||||
// Fallback to <select> for month and <input type="number"> for year
|
)}
|
||||||
<MonthPicker
|
|
||||||
class={`filter-field ${month ? 'is-active' : ''}`}
|
|
||||||
disabled={!account?.acct}
|
|
||||||
value={month || ''}
|
|
||||||
min={MIN_YEAR_MONTH}
|
|
||||||
max={new Date().toISOString().slice(0, 7)}
|
|
||||||
onInput={(e) => {
|
|
||||||
const { value, validity } = e;
|
|
||||||
if (!validity.valid) return;
|
|
||||||
setSearchParams(
|
|
||||||
value
|
|
||||||
? {
|
|
||||||
month: value,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
|
@ -433,7 +466,7 @@ function AccountStatuses() {
|
||||||
|
|
||||||
const accountInstance = useMemo(() => {
|
const accountInstance = useMemo(() => {
|
||||||
if (!account?.url) return null;
|
if (!account?.url) return null;
|
||||||
const domain = new URL(account.url).hostname;
|
const domain = URL.parse(account.url).hostname;
|
||||||
return domain;
|
return domain;
|
||||||
}, [account]);
|
}, [account]);
|
||||||
const sameInstance = instance === accountInstance;
|
const sameInstance = instance === accountInstance;
|
||||||
|
@ -467,7 +500,7 @@ function AccountStatuses() {
|
||||||
errorText="Unable to load posts"
|
errorText="Unable to load posts"
|
||||||
fetchItems={fetchAccountStatuses}
|
fetchItems={fetchAccountStatuses}
|
||||||
useItemID
|
useItemID
|
||||||
view={media ? 'media' : undefined}
|
view={media || mediaFirst ? 'media' : undefined}
|
||||||
boostsCarousel={snapStates.settings.boostsCarousel}
|
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||||
timelineStart={TimelineStart}
|
timelineStart={TimelineStart}
|
||||||
refresh={[
|
refresh={[
|
||||||
|
@ -512,7 +545,13 @@ function AccountStatuses() {
|
||||||
>
|
>
|
||||||
<Icon icon="transfer" />{' '}
|
<Icon icon="transfer" />{' '}
|
||||||
<small class="menu-double-lines">
|
<small class="menu-double-lines">
|
||||||
Switch to account's instance (<b>{accountInstance}</b>)
|
Switch to account's instance{' '}
|
||||||
|
{accountInstance ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
(<b>{punycode.toUnicode(accountInstance)}</b>)
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</small>
|
</small>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{!sameCurrentInstance && (
|
{!sameCurrentInstance && (
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#accounts-container section > ul > li .current {
|
#accounts-container section > ul > li .current {
|
||||||
margin-right: 8px;
|
margin-inline-end: 8px;
|
||||||
color: var(--green-color);
|
color: var(--green-color);
|
||||||
opacity: 0.1;
|
opacity: 0.1;
|
||||||
}
|
}
|
||||||
|
@ -47,7 +47,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#accounts-container .avatar {
|
#accounts-container .avatar {
|
||||||
margin-right: 8px;
|
margin-inline-end: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#accounts-container .accounts-list li div {
|
#accounts-container .accounts-list li div {
|
||||||
|
|
|
@ -7,18 +7,19 @@ import { useReducer } from 'preact/hooks';
|
||||||
import Avatar from '../components/avatar';
|
import Avatar from '../components/avatar';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import Link from '../components/link';
|
import Link from '../components/link';
|
||||||
import Menu2 from '../components/menu2';
|
|
||||||
import MenuConfirm from '../components/menu-confirm';
|
import MenuConfirm from '../components/menu-confirm';
|
||||||
|
import Menu2 from '../components/menu2';
|
||||||
import NameText from '../components/name-text';
|
import NameText from '../components/name-text';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
import { getCurrentAccountID, setCurrentAccountID } from '../utils/store-utils';
|
||||||
|
|
||||||
function Accounts({ onClose }) {
|
function Accounts({ onClose }) {
|
||||||
const { masto } = api();
|
const { masto } = api();
|
||||||
// Accounts
|
// Accounts
|
||||||
const accounts = store.local.getJSON('accounts');
|
const accounts = store.local.getJSON('accounts');
|
||||||
const currentAccount = store.session.get('currentAccount');
|
const currentAccount = getCurrentAccountID();
|
||||||
const moreThanOneAccount = accounts.length > 1;
|
const moreThanOneAccount = accounts.length > 1;
|
||||||
|
|
||||||
const [_, reload] = useReducer((x) => x + 1, 0);
|
const [_, reload] = useReducer((x) => x + 1, 0);
|
||||||
|
@ -81,7 +82,7 @@ function Accounts({ onClose }) {
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
states.showAccount = `${account.info.username}@${account.instanceURL}`;
|
states.showAccount = `${account.info.username}@${account.instanceURL}`;
|
||||||
} else {
|
} else {
|
||||||
store.session.set('currentAccount', account.info.id);
|
setCurrentAccountID(account.info.id);
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -111,7 +111,7 @@
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-align: left;
|
text-align: start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
@ -146,6 +146,9 @@
|
||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
accent-color: var(--link-color);
|
accent-color: var(--link-color);
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
|
&:dir(rtl) {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -251,7 +254,7 @@
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
to right,
|
var(--to-forward),
|
||||||
transparent,
|
transparent,
|
||||||
black 16px calc(100% - 16px),
|
black 16px calc(100% - 16px),
|
||||||
transparent
|
transparent
|
||||||
|
@ -315,7 +318,7 @@
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
font-size: 70%;
|
font-size: 70%;
|
||||||
margin-left: 4px;
|
margin-inline-start: 4px;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
@ -386,7 +389,7 @@
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -4px;
|
inset-inline-end: -4px;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
|
@ -406,7 +409,7 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
to right,
|
var(--to-forward),
|
||||||
black calc(100% - 0.5em),
|
black calc(100% - 0.5em),
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
|
@ -478,13 +481,13 @@
|
||||||
|
|
||||||
> li {
|
> li {
|
||||||
&:first-child > a {
|
&:first-child > a {
|
||||||
border-top-left-radius: var(--corner-radius);
|
border-start-start-radius: var(--corner-radius);
|
||||||
border-top-right-radius: var(--corner-radius);
|
border-start-end-radius: var(--corner-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child > a {
|
&:last-child > a {
|
||||||
border-bottom-left-radius: var(--corner-radius);
|
border-end-start-radius: var(--corner-radius);
|
||||||
border-bottom-right-radius: var(--corner-radius);
|
border-end-end-radius: var(--corner-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -502,13 +505,13 @@
|
||||||
|
|
||||||
@media (min-width: 40em) {
|
@media (min-width: 40em) {
|
||||||
&.separator + li a {
|
&.separator + li a {
|
||||||
border-top-left-radius: var(--corner-radius);
|
border-start-start-radius: var(--corner-radius);
|
||||||
border-top-right-radius: var(--corner-radius);
|
border-start-end-radius: var(--corner-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:has(+ .separator) a {
|
&:has(+ .separator) a {
|
||||||
border-bottom-left-radius: var(--corner-radius);
|
border-end-start-radius: var(--corner-radius);
|
||||||
border-bottom-right-radius: var(--corner-radius);
|
border-end-end-radius: var(--corner-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -525,10 +528,13 @@
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
box-shadow: 0 8px 16px -8px var(--drop-shadow-color),
|
box-shadow: 0 8px 16px -8px var(--drop-shadow-color),
|
||||||
inset 0 1px var(--bg-color);
|
inset 0 1px var(--bg-color);
|
||||||
outline: 1px solid var(--outline-color);
|
|
||||||
text-shadow: 0 1px var(--bg-color);
|
text-shadow: 0 1px var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover:not(:focus-visible) {
|
||||||
|
outline: 1px solid var(--outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
filter: brightness(0.95);
|
filter: brightness(0.95);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
@ -569,8 +575,12 @@
|
||||||
'author meta'
|
'author meta'
|
||||||
'content content';
|
'content content';
|
||||||
/* align-items: center; */
|
/* align-items: center; */
|
||||||
|
--bg-gradient-angle: 140deg;
|
||||||
|
&:dir(rtl) {
|
||||||
|
--bg-gradient-angle: -140deg;
|
||||||
|
}
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
140deg,
|
var(--bg-gradient-angle),
|
||||||
var(--post-bg-color),
|
var(--post-bg-color),
|
||||||
transparent min(160px, 50%)
|
transparent min(160px, 50%)
|
||||||
);
|
);
|
||||||
|
@ -611,7 +621,7 @@
|
||||||
}
|
}
|
||||||
&.visibility-direct {
|
&.visibility-direct {
|
||||||
--yellow-stripes: repeating-linear-gradient(
|
--yellow-stripes: repeating-linear-gradient(
|
||||||
-45deg,
|
135deg,
|
||||||
var(--reply-to-faded-color),
|
var(--reply-to-faded-color),
|
||||||
var(--reply-to-faded-color) 10px,
|
var(--reply-to-faded-color) 10px,
|
||||||
var(--reply-to-faded-color) 10px,
|
var(--reply-to-faded-color) 10px,
|
||||||
|
@ -626,10 +636,24 @@
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-height: 24px;
|
||||||
|
|
||||||
.icon {
|
> .avatar {
|
||||||
|
outline: 1px solid var(--bg-blur-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .avatar ~ .avatar {
|
||||||
|
margin-inline-start: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .icon {
|
||||||
color: var(--reblog-color);
|
color: var(--reblog-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> .name-text {
|
||||||
|
opacity: 0.75;
|
||||||
|
filter: grayscale(0.75);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-author {
|
.post-author {
|
||||||
|
@ -638,7 +662,7 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
to right,
|
var(--to-forward),
|
||||||
black calc(100% - 1em),
|
black calc(100% - 1em),
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
|
@ -796,6 +820,10 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-decoration-color: transparent;
|
text-decoration-color: transparent;
|
||||||
color: var(--link-text-color);
|
color: var(--link-text-color);
|
||||||
|
|
||||||
|
span {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -854,6 +882,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
animation: position-object 5s ease-in-out 5;
|
animation: position-object 5s ease-in-out 5;
|
||||||
|
animation-duration: var(--anim-duration, 5s);
|
||||||
|
|
||||||
/* @media (min-width: 40em) and (min-height: 600px) {
|
/* @media (min-width: 40em) and (min-height: 600px) {
|
||||||
transform: scale(3);
|
transform: scale(3);
|
||||||
|
@ -865,12 +894,15 @@
|
||||||
&:has(.post-peek-media),
|
&:has(.post-peek-media),
|
||||||
.post-peek-media:first-child img {
|
.post-peek-media:first-child img {
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
|
:dir(rtl) & {
|
||||||
|
transform-origin: right center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.post-peek-media:not(:last-child) {
|
.post-peek-media:not(:last-child) {
|
||||||
margin-right: -24px;
|
margin-inline-end: -24px;
|
||||||
box-shadow: 0 0 0 2px var(--bg-blur-color);
|
box-shadow: 0 0 0 2px var(--bg-blur-color);
|
||||||
}
|
}
|
||||||
/* Max 10, I'm not going to code more than this */
|
/* Max 10, I'm not going to code more than this */
|
||||||
|
@ -1077,6 +1109,20 @@
|
||||||
dd {
|
dd {
|
||||||
margin-block-end: 1em;
|
margin-block-end: 1em;
|
||||||
margin-inline: 1em;
|
margin-inline: 1em;
|
||||||
|
|
||||||
|
+ dd {
|
||||||
|
margin-block-start: -0.9em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode/';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { uid } from 'uid/single';
|
import { uid } from 'uid/single';
|
||||||
|
@ -32,14 +33,15 @@ import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
|
||||||
import db from '../utils/db';
|
import db from '../utils/db';
|
||||||
import emojifyText from '../utils/emojify-text';
|
import emojifyText from '../utils/emojify-text';
|
||||||
import { isFiltered } from '../utils/filters';
|
import { isFiltered } from '../utils/filters';
|
||||||
import getHTMLText from '../utils/getHTMLText';
|
|
||||||
import htmlContentLength from '../utils/html-content-length';
|
import htmlContentLength from '../utils/html-content-length';
|
||||||
import niceDateTime from '../utils/nice-date-time';
|
import niceDateTime from '../utils/nice-date-time';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states, { statusKey } from '../utils/states';
|
import states, { statusKey } from '../utils/states';
|
||||||
|
import statusPeek from '../utils/status-peek';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
import { getCurrentAccountNS } from '../utils/store-utils';
|
import { getCurrentAccountID, getCurrentAccountNS } from '../utils/store-utils';
|
||||||
|
import supports from '../utils/supports';
|
||||||
import { assignFollowedTags } from '../utils/timeline-utils';
|
import { assignFollowedTags } from '../utils/timeline-utils';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
@ -111,10 +113,12 @@ function Catchup() {
|
||||||
const [showTopLinks, setShowTopLinks] = useState(false);
|
const [showTopLinks, setShowTopLinks] = useState(false);
|
||||||
|
|
||||||
const currentAccount = useMemo(() => {
|
const currentAccount = useMemo(() => {
|
||||||
return store.session.get('currentAccount');
|
return getCurrentAccountID();
|
||||||
}, []);
|
}, []);
|
||||||
const isSelf = (accountID) => accountID === currentAccount;
|
const isSelf = (accountID) => accountID === currentAccount;
|
||||||
|
|
||||||
|
const supportsPixelfed = supports('@pixelfed/home-include-reblogs');
|
||||||
|
|
||||||
async function fetchHome({ maxCreatedAt }) {
|
async function fetchHome({ maxCreatedAt }) {
|
||||||
const maxCreatedAtDate = maxCreatedAt ? new Date(maxCreatedAt) : null;
|
const maxCreatedAtDate = maxCreatedAt ? new Date(maxCreatedAt) : null;
|
||||||
console.debug('fetchHome', maxCreatedAtDate);
|
console.debug('fetchHome', maxCreatedAtDate);
|
||||||
|
@ -122,6 +126,13 @@ function Catchup() {
|
||||||
const homeIterator = masto.v1.timelines.home.list({ limit: 40 });
|
const homeIterator = masto.v1.timelines.home.list({ limit: 40 });
|
||||||
mainloop: while (true) {
|
mainloop: while (true) {
|
||||||
try {
|
try {
|
||||||
|
if (supportsPixelfed && homeIterator.nextParams) {
|
||||||
|
if (typeof homeIterator.nextParams === 'string') {
|
||||||
|
homeIterator.nextParams += '&include_reblogs=true';
|
||||||
|
} else {
|
||||||
|
homeIterator.nextParams.include_reblogs = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const results = await homeIterator.next();
|
const results = await homeIterator.next();
|
||||||
const { value } = results;
|
const { value } = results;
|
||||||
if (value?.length) {
|
if (value?.length) {
|
||||||
|
@ -191,6 +202,7 @@ function Catchup() {
|
||||||
|
|
||||||
const [posts, setPosts] = useState([]);
|
const [posts, setPosts] = useState([]);
|
||||||
const catchupRangeRef = useRef();
|
const catchupRangeRef = useRef();
|
||||||
|
const catchupLastRef = useRef();
|
||||||
const NS = useMemo(() => getCurrentAccountNS(), []);
|
const NS = useMemo(() => getCurrentAccountNS(), []);
|
||||||
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
|
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
@ -429,9 +441,28 @@ function Catchup() {
|
||||||
return postFilterMatches;
|
return postFilterMatches;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deduplicate boosts
|
||||||
|
const boostedPosts = {};
|
||||||
|
filteredPosts.forEach((post) => {
|
||||||
|
if (post.reblog) {
|
||||||
|
if (boostedPosts[post.reblog.id]) {
|
||||||
|
if (boostedPosts[post.reblog.id].__BOOSTERS) {
|
||||||
|
boostedPosts[post.reblog.id].__BOOSTERS.add(post.account);
|
||||||
|
} else {
|
||||||
|
boostedPosts[post.reblog.id].__BOOSTERS = new Set([post.account]);
|
||||||
|
}
|
||||||
|
post.__HIDDEN = true;
|
||||||
|
} else {
|
||||||
|
boostedPosts[post.reblog.id] = post;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
|
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
|
||||||
filteredPosts = filteredPosts.filter(
|
filteredPosts = filteredPosts.filter(
|
||||||
(post) => post.account.id === selectedAuthor,
|
(post) =>
|
||||||
|
post.account.id === selectedAuthor ||
|
||||||
|
[...(post.__BOOSTERS || [])].find((a) => a.id === selectedAuthor),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -459,39 +490,41 @@ function Catchup() {
|
||||||
authorCountsList.forEach((authorID, index) => {
|
authorCountsList.forEach((authorID, index) => {
|
||||||
authorIndices[authorID] = index;
|
authorIndices[authorID] = index;
|
||||||
});
|
});
|
||||||
return filteredPosts.sort((a, b) => {
|
return filteredPosts
|
||||||
if (groupBy === 'account') {
|
.filter((post) => !post.__HIDDEN)
|
||||||
const aAccountID = a.account.id;
|
.sort((a, b) => {
|
||||||
const bAccountID = b.account.id;
|
if (groupBy === 'account') {
|
||||||
const aIndex = authorIndices[aAccountID];
|
const aAccountID = a.account.id;
|
||||||
const bIndex = authorIndices[bAccountID];
|
const bAccountID = b.account.id;
|
||||||
const order = aIndex - bIndex;
|
const aIndex = authorIndices[aAccountID];
|
||||||
if (order !== 0) {
|
const bIndex = authorIndices[bAccountID];
|
||||||
return order;
|
const order = aIndex - bIndex;
|
||||||
|
if (order !== 0) {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (sortBy !== 'createdAt') {
|
||||||
if (sortBy !== 'createdAt') {
|
a = a.reblog || a;
|
||||||
a = a.reblog || a;
|
b = b.reblog || b;
|
||||||
b = b.reblog || b;
|
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
|
||||||
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
|
return a.createdAt > b.createdAt ? 1 : -1;
|
||||||
return a.createdAt > b.createdAt ? 1 : -1;
|
}
|
||||||
|
}
|
||||||
|
if (sortBy === 'density') {
|
||||||
|
const aDensity = postDensity(a);
|
||||||
|
const bDensity = postDensity(b);
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
return aDensity > bDensity ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return bDensity > aDensity ? 1 : -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (sortBy === 'density') {
|
|
||||||
const aDensity = postDensity(a);
|
|
||||||
const bDensity = postDensity(b);
|
|
||||||
if (sortOrder === 'asc') {
|
if (sortOrder === 'asc') {
|
||||||
return aDensity > bDensity ? 1 : -1;
|
return a[sortBy] > b[sortBy] ? 1 : -1;
|
||||||
} else {
|
} else {
|
||||||
return bDensity > aDensity ? 1 : -1;
|
return b[sortBy] > a[sortBy] ? 1 : -1;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
if (sortOrder === 'asc') {
|
|
||||||
return a[sortBy] > b[sortBy] ? 1 : -1;
|
|
||||||
} else {
|
|
||||||
return b[sortBy] > a[sortBy] ? 1 : -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]);
|
}, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]);
|
||||||
|
|
||||||
const prevGroup = useRef(null);
|
const prevGroup = useRef(null);
|
||||||
|
@ -589,41 +622,46 @@ function Catchup() {
|
||||||
authors,
|
authors,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const prevSelectedAuthorMissing = useRef(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// console.log({
|
|
||||||
// prevSelectedAuthorMissing,
|
|
||||||
// selectedAuthor,
|
|
||||||
// authors,
|
|
||||||
// });
|
|
||||||
let timer;
|
|
||||||
if (selectedAuthor) {
|
if (selectedAuthor) {
|
||||||
if (authors[selectedAuthor]) {
|
if (authors[selectedAuthor]) {
|
||||||
if (prevSelectedAuthorMissing.current) {
|
// Check if author is visible and within the scrollable area viewport
|
||||||
timer = setTimeout(() => {
|
const authorElement = authorsListParent.current.querySelector(
|
||||||
authorsListParent.current
|
`[data-author="${selectedAuthor}"]`,
|
||||||
.querySelector(`[data-author="${selectedAuthor}"]`)
|
);
|
||||||
?.scrollIntoView({
|
const scrollableRect =
|
||||||
behavior: 'smooth',
|
authorsListParent.current?.getBoundingClientRect();
|
||||||
block: 'nearest',
|
const authorRect = authorElement?.getBoundingClientRect();
|
||||||
inline: 'center',
|
console.log({
|
||||||
});
|
sLeft: scrollableRect.left,
|
||||||
}, 500);
|
sRight: scrollableRect.right,
|
||||||
prevSelectedAuthorMissing.current = false;
|
aLeft: authorRect.left,
|
||||||
|
aRight: authorRect.right,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
authorRect.left < scrollableRect.left ||
|
||||||
|
authorRect.right > scrollableRect.right
|
||||||
|
) {
|
||||||
|
authorElement.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
} else if (authorRect.top < 0) {
|
||||||
|
authorElement.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'nearest',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
prevSelectedAuthorMissing.current = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [selectedAuthor, authors]);
|
}, [selectedAuthor, authors]);
|
||||||
|
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const itemsSelector = '.catchup-list > li > a';
|
const itemsSelector = '.catchup-list > li > a';
|
||||||
useHotkeys(
|
const jRef = useHotkeys(
|
||||||
'j',
|
'j',
|
||||||
() => {
|
() => {
|
||||||
const activeItem = document.activeElement.closest(itemsSelector);
|
const activeItem = document.activeElement.closest(itemsSelector);
|
||||||
|
@ -663,12 +701,121 @@ function Catchup() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
preventDefault: true,
|
preventDefault: true,
|
||||||
|
ignoreModifiers: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const kRef = useHotkeys(
|
||||||
|
'k',
|
||||||
|
() => {
|
||||||
|
const activeItem = document.activeElement.closest(itemsSelector);
|
||||||
|
const activeItemRect = activeItem?.getBoundingClientRect();
|
||||||
|
const allItems = Array.from(
|
||||||
|
scrollableRef.current.querySelectorAll(itemsSelector),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
activeItem &&
|
||||||
|
activeItemRect.top < scrollableRef.current.clientHeight &&
|
||||||
|
activeItemRect.bottom > 0
|
||||||
|
) {
|
||||||
|
const activeItemIndex = allItems.indexOf(activeItem);
|
||||||
|
let prevItem = allItems[activeItemIndex - 1];
|
||||||
|
if (prevItem) {
|
||||||
|
prevItem.focus();
|
||||||
|
prevItem.scrollIntoView({
|
||||||
|
block: 'center',
|
||||||
|
inline: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const topmostItem = allItems.find((item) => {
|
||||||
|
const itemRect = item.getBoundingClientRect();
|
||||||
|
return itemRect.top >= 44 && itemRect.left >= 0;
|
||||||
|
});
|
||||||
|
if (topmostItem) {
|
||||||
|
topmostItem.focus();
|
||||||
|
topmostItem.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preventDefault: true,
|
||||||
|
ignoreModifiers: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const hlRef = useHotkeys(
|
||||||
|
'h, l',
|
||||||
|
(_, handler) => {
|
||||||
|
// Go next/prev selectedAuthor in authorCountsList list
|
||||||
|
const key = handler.keys[0];
|
||||||
|
if (selectedAuthor) {
|
||||||
|
const index = authorCountsList.indexOf(selectedAuthor);
|
||||||
|
if (key === 'h') {
|
||||||
|
if (index > 0 && index < authorCountsList.length) {
|
||||||
|
setSelectedAuthor(authorCountsList[index - 1]);
|
||||||
|
scrollableRef.current?.focus();
|
||||||
|
}
|
||||||
|
} else if (key === 'l') {
|
||||||
|
if (index < authorCountsList.length - 1 && index >= 0) {
|
||||||
|
setSelectedAuthor(authorCountsList[index + 1]);
|
||||||
|
scrollableRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (key === 'l') {
|
||||||
|
setSelectedAuthor(authorCountsList[0]);
|
||||||
|
scrollableRef.current?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preventDefault: true,
|
||||||
|
ignoreModifiers: true,
|
||||||
|
enableOnFormTags: ['input'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const escRef = useHotkeys(
|
||||||
|
'esc',
|
||||||
|
() => {
|
||||||
|
setSelectedAuthor(null);
|
||||||
|
scrollableRef.current?.focus();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preventDefault: true,
|
||||||
|
ignoreModifiers: true,
|
||||||
|
enableOnFormTags: ['input'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const dotRef = useHotkeys(
|
||||||
|
'.',
|
||||||
|
() => {
|
||||||
|
scrollableRef.current?.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preventDefault: true,
|
||||||
|
ignoreModifiers: true,
|
||||||
|
enableOnFormTags: ['input'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scrollableRef}
|
ref={(node) => {
|
||||||
|
scrollableRef.current = node;
|
||||||
|
jRef.current = node;
|
||||||
|
kRef.current = node;
|
||||||
|
hlRef.current = node;
|
||||||
|
escRef.current = node;
|
||||||
|
}}
|
||||||
id="catchup-page"
|
id="catchup-page"
|
||||||
class="deck-container"
|
class="deck-container"
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
|
@ -790,7 +937,15 @@ function Catchup() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (range < RANGES[RANGES.length - 1].value) {
|
if (range < RANGES[RANGES.length - 1].value) {
|
||||||
const duration = range * 60 * 60 * 1000;
|
let duration;
|
||||||
|
if (
|
||||||
|
range === RANGES[RANGES.length - 1].value &&
|
||||||
|
catchupLastRef.current?.checked
|
||||||
|
) {
|
||||||
|
duration = Date.now() - lastCatchupEndAt;
|
||||||
|
} else {
|
||||||
|
duration = range * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
handleCatchupClick({ duration });
|
handleCatchupClick({ duration });
|
||||||
} else {
|
} else {
|
||||||
handleCatchupClick();
|
handleCatchupClick();
|
||||||
|
@ -800,11 +955,25 @@ function Catchup() {
|
||||||
Catch up
|
Catch up
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{lastCatchupRange && range > lastCatchupRange && (
|
{lastCatchupRange && range > lastCatchupRange ? (
|
||||||
<p class="catchup-info">
|
<p class="catchup-info">
|
||||||
<Icon icon="info" /> Overlaps with your last catch-up
|
<Icon icon="info" /> Overlaps with your last catch-up
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : range === RANGES[RANGES.length - 1].value &&
|
||||||
|
lastCatchupEndAt ? (
|
||||||
|
<p class="catchup-info">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
switch
|
||||||
|
checked
|
||||||
|
ref={catchupLastRef}
|
||||||
|
/>{' '}
|
||||||
|
Until the last catch-up (
|
||||||
|
{dtf.format(new Date(lastCatchupEndAt))})
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p class="insignificant">
|
<p class="insignificant">
|
||||||
<small>
|
<small>
|
||||||
Note: your instance might only show a maximum of 800 posts in
|
Note: your instance might only show a maximum of 800 posts in
|
||||||
|
@ -821,10 +990,12 @@ function Catchup() {
|
||||||
<Link to={`/catchup?id=${pc.id}`}>
|
<Link to={`/catchup?id=${pc.id}`}>
|
||||||
<Icon icon="history2" />{' '}
|
<Icon icon="history2" />{' '}
|
||||||
<span>
|
<span>
|
||||||
{formatRange(
|
{pc.startAt
|
||||||
new Date(pc.startAt),
|
? dtf.formatRange(
|
||||||
new Date(pc.endAt),
|
new Date(pc.startAt),
|
||||||
)}
|
new Date(pc.endAt),
|
||||||
|
)
|
||||||
|
: `… – ${dtf.format(new Date(pc.endAt))}`}
|
||||||
</span>
|
</span>
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
<span>
|
<span>
|
||||||
|
@ -876,7 +1047,7 @@ function Catchup() {
|
||||||
{posts.length > 0 && (
|
{posts.length > 0 && (
|
||||||
<p>
|
<p>
|
||||||
<b class="ib">
|
<b class="ib">
|
||||||
{formatRange(
|
{dtf.formatRange(
|
||||||
new Date(posts[0].createdAt),
|
new Date(posts[0].createdAt),
|
||||||
new Date(posts[posts.length - 1].createdAt),
|
new Date(posts[posts.length - 1].createdAt),
|
||||||
)}
|
)}
|
||||||
|
@ -939,9 +1110,11 @@ function Catchup() {
|
||||||
height,
|
height,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
} = card;
|
} = card;
|
||||||
const domain = new URL(url).hostname
|
const domain = punycode.toUnicode(
|
||||||
.replace(/^www\./, '')
|
URL.parse(url)
|
||||||
.replace(/\/$/, '');
|
.hostname.replace(/^www\./, '')
|
||||||
|
.replace(/\/$/, ''),
|
||||||
|
);
|
||||||
let accentColor;
|
let accentColor;
|
||||||
if (blurhash) {
|
if (blurhash) {
|
||||||
const averageColor = getBlurHashAverageColor(blurhash);
|
const averageColor = getBlurHashAverageColor(blurhash);
|
||||||
|
@ -997,7 +1170,12 @@ function Catchup() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!!title && (
|
{!!title && (
|
||||||
<h1 class="title" lang={language} dir="auto">
|
<h1
|
||||||
|
class="title"
|
||||||
|
lang={language}
|
||||||
|
dir="auto"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
)}
|
||||||
|
@ -1007,6 +1185,7 @@ function Catchup() {
|
||||||
class="description"
|
class="description"
|
||||||
lang={language}
|
lang={language}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
|
title={description}
|
||||||
>
|
>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
@ -1078,6 +1257,10 @@ function Catchup() {
|
||||||
}
|
}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
setSelectedFilterCategory(label);
|
setSelectedFilterCategory(label);
|
||||||
|
if (label === 'Boosts') {
|
||||||
|
setSortBy('reblogsCount');
|
||||||
|
setGroupBy(null);
|
||||||
|
}
|
||||||
// setSelectedAuthor(null);
|
// setSelectedAuthor(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1120,7 +1303,7 @@ function Catchup() {
|
||||||
authors[author].avatarStatic || authors[author].avatar
|
authors[author].avatarStatic || authors[author].avatar
|
||||||
}
|
}
|
||||||
size="xxl"
|
size="xxl"
|
||||||
alt={`${authors[author].displayName} (@${authors[author].username})`}
|
alt={`${authors[author].displayName} (@${authors[author].acct})`}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
<span class="count">{authorCounts[author]}</span>
|
<span class="count">{authorCounts[author]}</span>
|
||||||
<span class="username">{authors[author].username}</span>
|
<span class="username">{authors[author].username}</span>
|
||||||
|
@ -1330,6 +1513,25 @@ function Catchup() {
|
||||||
Posts are grouped by authors, sorted by posts count per
|
Posts are grouped by authors, sorted by posts count per
|
||||||
author.
|
author.
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt>Keyboard shortcuts</dt>
|
||||||
|
<dd>
|
||||||
|
<kbd>j</kbd>: Next post
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<kbd>k</kbd>: Previous post
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<kbd>l</kbd>: Next author
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<kbd>h</kbd>: Previous author
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<kbd>Enter</kbd>: Open post details
|
||||||
|
</dd>
|
||||||
|
<dd>
|
||||||
|
<kbd>.</kbd>: Scroll to top
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1351,6 +1553,7 @@ const PostLine = memo(
|
||||||
_followedTags: isFollowedTags,
|
_followedTags: isFollowedTags,
|
||||||
_filtered: filterInfo,
|
_filtered: filterInfo,
|
||||||
visibility,
|
visibility,
|
||||||
|
__BOOSTERS,
|
||||||
} = post;
|
} = post;
|
||||||
const isReplyTo = inReplyToId && inReplyToAccountId !== account.id;
|
const isReplyTo = inReplyToId && inReplyToAccountId !== account.id;
|
||||||
const isFiltered = !!filterInfo;
|
const isFiltered = !!filterInfo;
|
||||||
|
@ -1384,7 +1587,12 @@ const PostLine = memo(
|
||||||
<Avatar
|
<Avatar
|
||||||
url={account.avatarStatic || account.avatar}
|
url={account.avatarStatic || account.avatar}
|
||||||
squircle={account.bot}
|
squircle={account.bot}
|
||||||
/>{' '}
|
/>
|
||||||
|
{__BOOSTERS?.size > 0
|
||||||
|
? [...__BOOSTERS].map((b) => (
|
||||||
|
<Avatar url={b.avatarStatic || b.avatar} squircle={b.bot} />
|
||||||
|
))
|
||||||
|
: ''}{' '}
|
||||||
<Icon icon="rocket" />{' '}
|
<Icon icon="rocket" />{' '}
|
||||||
{/* <Avatar
|
{/* <Avatar
|
||||||
url={reblog.account.avatarStatic || reblog.account.avatar}
|
url={reblog.account.avatarStatic || reblog.account.avatar}
|
||||||
|
@ -1483,55 +1691,70 @@ function PostPeek({ post, filterInfo }) {
|
||||||
} = post;
|
} = post;
|
||||||
const isThread =
|
const isThread =
|
||||||
(inReplyToId && inReplyToAccountId === account.id) || !!_thread;
|
(inReplyToId && inReplyToAccountId === account.id) || !!_thread;
|
||||||
const showMedia = !spoilerText && !sensitive;
|
|
||||||
const postText = content ? getHTMLText(content) : '';
|
const readingExpandSpoilers = useMemo(() => {
|
||||||
|
const prefs = store.account.get('preferences') || {};
|
||||||
|
return !!prefs['reading:expand:spoilers'];
|
||||||
|
}, []);
|
||||||
|
// const readingExpandSpoilers = true;
|
||||||
|
const showMedia = readingExpandSpoilers || (!spoilerText && !sensitive);
|
||||||
|
const postText = content ? statusPeek(post) : '';
|
||||||
|
|
||||||
|
const showPostContent = !spoilerText || readingExpandSpoilers;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="post-peek" title={!spoilerText ? postText : ''}>
|
<div class="post-peek" title={!spoilerText ? postText : ''}>
|
||||||
<span class="post-peek-content">
|
<span class="post-peek-content">
|
||||||
|
{isThread && !showPostContent && (
|
||||||
|
<>
|
||||||
|
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!!filterInfo ? (
|
{!!filterInfo ? (
|
||||||
<>
|
<span class="post-peek-filtered">
|
||||||
{isThread && (
|
Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''}
|
||||||
<>
|
</span>
|
||||||
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span class="post-peek-filtered">
|
|
||||||
Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : !!spoilerText ? (
|
|
||||||
<>
|
|
||||||
{isThread && (
|
|
||||||
<>
|
|
||||||
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span class="post-peek-spoiler">
|
|
||||||
<Icon icon="eye-close" /> {spoilerText}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div class="post-peek-html">
|
<>
|
||||||
{isThread && (
|
{!!spoilerText && (
|
||||||
<>
|
<span class="post-peek-spoiler">
|
||||||
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
|
<Icon
|
||||||
</>
|
icon={`${readingExpandSpoilers ? 'eye-open' : 'eye-close'}`}
|
||||||
|
/>{' '}
|
||||||
|
{spoilerText}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{content ? (
|
{showPostContent && (
|
||||||
<div
|
<div class="post-peek-html">
|
||||||
dangerouslySetInnerHTML={{
|
{isThread && (
|
||||||
__html: emojifyText(content, emojis),
|
<>
|
||||||
}}
|
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
|
||||||
/>
|
</>
|
||||||
) : mediaAttachments?.length === 1 &&
|
)}
|
||||||
mediaAttachments[0].description ? (
|
{!!content && (
|
||||||
<>
|
<div
|
||||||
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
|
dangerouslySetInnerHTML={{
|
||||||
<div>{mediaAttachments[0].description}</div>
|
__html: emojifyText(content, emojis),
|
||||||
</>
|
}}
|
||||||
) : null}
|
/>
|
||||||
</div>
|
)}
|
||||||
|
{!!poll?.options?.length &&
|
||||||
|
poll.options.map((o) => (
|
||||||
|
<div>
|
||||||
|
{poll.multiple ? '▪️' : '•'} {o.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!content &&
|
||||||
|
mediaAttachments?.length === 1 &&
|
||||||
|
mediaAttachments[0].description && (
|
||||||
|
<>
|
||||||
|
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
|
||||||
|
<div>{mediaAttachments[0].description}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!filterInfo && (
|
{!filterInfo && (
|
||||||
|
@ -1546,6 +1769,12 @@ function PostPeek({ post, filterInfo }) {
|
||||||
? mediaAttachments.map((m) => {
|
? mediaAttachments.map((m) => {
|
||||||
const mediaURL = m.previewUrl || m.url;
|
const mediaURL = m.previewUrl || m.url;
|
||||||
const remoteMediaURL = m.previewRemoteUrl || m.remoteUrl;
|
const remoteMediaURL = m.previewRemoteUrl || m.remoteUrl;
|
||||||
|
const width = m.meta?.original
|
||||||
|
? m.meta.original.width
|
||||||
|
: m.meta?.small?.width || m.meta?.original?.width;
|
||||||
|
const height = m.meta?.original
|
||||||
|
? m.meta.original.height
|
||||||
|
: m.meta?.small?.height || m.meta?.original?.height;
|
||||||
return (
|
return (
|
||||||
<span key={m.id} class="post-peek-media">
|
<span key={m.id} class="post-peek-media">
|
||||||
{{
|
{{
|
||||||
|
@ -1563,6 +1792,12 @@ function PostPeek({ post, filterInfo }) {
|
||||||
e.target.src = remoteMediaURL;
|
e.target.src = remoteMediaURL;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
'--anim-duration': `${Math.min(
|
||||||
|
Math.max(Math.max(width, height) / 100, 5),
|
||||||
|
120,
|
||||||
|
)}s`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span class="post-peek-faux-media">🖼</span>
|
<span class="post-peek-faux-media">🖼</span>
|
||||||
|
@ -1625,6 +1860,18 @@ function PostPeek({ post, filterInfo }) {
|
||||||
card.title || card.description || card.imageDescription
|
card.title || card.description || card.imageDescription
|
||||||
}
|
}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
style={{
|
||||||
|
'--anim-duration':
|
||||||
|
card.width &&
|
||||||
|
card.height &&
|
||||||
|
`${Math.min(
|
||||||
|
Math.max(
|
||||||
|
Math.max(card.width, card.height) / 100,
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
120,
|
||||||
|
)}s`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span class="post-peek-faux-media">🔗</span>
|
<span class="post-peek-faux-media">🔗</span>
|
||||||
|
@ -1668,9 +1915,6 @@ const dtf = new Intl.DateTimeFormat(locale, {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
});
|
});
|
||||||
function formatRange(startDate, endDate) {
|
|
||||||
return dtf.formatRange(startDate, endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function binByTime(data, key, numBins) {
|
function binByTime(data, key, numBins) {
|
||||||
// Extract dates from data objects
|
// Extract dates from data objects
|
||||||
|
|
149
src/pages/filters.css
Normal file
149
src/pages/filters.css
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
#filters-page {
|
||||||
|
.filters-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#filters-add-edit-modal {
|
||||||
|
.filter-form-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
+ .filter-form-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid var(--outline-color);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding-top: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-block: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-keywords {
|
||||||
|
margin: 0 -16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-cols {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.filter-form-col {
|
||||||
|
flex-basis: 160px;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
> *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
> *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keywords {
|
||||||
|
--gap: 16px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
padding: var(--gap);
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 25vh;
|
||||||
|
background-color: var(--bg-faded-blur-color);
|
||||||
|
counter-reset: index;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
|
||||||
|
li {
|
||||||
|
counter-increment: index;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&:not(:only-child):before {
|
||||||
|
content: counter(index);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
flex-basis: 160px;
|
||||||
|
flex-grow: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keyword-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-keywords-footer {
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type='submit'] {
|
||||||
|
padding-inline: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
588
src/pages/filters.jsx
Normal file
588
src/pages/filters.jsx
Normal file
|
@ -0,0 +1,588 @@
|
||||||
|
import './filters.css';
|
||||||
|
|
||||||
|
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
import Link from '../components/link';
|
||||||
|
import Loader from '../components/loader';
|
||||||
|
import MenuConfirm from '../components/menu-confirm';
|
||||||
|
import Modal from '../components/modal';
|
||||||
|
import NavMenu from '../components/nav-menu';
|
||||||
|
import RelativeTime from '../components/relative-time';
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
import useInterval from '../utils/useInterval';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
|
||||||
|
const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
|
||||||
|
const FILTER_CONTEXT_LABELS = {
|
||||||
|
home: 'Home and lists',
|
||||||
|
notifications: 'Notifications',
|
||||||
|
public: 'Public timelines',
|
||||||
|
thread: 'Conversations',
|
||||||
|
account: 'Profiles',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPIRY_DURATIONS = [
|
||||||
|
0, // forever
|
||||||
|
30 * 60, // 30 minutes
|
||||||
|
60 * 60, // 1 hour
|
||||||
|
6 * 60 * 60, // 6 hours
|
||||||
|
12 * 60 * 60, // 12 hours
|
||||||
|
60 * 60 * 24, // 24 hours
|
||||||
|
60 * 60 * 24 * 7, // 7 days
|
||||||
|
60 * 60 * 24 * 30, // 30 days
|
||||||
|
];
|
||||||
|
const EXPIRY_DURATIONS_LABELS = {
|
||||||
|
0: 'Never',
|
||||||
|
1800: '30 minutes',
|
||||||
|
3600: '1 hour',
|
||||||
|
21600: '6 hours',
|
||||||
|
43200: '12 hours',
|
||||||
|
86_400: '24 hours',
|
||||||
|
604_800: '7 days',
|
||||||
|
2_592_000: '30 days',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Filters() {
|
||||||
|
const { masto } = api();
|
||||||
|
useTitle(`Filters`, `/ft`);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
|
||||||
|
|
||||||
|
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
|
||||||
|
const [filters, setFilters] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const filters = await masto.v2.filters.list();
|
||||||
|
filters.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
filters.forEach((filter) => {
|
||||||
|
if (filter.keywords?.length) {
|
||||||
|
filter.keywords.sort((a, b) => a.id - b.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(filters);
|
||||||
|
setFilters(filters);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [reloadCount]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="filters-page" class="deck-container" tabIndex="-1">
|
||||||
|
<div class="timeline-deck deck">
|
||||||
|
<header>
|
||||||
|
<div class="header-grid">
|
||||||
|
<div class="header-side">
|
||||||
|
<NavMenu />
|
||||||
|
<Link to="/" class="button plain">
|
||||||
|
<Icon icon="home" size="l" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1>Filters</h1>
|
||||||
|
<div class="header-side">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain"
|
||||||
|
onClick={() => {
|
||||||
|
setShowFiltersAddEditModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="plus" size="l" alt="New filter" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{filters.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ul class="filters-list">
|
||||||
|
{filters.map((filter) => {
|
||||||
|
const { id, title, expiresAt, keywords } = filter;
|
||||||
|
return (
|
||||||
|
<li key={id}>
|
||||||
|
<div>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{keywords?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{keywords.map((k) => (
|
||||||
|
<>
|
||||||
|
<span class="tag collapsed insignificant">
|
||||||
|
{k.wholeWord ? `“${k.keyword}”` : k.keyword}
|
||||||
|
</span>{' '}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small class="insignificant">
|
||||||
|
<ExpiryStatus expiresAt={expiresAt} />
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain"
|
||||||
|
onClick={() => {
|
||||||
|
setShowFiltersAddEditModal({
|
||||||
|
filter,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="pencil" size="l" alt="Edit filter" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{filters.length > 1 && (
|
||||||
|
<footer class="ui-state">
|
||||||
|
<small class="insignificant">
|
||||||
|
{filters.length} filter
|
||||||
|
{filters.length === 1 ? '' : 's'}
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : uiState === 'loading' ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
<Loader />
|
||||||
|
</p>
|
||||||
|
) : uiState === 'error' ? (
|
||||||
|
<p class="ui-state">Unable to load filters.</p>
|
||||||
|
) : (
|
||||||
|
<p class="ui-state">No filters yet.</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{!!showFiltersAddEditModal && (
|
||||||
|
<Modal
|
||||||
|
title="Add filter"
|
||||||
|
onClose={() => {
|
||||||
|
setShowFiltersAddEditModal(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FiltersAddEdit
|
||||||
|
filter={showFiltersAddEditModal?.filter}
|
||||||
|
onClose={(result) => {
|
||||||
|
if (result.state === 'success') {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
setShowFiltersAddEditModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _id = 1;
|
||||||
|
const incID = () => _id++;
|
||||||
|
function FiltersAddEdit({ filter, onClose }) {
|
||||||
|
const { masto } = api();
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const editMode = !!filter;
|
||||||
|
const { context, expiresAt, id, keywords, title, filterAction } =
|
||||||
|
filter || {};
|
||||||
|
const hasExpiry = !!expiresAt;
|
||||||
|
const expiresAtDate = hasExpiry && new Date(expiresAt);
|
||||||
|
const [editKeywords, setEditKeywords] = useState(keywords || []);
|
||||||
|
const keywordsRef = useRef();
|
||||||
|
|
||||||
|
// Hacky way of handling removed keywords for both existing and new ones
|
||||||
|
const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
|
||||||
|
const [removedKeyword_IDs, setRemovedKeyword_IDs] = useState([]);
|
||||||
|
|
||||||
|
const filteredEditKeywords = editKeywords.filter(
|
||||||
|
(k) =>
|
||||||
|
!removedKeywordIDs.includes(k.id) && !removedKeyword_IDs.includes(k._id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sheet" id="filters-add-edit-modal">
|
||||||
|
{!!onClose && (
|
||||||
|
<button type="button" class="sheet-close" onClick={onClose}>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<header>
|
||||||
|
<h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const title = formData.get('title');
|
||||||
|
const keywordIDs = formData.getAll('keyword_attributes[][id]');
|
||||||
|
const keywordKeywords = formData.getAll(
|
||||||
|
'keyword_attributes[][keyword]',
|
||||||
|
);
|
||||||
|
// const keywordWholeWords = formData.getAll(
|
||||||
|
// 'keyword_attributes[][whole_word]',
|
||||||
|
// );
|
||||||
|
// Not using getAll because it skips the empty checkboxes
|
||||||
|
const keywordWholeWords = [
|
||||||
|
...keywordsRef.current.querySelectorAll(
|
||||||
|
'input[name="keyword_attributes[][whole_word]"]',
|
||||||
|
),
|
||||||
|
].map((i) => i.checked);
|
||||||
|
const keywordsAttributes = keywordKeywords.map((k, i) => ({
|
||||||
|
id: keywordIDs[i] || undefined,
|
||||||
|
keyword: k,
|
||||||
|
wholeWord: keywordWholeWords[i],
|
||||||
|
}));
|
||||||
|
// if (editMode && keywords?.length) {
|
||||||
|
// // Find which one got deleted and add to keywordsAttributes
|
||||||
|
// keywords.forEach((k) => {
|
||||||
|
// if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
|
||||||
|
// keywordsAttributes.push({
|
||||||
|
// ...k,
|
||||||
|
// _destroy: true,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
if (editMode && removedKeywordIDs?.length) {
|
||||||
|
removedKeywordIDs.forEach((id) => {
|
||||||
|
keywordsAttributes.push({
|
||||||
|
id,
|
||||||
|
_destroy: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const context = formData.getAll('context');
|
||||||
|
let expiresIn = formData.get('expires_in');
|
||||||
|
const filterAction = formData.get('filter_action');
|
||||||
|
console.log({
|
||||||
|
title,
|
||||||
|
keywordIDs,
|
||||||
|
keywords: keywordKeywords,
|
||||||
|
wholeWords: keywordWholeWords,
|
||||||
|
keywordsAttributes,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if (!title || !context?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUIState('loading');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
let filterResult;
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
if (expiresIn === '' || expiresIn === null) {
|
||||||
|
// No value
|
||||||
|
// Preserve existing expiry if not specified
|
||||||
|
// Seconds from now to expiresAtDate
|
||||||
|
// Other clients don't do this
|
||||||
|
if (hasExpiry) {
|
||||||
|
expiresIn = Math.floor(
|
||||||
|
(expiresAtDate - new Date()) / 1000,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expiresIn = null;
|
||||||
|
}
|
||||||
|
} else if (expiresIn === '0' || expiresIn === 0) {
|
||||||
|
// 0 = Never
|
||||||
|
expiresIn = null;
|
||||||
|
} else {
|
||||||
|
expiresIn = +expiresIn;
|
||||||
|
}
|
||||||
|
filterResult = await masto.v2.filters.$select(id).update({
|
||||||
|
title,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
keywordsAttributes,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
expiresIn = +expiresIn || null;
|
||||||
|
filterResult = await masto.v2.filters.create({
|
||||||
|
title,
|
||||||
|
context,
|
||||||
|
expiresIn,
|
||||||
|
keywordsAttributes,
|
||||||
|
filterAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log({ filterResult });
|
||||||
|
setUIState('default');
|
||||||
|
onClose?.({
|
||||||
|
state: 'success',
|
||||||
|
filter: filterResult,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setUIState('error');
|
||||||
|
alert(
|
||||||
|
editMode
|
||||||
|
? 'Unable to edit filter'
|
||||||
|
: 'Unable to create filter',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="filter-form-row">
|
||||||
|
<label>
|
||||||
|
<b>Title</b>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
defaultValue={title}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
dir="auto"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-keywords" ref={keywordsRef}>
|
||||||
|
{filteredEditKeywords.length ? (
|
||||||
|
<ul class="filter-keywords">
|
||||||
|
{filteredEditKeywords.map((k) => {
|
||||||
|
const { id, keyword, wholeWord, _id } = k;
|
||||||
|
return (
|
||||||
|
<li key={`${id}-${_id}`}>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="keyword_attributes[][id]"
|
||||||
|
value={id}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="keyword_attributes[][keyword]"
|
||||||
|
type="text"
|
||||||
|
defaultValue={keyword}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
required
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
<div class="filter-keyword-actions">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
name="keyword_attributes[][whole_word]"
|
||||||
|
type="checkbox"
|
||||||
|
value={id} // Hacky way to map checkbox boolean to the keyword id
|
||||||
|
defaultChecked={wholeWord}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
Whole word
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light danger small"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
if (id) {
|
||||||
|
removedKeywordIDs.push(id);
|
||||||
|
setRemovedKeywordIDs([...removedKeywordIDs]);
|
||||||
|
} else if (_id) {
|
||||||
|
removedKeyword_IDs.push(_id);
|
||||||
|
setRemovedKeyword_IDs([...removedKeyword_IDs]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div class="filter-keywords">
|
||||||
|
<div class="insignificant">No keywords. Add one.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<footer class="filter-keywords-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
onClick={() => {
|
||||||
|
setEditKeywords([
|
||||||
|
...editKeywords,
|
||||||
|
{
|
||||||
|
_id: incID(),
|
||||||
|
keyword: '',
|
||||||
|
wholeWord: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setTimeout(() => {
|
||||||
|
// Focus last input
|
||||||
|
const fields =
|
||||||
|
keywordsRef.current.querySelectorAll(
|
||||||
|
'input[type="text"]',
|
||||||
|
);
|
||||||
|
fields[fields.length - 1]?.focus?.();
|
||||||
|
}, 10);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add keyword
|
||||||
|
</button>{' '}
|
||||||
|
{filteredEditKeywords?.length > 1 && (
|
||||||
|
<small class="insignificant">
|
||||||
|
{filteredEditKeywords.length} keyword
|
||||||
|
{filteredEditKeywords.length === 1 ? '' : 's'}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-cols">
|
||||||
|
<div class="filter-form-col">
|
||||||
|
<div>
|
||||||
|
<b>Filter from…</b>
|
||||||
|
</div>
|
||||||
|
{FILTER_CONTEXT.map((ctx) => (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class={
|
||||||
|
FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
|
||||||
|
? 'insignificant'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="context"
|
||||||
|
value={ctx}
|
||||||
|
defaultChecked={!!context ? context.includes(ctx) : true}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
{FILTER_CONTEXT_LABELS[ctx]}
|
||||||
|
{FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
|
||||||
|
</label>{' '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p>
|
||||||
|
<small class="insignificant">* Not implemented yet</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="filter-form-col">
|
||||||
|
{editMode && (
|
||||||
|
<>
|
||||||
|
Status:{' '}
|
||||||
|
<b>
|
||||||
|
<ExpiryStatus expiresAt={expiresAt} showNeverExpires />
|
||||||
|
</b>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label for="filters-expires_in">
|
||||||
|
{editMode ? 'Change expiry' : 'Expiry'}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filters-expires_in"
|
||||||
|
name="expires_in"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
defaultValue={editMode ? undefined : 0}
|
||||||
|
>
|
||||||
|
{editMode && <option></option>}
|
||||||
|
{EXPIRY_DURATIONS.map((v) => (
|
||||||
|
<option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Filtered post will be…
|
||||||
|
<br />
|
||||||
|
<label class="ib">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="filter_action"
|
||||||
|
value="warn"
|
||||||
|
defaultChecked={filterAction === 'warn' || !editMode}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
minimized
|
||||||
|
</label>{' '}
|
||||||
|
<label class="ib">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="filter_action"
|
||||||
|
value="hide"
|
||||||
|
defaultChecked={filterAction === 'hide'}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
/>{' '}
|
||||||
|
hidden
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="filter-form-footer">
|
||||||
|
<span>
|
||||||
|
<button type="submit" disabled={uiState === 'loading'}>
|
||||||
|
{editMode ? 'Save' : 'Create'}
|
||||||
|
</button>{' '}
|
||||||
|
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||||
|
</span>
|
||||||
|
{editMode && (
|
||||||
|
<MenuConfirm
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
align="end"
|
||||||
|
menuItemClassName="danger"
|
||||||
|
confirmLabel="Delete this filter?"
|
||||||
|
onClick={() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v2.filters.$select(id).remove();
|
||||||
|
setUIState('default');
|
||||||
|
onClose?.({
|
||||||
|
state: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
alert('Unable to delete filter.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light danger"
|
||||||
|
onClick={() => {}}
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
>
|
||||||
|
Delete…
|
||||||
|
</button>
|
||||||
|
</MenuConfirm>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpiryStatus({ expiresAt, showNeverExpires }) {
|
||||||
|
const hasExpiry = !!expiresAt;
|
||||||
|
const expiresAtDate = hasExpiry && new Date(expiresAt);
|
||||||
|
const expired = hasExpiry && expiresAtDate <= new Date();
|
||||||
|
|
||||||
|
// If less than a minute left, re-render interval every second, else every minute
|
||||||
|
const [_, rerender] = useReducer((c) => c + 1, 0);
|
||||||
|
useInterval(rerender, expired || 30_000);
|
||||||
|
|
||||||
|
return expired ? (
|
||||||
|
'Expired'
|
||||||
|
) : hasExpiry ? (
|
||||||
|
<>
|
||||||
|
Expiring <RelativeTime datetime={expiresAtDate} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
showNeverExpires && 'Never expires'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Filters;
|
|
@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
function FollowedHashtags() {
|
function FollowedHashtags() {
|
||||||
const { masto, instance } = api();
|
const { masto, instance } = api();
|
||||||
useTitle(`Followed Hashtags`, `/ft`);
|
useTitle(`Followed Hashtags`, `/fh`);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
const [followedHashtags, setFollowedHashtags] = useState([]);
|
const [followedHashtags, setFollowedHashtags] = useState([]);
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { useSnapshot } from 'valtio';
|
||||||
import Timeline from '../components/timeline';
|
import Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { filteredItems } from '../utils/filters';
|
import { filteredItems } from '../utils/filters';
|
||||||
import states from '../utils/states';
|
import states, { getStatus, saveStatus } from '../utils/states';
|
||||||
import { getStatus, saveStatus } from '../utils/states';
|
import supports from '../utils/supports';
|
||||||
import {
|
import {
|
||||||
assignFollowedTags,
|
assignFollowedTags,
|
||||||
clearFollowedTagsState,
|
clearFollowedTagsState,
|
||||||
|
@ -23,11 +23,19 @@ function Following({ title, path, id, ...props }) {
|
||||||
const latestItem = useRef();
|
const latestItem = useRef();
|
||||||
|
|
||||||
console.debug('RENDER Following', title, id);
|
console.debug('RENDER Following', title, id);
|
||||||
|
const supportsPixelfed = supports('@pixelfed/home-include-reblogs');
|
||||||
|
|
||||||
async function fetchHome(firstLoad) {
|
async function fetchHome(firstLoad) {
|
||||||
if (firstLoad || !homeIterator.current) {
|
if (firstLoad || !homeIterator.current) {
|
||||||
homeIterator.current = masto.v1.timelines.home.list({ limit: LIMIT });
|
homeIterator.current = masto.v1.timelines.home.list({ limit: LIMIT });
|
||||||
}
|
}
|
||||||
|
if (supportsPixelfed && homeIterator.current?.nextParams) {
|
||||||
|
if (typeof homeIterator.current.nextParams === 'string') {
|
||||||
|
homeIterator.current.nextParams += '&include_reblogs=true';
|
||||||
|
} else {
|
||||||
|
homeIterator.current.nextParams.include_reblogs = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const results = await homeIterator.current.next();
|
const results = await homeIterator.current.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
if (value?.length) {
|
if (value?.length) {
|
||||||
|
@ -63,15 +71,18 @@ function Following({ title, path, id, ...props }) {
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
try {
|
try {
|
||||||
const results = await masto.v1.timelines.home
|
const opts = {
|
||||||
.list({
|
limit: 5,
|
||||||
limit: 5,
|
since_id: latestItem.current,
|
||||||
since_id: latestItem.current,
|
};
|
||||||
})
|
if (supports('@pixelfed/home-include-reblogs')) {
|
||||||
.next();
|
opts.include_reblogs = true;
|
||||||
|
}
|
||||||
|
const results = await masto.v1.timelines.home.list(opts).next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
console.log('checkForUpdates', latestItem.current, value);
|
console.log('checkForUpdates', latestItem.current, value);
|
||||||
if (value?.length) {
|
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||||
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
latestItem.current = value[0].id;
|
latestItem.current = value[0].id;
|
||||||
value = dedupeBoosts(value, instance);
|
value = dedupeBoosts(value, instance);
|
||||||
value = filteredItems(value, 'home');
|
value = filteredItems(value, 'home');
|
||||||
|
|
|
@ -5,19 +5,19 @@ import {
|
||||||
MenuHeader,
|
MenuHeader,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
} from '@szhsin/react-menu';
|
} from '@szhsin/react-menu';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import Menu2 from '../components/menu2';
|
|
||||||
import MenuConfirm from '../components/menu-confirm';
|
import MenuConfirm from '../components/menu-confirm';
|
||||||
|
import Menu2 from '../components/menu2';
|
||||||
import { SHORTCUTS_LIMIT } from '../components/shortcuts-settings';
|
import { SHORTCUTS_LIMIT } from '../components/shortcuts-settings';
|
||||||
import Timeline from '../components/timeline';
|
import Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { filteredItems } from '../utils/filters';
|
import { filteredItems } from '../utils/filters';
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import { saveStatus } from '../utils/states';
|
import { isMediaFirstInstance } from '../utils/store-utils';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
@ -55,6 +55,8 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
useTitle(title, `/:instance?/t/:hashtag`);
|
useTitle(title, `/:instance?/t/:hashtag`);
|
||||||
const latestItem = useRef();
|
const latestItem = useRef();
|
||||||
|
|
||||||
|
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
|
||||||
|
|
||||||
// const hashtagsIterator = useRef();
|
// const hashtagsIterator = useRef();
|
||||||
const maxID = useRef(undefined);
|
const maxID = useRef(undefined);
|
||||||
async function fetchHashtags(firstLoad) {
|
async function fetchHashtags(firstLoad) {
|
||||||
|
@ -73,7 +75,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
any: hashtags.slice(1),
|
any: hashtags.slice(1),
|
||||||
maxId: firstLoad ? undefined : maxID.current,
|
maxId: firstLoad ? undefined : maxID.current,
|
||||||
onlyMedia: media,
|
onlyMedia: media ? true : undefined,
|
||||||
})
|
})
|
||||||
.next();
|
.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
|
@ -85,7 +87,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
// value = filteredItems(value, 'public');
|
// value = filteredItems(value, 'public');
|
||||||
value.forEach((item) => {
|
value.forEach((item) => {
|
||||||
saveStatus(item, instance, {
|
saveStatus(item, instance, {
|
||||||
skipThreading: media, // If media view, no need to form threads
|
skipThreading: media || mediaFirst, // If media view, no need to form threads
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -109,8 +111,9 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
})
|
})
|
||||||
.next();
|
.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
value = filteredItems(value, 'public');
|
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||||
if (value?.length) {
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
|
value = filteredItems(value, 'public');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
@ -136,6 +139,26 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
|
|
||||||
const reachLimit = hashtags.length >= TOTAL_TAGS_LIMIT;
|
const reachLimit = hashtags.length >= TOTAL_TAGS_LIMIT;
|
||||||
|
|
||||||
|
const [featuredUIState, setFeaturedUIState] = useState('default');
|
||||||
|
const [featuredTags, setFeaturedTags] = useState([]);
|
||||||
|
const [isFeaturedTag, setIsFeaturedTag] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const featuredTags = await masto.v1.featuredTags.list();
|
||||||
|
setFeaturedTags(featuredTags);
|
||||||
|
setIsFeaturedTag(
|
||||||
|
featuredTags.some(
|
||||||
|
(tag) => tag.name.toLowerCase() === hashtag.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Timeline
|
<Timeline
|
||||||
key={instance + hashtagTitle}
|
key={instance + hashtagTitle}
|
||||||
|
@ -143,7 +166,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
titleComponent={
|
titleComponent={
|
||||||
!!instance && (
|
!!instance && (
|
||||||
<h1 class="header-double-lines">
|
<h1 class="header-double-lines">
|
||||||
<b>{hashtagTitle}</b>
|
<b dir="auto">{hashtagTitle}</b>
|
||||||
<div>{instance}</div>
|
<div>{instance}</div>
|
||||||
</h1>
|
</h1>
|
||||||
)
|
)
|
||||||
|
@ -155,7 +178,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
fetchItems={fetchHashtags}
|
fetchItems={fetchHashtags}
|
||||||
checkForUpdates={checkForUpdates}
|
checkForUpdates={checkForUpdates}
|
||||||
useItemID
|
useItemID
|
||||||
view={media ? 'media' : undefined}
|
view={media || mediaFirst ? 'media' : undefined}
|
||||||
refresh={media}
|
refresh={media}
|
||||||
// allowFilters
|
// allowFilters
|
||||||
filterContext="public"
|
filterContext="public"
|
||||||
|
@ -229,26 +252,93 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</MenuConfirm>
|
</MenuConfirm>
|
||||||
|
<MenuItem
|
||||||
|
type="checkbox"
|
||||||
|
checked={isFeaturedTag}
|
||||||
|
disabled={featuredUIState === 'loading' || !authenticated}
|
||||||
|
onClick={() => {
|
||||||
|
setFeaturedUIState('loading');
|
||||||
|
if (isFeaturedTag) {
|
||||||
|
const featuredTagID = featuredTags.find(
|
||||||
|
(tag) => tag.name.toLowerCase() === hashtag.toLowerCase(),
|
||||||
|
).id;
|
||||||
|
if (featuredTagID) {
|
||||||
|
masto.v1.featuredTags
|
||||||
|
.$select(featuredTagID)
|
||||||
|
.remove()
|
||||||
|
.then(() => {
|
||||||
|
setIsFeaturedTag(false);
|
||||||
|
showToast('Unfeatured on profile');
|
||||||
|
setFeaturedTags(
|
||||||
|
featuredTags.filter(
|
||||||
|
(tag) => tag.id !== featuredTagID,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setFeaturedUIState('default');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast('Unable to unfeature on profile');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
masto.v1.featuredTags
|
||||||
|
.create({
|
||||||
|
name: hashtag,
|
||||||
|
})
|
||||||
|
.then((value) => {
|
||||||
|
setIsFeaturedTag(true);
|
||||||
|
showToast('Featured on profile');
|
||||||
|
setFeaturedTags(featuredTags.concat(value));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setFeaturedUIState('default');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFeaturedTag ? (
|
||||||
|
<>
|
||||||
|
<Icon icon="check-circle" />
|
||||||
|
<span>Featured on profile</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="check-circle" />
|
||||||
|
<span>Feature on profile</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!mediaFirst && (
|
||||||
|
<>
|
||||||
|
<MenuHeader className="plain">Filters</MenuHeader>
|
||||||
|
<MenuItem
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!media}
|
||||||
|
onClick={() => {
|
||||||
|
if (media) {
|
||||||
|
searchParams.delete('media');
|
||||||
|
} else {
|
||||||
|
searchParams.set('media', '1');
|
||||||
|
}
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="check-circle" />{' '}
|
||||||
|
<span class="menu-grow">Media only</span>
|
||||||
|
</MenuItem>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<MenuHeader className="plain">Filters</MenuHeader>
|
|
||||||
<MenuItem
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!media}
|
|
||||||
onClick={() => {
|
|
||||||
if (media) {
|
|
||||||
searchParams.delete('media');
|
|
||||||
} else {
|
|
||||||
searchParams.set('media', '1');
|
|
||||||
}
|
|
||||||
setSearchParams(searchParams);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="check-circle" />{' '}
|
|
||||||
<span class="menu-grow">Media only</span>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuDivider />
|
|
||||||
<FocusableItem className="menu-field" disabled={reachLimit}>
|
<FocusableItem className="menu-field" disabled={reachLimit}>
|
||||||
{({ ref }) => (
|
{({ ref }) => (
|
||||||
<form
|
<form
|
||||||
|
@ -285,10 +375,11 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
required
|
required
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
spellcheck={false}
|
spellCheck={false}
|
||||||
// no spaces, no hashtags
|
// no spaces, no hashtags
|
||||||
pattern="[^#][^\s#]+[^#]"
|
pattern="[^#][^\s#]+[^#]"
|
||||||
disabled={reachLimit}
|
disabled={reachLimit}
|
||||||
|
dir="auto"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
@ -312,7 +403,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
|
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
|
||||||
<span>
|
<span class="bidi-isolate">
|
||||||
<span class="more-insignificant">#</span>
|
<span class="more-insignificant">#</span>
|
||||||
{t}
|
{t}
|
||||||
</span>
|
</span>
|
||||||
|
@ -358,7 +449,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="shortcut" /> <span>Add to Shorcuts</span>
|
<Icon icon="shortcut" /> <span>Add to Shortcuts</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
@ -12,11 +12,15 @@ import Loader from '../components/loader';
|
||||||
import Notification from '../components/notification';
|
import Notification from '../components/notification';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import db from '../utils/db';
|
import db from '../utils/db';
|
||||||
import groupNotifications from '../utils/group-notifications';
|
import { massageNotifications2 } from '../utils/group-notifications';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import { getCurrentAccountNS } from '../utils/store-utils';
|
import { getCurrentAccountNS } from '../utils/store-utils';
|
||||||
|
|
||||||
import Following from './following';
|
import Following from './following';
|
||||||
|
import {
|
||||||
|
getGroupedNotifications,
|
||||||
|
mastoFetchNotifications,
|
||||||
|
} from './notifications';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
|
@ -84,20 +88,17 @@ function NotificationsLink() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTIFICATIONS_LIMIT = 30;
|
|
||||||
const NOTIFICATIONS_DISPLAY_LIMIT = 5;
|
const NOTIFICATIONS_DISPLAY_LIMIT = 5;
|
||||||
function NotificationsMenu({ anchorRef, state, onClose }) {
|
function NotificationsMenu({ anchorRef, state, onClose }) {
|
||||||
const { masto, instance } = api();
|
const { masto, instance } = api();
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
const notificationsIterator = masto.v1.notifications.list({
|
const notificationsIterator = mastoFetchNotifications();
|
||||||
limit: NOTIFICATIONS_LIMIT,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchNotifications() {
|
async function fetchNotifications() {
|
||||||
const allNotifications = await notificationsIterator.next();
|
const allNotifications = await notificationsIterator.next();
|
||||||
const notifications = allNotifications.value;
|
const notifications = massageNotifications2(allNotifications.value);
|
||||||
|
|
||||||
if (notifications?.length) {
|
if (notifications?.length) {
|
||||||
notifications.forEach((notification) => {
|
notifications.forEach((notification) => {
|
||||||
|
@ -106,16 +107,16 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedNotifications = groupNotifications(notifications);
|
const groupedNotifications = getGroupedNotifications(notifications);
|
||||||
|
|
||||||
states.notificationsLast = notifications[0];
|
states.notificationsLast = groupedNotifications[0];
|
||||||
states.notifications = groupedNotifications;
|
states.notifications = groupedNotifications;
|
||||||
|
|
||||||
// Update last read marker
|
// Update last read marker
|
||||||
masto.v1.markers
|
masto.v1.markers
|
||||||
.create({
|
.create({
|
||||||
notifications: {
|
notifications: {
|
||||||
lastReadId: notifications[0].id,
|
lastReadId: groupedNotifications[0].id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
@ -151,8 +152,11 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
|
||||||
if (state === 'open') loadNotifications();
|
if (state === 'open') loadNotifications();
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
|
const menuRef = useRef();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledMenu
|
<ControlledMenu
|
||||||
|
ref={menuRef}
|
||||||
menuClassName="notifications-menu"
|
menuClassName="notifications-menu"
|
||||||
state={state}
|
state={state}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
|
@ -160,6 +164,11 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
|
||||||
portal={{
|
portal={{
|
||||||
target: document.body,
|
target: document.body,
|
||||||
}}
|
}}
|
||||||
|
containerProps={{
|
||||||
|
onClick: () => {
|
||||||
|
menuRef.current?.closeMenu?.();
|
||||||
|
},
|
||||||
|
}}
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
viewScroll="close"
|
viewScroll="close"
|
||||||
position="anchor"
|
position="anchor"
|
||||||
|
@ -176,7 +185,7 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
|
||||||
.slice(0, NOTIFICATIONS_DISPLAY_LIMIT)
|
.slice(0, NOTIFICATIONS_DISPLAY_LIMIT)
|
||||||
.map((notification) => (
|
.map((notification) => (
|
||||||
<Notification
|
<Notification
|
||||||
key={notification.id}
|
key={notification._ids || notification.id}
|
||||||
instance={instance}
|
instance={instance}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
disableContextMenu
|
disableContextMenu
|
||||||
|
|
|
@ -24,11 +24,13 @@ export default function HttpRoute() {
|
||||||
// Check if status returns 200
|
// Check if status returns 200
|
||||||
try {
|
try {
|
||||||
const { instance, id } = statusObject;
|
const { instance, id } = statusObject;
|
||||||
const { masto } = api({ instance });
|
if (id) {
|
||||||
const status = await masto.v1.statuses.$select(id).fetch();
|
const { masto } = api({ instance });
|
||||||
if (status) {
|
const status = await masto.v1.statuses.$select(id).fetch();
|
||||||
window.location.hash = statusURL + '?view=full';
|
if (status) {
|
||||||
return;
|
window.location.hash = statusURL + '?view=full';
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import './lists.css';
|
import './lists.css';
|
||||||
|
|
||||||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
@ -10,12 +10,14 @@ import AccountBlock from '../components/account-block';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import Link from '../components/link';
|
import Link from '../components/link';
|
||||||
import ListAddEdit from '../components/list-add-edit';
|
import ListAddEdit from '../components/list-add-edit';
|
||||||
import Menu2 from '../components/menu2';
|
|
||||||
import MenuConfirm from '../components/menu-confirm';
|
import MenuConfirm from '../components/menu-confirm';
|
||||||
|
import MenuLink from '../components/menu-link';
|
||||||
|
import Menu2 from '../components/menu2';
|
||||||
import Modal from '../components/modal';
|
import Modal from '../components/modal';
|
||||||
import Timeline from '../components/timeline';
|
import Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { filteredItems } from '../utils/filters';
|
import { filteredItems } from '../utils/filters';
|
||||||
|
import { getList, getLists } from '../utils/lists';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
@ -61,8 +63,9 @@ function List(props) {
|
||||||
since_id: latestItem.current,
|
since_id: latestItem.current,
|
||||||
});
|
});
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
value = filteredItems(value, 'home');
|
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||||
if (value?.length) {
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
|
value = filteredItems(value, 'home');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
@ -71,13 +74,18 @@ function List(props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [lists, setLists] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getLists().then(setLists);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [list, setList] = useState({ title: 'List' });
|
const [list, setList] = useState({ title: 'List' });
|
||||||
// const [title, setTitle] = useState(`List`);
|
// const [title, setTitle] = useState(`List`);
|
||||||
useTitle(list.title, `/l/:id`);
|
useTitle(list.title, `/l/:id`);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await masto.v1.lists.$select(id).fetch();
|
const list = await getList(id);
|
||||||
setList(list);
|
setList(list);
|
||||||
// setTitle(list.title);
|
// setTitle(list.title);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -107,9 +115,32 @@ function List(props) {
|
||||||
showReplyParent
|
showReplyParent
|
||||||
// refresh={reloadCount}
|
// refresh={reloadCount}
|
||||||
headerStart={
|
headerStart={
|
||||||
<Link to="/l" class="button plain">
|
// <Link to="/l" class="button plain">
|
||||||
<Icon icon="list" size="l" />
|
// <Icon icon="list" size="l" />
|
||||||
</Link>
|
// </Link>
|
||||||
|
<Menu2
|
||||||
|
overflow="auto"
|
||||||
|
menuButton={
|
||||||
|
<button type="button" class="plain">
|
||||||
|
<Icon icon="list" size="l" alt="Lists" />
|
||||||
|
<Icon icon="chevron-down" size="s" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu2>
|
||||||
}
|
}
|
||||||
headerEnd={
|
headerEnd={
|
||||||
<Menu2
|
<Menu2
|
||||||
|
|
|
@ -8,11 +8,10 @@ import ListAddEdit from '../components/list-add-edit';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
import Modal from '../components/modal';
|
import Modal from '../components/modal';
|
||||||
import NavMenu from '../components/nav-menu';
|
import NavMenu from '../components/nav-menu';
|
||||||
import { api } from '../utils/api';
|
import { fetchLists } from '../utils/lists';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
function Lists() {
|
function Lists() {
|
||||||
const { masto } = api();
|
|
||||||
useTitle(`Lists`, `/l`);
|
useTitle(`Lists`, `/l`);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
|
@ -22,8 +21,7 @@ function Lists() {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const lists = await masto.v1.lists.list();
|
const lists = await fetchLists();
|
||||||
lists.sort((a, b) => a.title.localeCompare(b.title));
|
|
||||||
console.log(lists);
|
console.log(lists);
|
||||||
setLists(lists);
|
setLists(lists);
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
|
|
|
@ -30,14 +30,15 @@
|
||||||
|
|
||||||
#instances-suggestions {
|
#instances-suggestions {
|
||||||
margin: 0.2em 0 0;
|
margin: 0.2em 0 0;
|
||||||
padding: 0 0 0 1.2em;
|
padding: 0;
|
||||||
|
padding-inline-start: 1.2em;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
width: 90vw;
|
width: 90vw;
|
||||||
max-width: 40em;
|
max-width: 40em;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
to right,
|
var(--to-forward),
|
||||||
transparent,
|
transparent,
|
||||||
black 1.2em,
|
black 1.2em,
|
||||||
black calc(100% - 5em),
|
black calc(100% - 5em),
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import './login.css';
|
import './login.css';
|
||||||
|
|
||||||
|
import Fuse from 'fuse.js';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -11,6 +12,7 @@ import instancesListURL from '../data/instances.json?url';
|
||||||
import { getAuthorizationURL, registerApplication } from '../utils/auth';
|
import { getAuthorizationURL, registerApplication } from '../utils/auth';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
import { gtsDtth } from '../utils/dtth';
|
||||||
|
|
||||||
const { PHANPY_DEFAULT_INSTANCE: DEFAULT_INSTANCE } = import.meta.env;
|
const { PHANPY_DEFAULT_INSTANCE: DEFAULT_INSTANCE } = import.meta.env;
|
||||||
|
|
||||||
|
@ -23,16 +25,18 @@ function Login() {
|
||||||
const instance = searchParams.get('instance');
|
const instance = searchParams.get('instance');
|
||||||
const submit = searchParams.get('submit');
|
const submit = searchParams.get('submit');
|
||||||
const [instanceText, setInstanceText] = useState(
|
const [instanceText, setInstanceText] = useState(
|
||||||
instance || cachedInstanceURL?.toLowerCase() || '',
|
instance || cachedInstanceURL?.toLowerCase() || gtsDtth,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [instancesList, setInstancesList] = useState([]);
|
const [instancesList, setInstancesList] = useState([]);
|
||||||
|
const searcher = useRef();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(instancesListURL);
|
const res = await fetch(instancesListURL);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setInstancesList(data);
|
setInstancesList(data);
|
||||||
|
searcher.current = new Fuse(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently fail
|
// Silently fail
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
@ -90,21 +94,11 @@ function Login() {
|
||||||
!/[\s\/\\@]/.test(cleanInstanceText);
|
!/[\s\/\\@]/.test(cleanInstanceText);
|
||||||
|
|
||||||
const instancesSuggestions = cleanInstanceText
|
const instancesSuggestions = cleanInstanceText
|
||||||
? instancesList
|
? searcher.current
|
||||||
.filter((instance) => instance.includes(instanceText))
|
?.search(cleanInstanceText, {
|
||||||
.sort((a, b) => {
|
limit: 10,
|
||||||
// Move text that starts with instanceText to the start
|
|
||||||
const aStartsWith = a
|
|
||||||
.toLowerCase()
|
|
||||||
.startsWith(instanceText.toLowerCase());
|
|
||||||
const bStartsWith = b
|
|
||||||
.toLowerCase()
|
|
||||||
.startsWith(instanceText.toLowerCase());
|
|
||||||
if (aStartsWith && !bStartsWith) return -1;
|
|
||||||
if (!aStartsWith && bStartsWith) return 1;
|
|
||||||
return 0;
|
|
||||||
})
|
})
|
||||||
.slice(0, 10)
|
?.map((match) => match.item)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const selectedInstanceText = instanceTextLooksLikeDomain
|
const selectedInstanceText = instanceTextLooksLikeDomain
|
||||||
|
@ -160,11 +154,12 @@ function Login() {
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck={false}
|
spellCheck={false}
|
||||||
placeholder="instance domain"
|
placeholder="instance domain"
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
setInstanceText(e.target.value);
|
setInstanceText(e.target.value);
|
||||||
}}
|
}}
|
||||||
|
dir="auto"
|
||||||
/>
|
/>
|
||||||
{instancesSuggestions?.length > 0 ? (
|
{instancesSuggestions?.length > 0 ? (
|
||||||
<ul id="instances-suggestions">
|
<ul id="instances-suggestions">
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||||
import Link from '../components/link';
|
import Link from '../components/link';
|
||||||
import Timeline from '../components/timeline';
|
import Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import { fixNotifications } from '../utils/group-notifications';
|
||||||
import { saveStatus } from '../utils/states';
|
import { saveStatus } from '../utils/states';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
@ -30,6 +31,8 @@ function Mentions({ columnMode, ...props }) {
|
||||||
const results = await mentionsIterator.current.next();
|
const results = await mentionsIterator.current.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
if (value?.length) {
|
if (value?.length) {
|
||||||
|
value = fixNotifications(value);
|
||||||
|
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
latestItem.current = value[0].id;
|
latestItem.current = value[0].id;
|
||||||
console.log('First load', latestItem.current);
|
console.log('First load', latestItem.current);
|
||||||
|
@ -95,7 +98,9 @@ function Mentions({ columnMode, ...props }) {
|
||||||
latestConversationItem.current,
|
latestConversationItem.current,
|
||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
if (value?.length) {
|
const valueContainsLatestItem =
|
||||||
|
value[0]?.id === latestConversationItem.current; // since_id might not be supported
|
||||||
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
latestConversationItem.current = value[0].lastStatus.id;
|
latestConversationItem.current = value[0].lastStatus.id;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,6 +143,7 @@
|
||||||
border-color: var(--reply-to-color);
|
border-color: var(--reply-to-color);
|
||||||
box-shadow: 0 0 0 3px var(--reply-to-faded-color);
|
box-shadow: 0 0 0 3px var(--reply-to-faded-color);
|
||||||
}
|
}
|
||||||
|
.notification:focus-visible .status-link,
|
||||||
.notification .status-link:is(:hover, :focus) {
|
.notification .status-link:is(:hover, :focus) {
|
||||||
background-color: var(--bg-blur-color);
|
background-color: var(--bg-blur-color);
|
||||||
filter: saturate(1);
|
filter: saturate(1);
|
||||||
|
@ -184,7 +185,7 @@
|
||||||
.notification-group-statuses > li:before {
|
.notification-group-statuses > li:before {
|
||||||
content: counter(index);
|
content: counter(index);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
inset-inline-start: 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -193,16 +194,19 @@
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
}
|
}
|
||||||
.notification-group-statuses > li:not(:last-child) .status-link {
|
.notification-group-statuses > li:not(:last-child) .status-link {
|
||||||
border-bottom-left-radius: 0;
|
border-end-start-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-end-end-radius: 0;
|
||||||
}
|
}
|
||||||
.notification-group-statuses > li:not(:first-child) .status-link {
|
.notification-group-statuses > li:not(:first-child) .status-link {
|
||||||
border-top-left-radius: 0;
|
border-start-start-radius: 0;
|
||||||
border-top-right-radius: 0;
|
border-start-end-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mentions-option {
|
#mentions-option {
|
||||||
float: right;
|
float: right;
|
||||||
|
&:dir(rtl) {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
}
|
}
|
||||||
#mentions-option label {
|
#mentions-option label {
|
||||||
|
@ -387,7 +391,7 @@
|
||||||
width: calc(100% - 16px);
|
width: calc(100% - 16px);
|
||||||
}
|
}
|
||||||
.announcements > ul > li:last-child {
|
.announcements > ul > li:last-child {
|
||||||
border-right: none;
|
border-inline-end: none;
|
||||||
}
|
}
|
||||||
.announcements .announcement-block {
|
.announcements .announcement-block {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
@ -420,3 +424,145 @@
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: var(--link-faded-color);
|
background-color: var(--link-faded-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* FILTERED NOTIFICATIONS */
|
||||||
|
|
||||||
|
.filtered-notifications {
|
||||||
|
padding-block-end: 16px;
|
||||||
|
|
||||||
|
summary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
|
||||||
|
&::marker,
|
||||||
|
&::-webkit-details-marker {
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details[open] summary {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary + ul {
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 50vh;
|
||||||
|
max-height: 50dvh;
|
||||||
|
overflow: auto;
|
||||||
|
border-top: 1px solid var(--outline-color);
|
||||||
|
border-bottom: 1px solid var(--outline-color);
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
row-gap: 8px;
|
||||||
|
column-gap: 16px;
|
||||||
|
border-bottom: 1px solid var(--outline-color);
|
||||||
|
}
|
||||||
|
li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-notifcations {
|
||||||
|
min-width: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.last-post {
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
> .status-link {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
--max-height: 160px;
|
||||||
|
max-height: var(--max-height);
|
||||||
|
border: 1px solid var(--outline-color);
|
||||||
|
|
||||||
|
&:is(:hover, :focus-visible) {
|
||||||
|
border-color: var(--outline-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
black calc(var(--max-height) / 2),
|
||||||
|
transparent calc(var(--max-height) - 8px)
|
||||||
|
);
|
||||||
|
font-size: calc(var(--text-size) * 0.9);
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
pointer-events: none;
|
||||||
|
filter: saturate(0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-notifications-account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-request-buttons {
|
||||||
|
grid-area: buttons;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
max-width: 30vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-request-states {
|
||||||
|
min-height: 32px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-inline: 8px;
|
||||||
|
|
||||||
|
&.notification-accepted {
|
||||||
|
color: var(--green-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.notification-dismissed {
|
||||||
|
color: var(--red-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#notifications-settings {
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import './notifications.css';
|
||||||
import { Fragment } from 'preact';
|
import { Fragment } from 'preact';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
@ -13,24 +14,76 @@ import FollowRequestButtons from '../components/follow-request-buttons';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import Link from '../components/link';
|
import Link from '../components/link';
|
||||||
import Loader from '../components/loader';
|
import Loader from '../components/loader';
|
||||||
|
import Modal from '../components/modal';
|
||||||
import NavMenu from '../components/nav-menu';
|
import NavMenu from '../components/nav-menu';
|
||||||
import Notification from '../components/notification';
|
import Notification from '../components/notification';
|
||||||
|
import Status from '../components/status';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import enhanceContent from '../utils/enhance-content';
|
import enhanceContent from '../utils/enhance-content';
|
||||||
import groupNotifications from '../utils/group-notifications';
|
import groupNotifications, {
|
||||||
|
groupNotifications2,
|
||||||
|
massageNotifications2,
|
||||||
|
} from '../utils/group-notifications';
|
||||||
import handleContentLinks from '../utils/handle-content-links';
|
import handleContentLinks from '../utils/handle-content-links';
|
||||||
|
import mem from '../utils/mem';
|
||||||
import niceDateTime from '../utils/nice-date-time';
|
import niceDateTime from '../utils/nice-date-time';
|
||||||
import { getRegistration } from '../utils/push-notifications';
|
import { getRegistration } from '../utils/push-notifications';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
|
import showToast from '../utils/show-toast';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import { getCurrentInstance } from '../utils/store-utils';
|
import { getCurrentInstance } from '../utils/store-utils';
|
||||||
|
import supports from '../utils/supports';
|
||||||
import usePageVisibility from '../utils/usePageVisibility';
|
import usePageVisibility from '../utils/usePageVisibility';
|
||||||
import useScroll from '../utils/useScroll';
|
import useScroll from '../utils/useScroll';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 30; // 30 is the maximum limit :(
|
const NOTIFICATIONS_LIMIT = 80;
|
||||||
|
const NOTIFICATIONS_GROUPED_LIMIT = 20;
|
||||||
const emptySearchParams = new URLSearchParams();
|
const emptySearchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
const scrollIntoViewOptions = {
|
||||||
|
block: 'center',
|
||||||
|
inline: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
};
|
||||||
|
|
||||||
|
const memSupportsGroupedNotifications = mem(
|
||||||
|
() => supports('@mastodon/grouped-notifications'),
|
||||||
|
{
|
||||||
|
maxAge: 1000 * 60 * 5, // 5 minutes
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function mastoFetchNotifications(opts = {}) {
|
||||||
|
const { masto } = api();
|
||||||
|
if (
|
||||||
|
states.settings.groupedNotificationsAlpha &&
|
||||||
|
memSupportsGroupedNotifications()
|
||||||
|
) {
|
||||||
|
// https://github.com/mastodon/mastodon/pull/29889
|
||||||
|
return masto.v2_alpha.notifications.list({
|
||||||
|
limit: NOTIFICATIONS_GROUPED_LIMIT,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return masto.v1.notifications.list({
|
||||||
|
limit: NOTIFICATIONS_LIMIT,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupedNotifications(notifications) {
|
||||||
|
if (
|
||||||
|
states.settings.groupedNotificationsAlpha &&
|
||||||
|
memSupportsGroupedNotifications()
|
||||||
|
) {
|
||||||
|
return groupNotifications2(notifications);
|
||||||
|
} else {
|
||||||
|
return groupNotifications(notifications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Notifications({ columnMode }) {
|
function Notifications({ columnMode }) {
|
||||||
useTitle('Notifications', '/notifications');
|
useTitle('Notifications', '/notifications');
|
||||||
const { masto, instance } = api();
|
const { masto, instance } = api();
|
||||||
|
@ -56,13 +109,19 @@ function Notifications({ columnMode }) {
|
||||||
async function fetchNotifications(firstLoad) {
|
async function fetchNotifications(firstLoad) {
|
||||||
if (firstLoad || !notificationsIterator.current) {
|
if (firstLoad || !notificationsIterator.current) {
|
||||||
// Reset iterator
|
// Reset iterator
|
||||||
notificationsIterator.current = masto.v1.notifications.list({
|
notificationsIterator.current = mastoFetchNotifications({
|
||||||
limit: LIMIT,
|
|
||||||
excludeTypes: ['follow_request'],
|
excludeTypes: ['follow_request'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (/max_id=($|&)/i.test(notificationsIterator.current?.nextParams)) {
|
||||||
|
// Pixelfed returns next paginationed link with empty max_id
|
||||||
|
// I assume, it's done (end of list)
|
||||||
|
return {
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
const allNotifications = await notificationsIterator.current.next();
|
const allNotifications = await notificationsIterator.current.next();
|
||||||
const notifications = allNotifications.value;
|
const notifications = massageNotifications2(allNotifications.value);
|
||||||
|
|
||||||
if (notifications?.length) {
|
if (notifications?.length) {
|
||||||
notifications.forEach((notification) => {
|
notifications.forEach((notification) => {
|
||||||
|
@ -71,17 +130,43 @@ function Notifications({ columnMode }) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupedNotifications = groupNotifications(notifications);
|
// TEST: Slot in a fake notification to test 'severed_relationships'
|
||||||
|
// notifications.unshift({
|
||||||
|
// id: '123123',
|
||||||
|
// type: 'severed_relationships',
|
||||||
|
// createdAt: '2024-03-22T19:20:08.316Z',
|
||||||
|
// event: {
|
||||||
|
// type: 'account_suspension',
|
||||||
|
// targetName: 'mastodon.dev',
|
||||||
|
// followersCount: 0,
|
||||||
|
// followingCount: 0,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// TEST: Slot in a fake notification to test 'moderation_warning'
|
||||||
|
// notifications.unshift({
|
||||||
|
// id: '123123',
|
||||||
|
// type: 'moderation_warning',
|
||||||
|
// createdAt: new Date().toISOString(),
|
||||||
|
// moderation_warning: {
|
||||||
|
// id: '1231234',
|
||||||
|
// action: 'mark_statuses_as_sensitive',
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// console.log({ notifications });
|
||||||
|
|
||||||
|
const groupedNotifications = getGroupedNotifications(notifications);
|
||||||
|
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
states.notificationsLast = notifications[0];
|
states.notificationsLast = groupedNotifications[0];
|
||||||
states.notifications = groupedNotifications;
|
states.notifications = groupedNotifications;
|
||||||
|
|
||||||
// Update last read marker
|
// Update last read marker
|
||||||
masto.v1.markers
|
masto.v1.markers
|
||||||
.create({
|
.create({
|
||||||
notifications: {
|
notifications: {
|
||||||
lastReadId: notifications[0].id,
|
lastReadId: groupedNotifications[0].id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
@ -129,6 +214,28 @@ function Notifications({ columnMode }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supportsFilteredNotifications = supports(
|
||||||
|
'@mastodon/filtered-notifications',
|
||||||
|
);
|
||||||
|
const [showNotificationsSettings, setShowNotificationsSettings] =
|
||||||
|
useState(false);
|
||||||
|
const [notificationsPolicy, setNotificationsPolicy] = useState({});
|
||||||
|
function fetchNotificationsPolicy() {
|
||||||
|
return masto.v1.notifications.policy.fetch().catch(() => {});
|
||||||
|
}
|
||||||
|
function loadNotificationsPolicy() {
|
||||||
|
fetchNotificationsPolicy()
|
||||||
|
.then((policy) => {
|
||||||
|
console.log('✨ Notifications policy', policy);
|
||||||
|
setNotificationsPolicy(policy);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
const [notificationsRequests, setNotificationsRequests] = useState(null);
|
||||||
|
function fetchNotificationsRequest() {
|
||||||
|
return masto.v1.notifications.requests.list();
|
||||||
|
}
|
||||||
|
|
||||||
const loadNotifications = (firstLoad) => {
|
const loadNotifications = (firstLoad) => {
|
||||||
setShowNew(false);
|
setShowNew(false);
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
|
@ -154,6 +261,10 @@ function Notifications({ columnMode }) {
|
||||||
setFollowRequests(requests);
|
setFollowRequests(requests);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
if (supportsFilteredNotifications) {
|
||||||
|
loadNotificationsPolicy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { done } = await fetchNotificationsPromise;
|
const { done } = await fetchNotificationsPromise;
|
||||||
|
@ -161,6 +272,7 @@ function Notifications({ columnMode }) {
|
||||||
|
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
@ -209,7 +321,6 @@ function Notifications({ columnMode }) {
|
||||||
|
|
||||||
const lastHiddenTime = useRef();
|
const lastHiddenTime = useRef();
|
||||||
usePageVisibility((visible) => {
|
usePageVisibility((visible) => {
|
||||||
let unsub;
|
|
||||||
if (visible) {
|
if (visible) {
|
||||||
const timeDiff = Date.now() - lastHiddenTime.current;
|
const timeDiff = Date.now() - lastHiddenTime.current;
|
||||||
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
|
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
|
||||||
|
@ -220,17 +331,21 @@ function Notifications({ columnMode }) {
|
||||||
} else {
|
} else {
|
||||||
lastHiddenTime.current = Date.now();
|
lastHiddenTime.current = Date.now();
|
||||||
}
|
}
|
||||||
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
|
|
||||||
if (v) {
|
|
||||||
loadUpdates();
|
|
||||||
}
|
|
||||||
setShowNew(v);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
unsub?.();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
const firstLoad = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
let unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
|
||||||
|
if (firstLoad.current) {
|
||||||
|
firstLoad.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uiState === 'loading') return;
|
||||||
|
if (v) loadUpdates();
|
||||||
|
setShowNew(v);
|
||||||
|
});
|
||||||
|
return () => unsub?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const todayDate = new Date();
|
const todayDate = new Date();
|
||||||
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
|
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
|
||||||
|
@ -270,11 +385,84 @@ function Notifications({ columnMode }) {
|
||||||
// }
|
// }
|
||||||
// }, [uiState]);
|
// }, [uiState]);
|
||||||
|
|
||||||
|
const itemsSelector = '.notification';
|
||||||
|
const jRef = useHotkeys('j', () => {
|
||||||
|
const activeItem = document.activeElement.closest(itemsSelector);
|
||||||
|
const activeItemRect = activeItem?.getBoundingClientRect();
|
||||||
|
const allItems = Array.from(
|
||||||
|
scrollableRef.current.querySelectorAll(itemsSelector),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
activeItem &&
|
||||||
|
activeItemRect.top < scrollableRef.current.clientHeight &&
|
||||||
|
activeItemRect.bottom > 0
|
||||||
|
) {
|
||||||
|
const activeItemIndex = allItems.indexOf(activeItem);
|
||||||
|
let nextItem = allItems[activeItemIndex + 1];
|
||||||
|
if (nextItem) {
|
||||||
|
nextItem.focus();
|
||||||
|
nextItem.scrollIntoView(scrollIntoViewOptions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const topmostItem = allItems.find((item) => {
|
||||||
|
const itemRect = item.getBoundingClientRect();
|
||||||
|
return itemRect.top >= 44 && itemRect.left >= 0;
|
||||||
|
});
|
||||||
|
if (topmostItem) {
|
||||||
|
topmostItem.focus();
|
||||||
|
topmostItem.scrollIntoView(scrollIntoViewOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const kRef = useHotkeys('k', () => {
|
||||||
|
// focus on previous status after active item
|
||||||
|
const activeItem = document.activeElement.closest(itemsSelector);
|
||||||
|
const activeItemRect = activeItem?.getBoundingClientRect();
|
||||||
|
const allItems = Array.from(
|
||||||
|
scrollableRef.current.querySelectorAll(itemsSelector),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
activeItem &&
|
||||||
|
activeItemRect.top < scrollableRef.current.clientHeight &&
|
||||||
|
activeItemRect.bottom > 0
|
||||||
|
) {
|
||||||
|
const activeItemIndex = allItems.indexOf(activeItem);
|
||||||
|
let prevItem = allItems[activeItemIndex - 1];
|
||||||
|
if (prevItem) {
|
||||||
|
prevItem.focus();
|
||||||
|
prevItem.scrollIntoView(scrollIntoViewOptions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const topmostItem = allItems.find((item) => {
|
||||||
|
const itemRect = item.getBoundingClientRect();
|
||||||
|
return itemRect.top >= 44 && itemRect.left >= 0;
|
||||||
|
});
|
||||||
|
if (topmostItem) {
|
||||||
|
topmostItem.focus();
|
||||||
|
topmostItem.scrollIntoView(scrollIntoViewOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const oRef = useHotkeys(['enter', 'o'], () => {
|
||||||
|
const activeItem = document.activeElement.closest(itemsSelector);
|
||||||
|
const statusLink = activeItem?.querySelector('.status-link');
|
||||||
|
if (statusLink) {
|
||||||
|
statusLink.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="notifications-page"
|
id="notifications-page"
|
||||||
class="deck-container"
|
class="deck-container"
|
||||||
ref={scrollableRef}
|
ref={(node) => {
|
||||||
|
scrollableRef.current = node;
|
||||||
|
jRef.current = node;
|
||||||
|
kRef.current = node;
|
||||||
|
oRef.current = node;
|
||||||
|
}}
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
>
|
>
|
||||||
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
||||||
|
@ -301,7 +489,17 @@ function Notifications({ columnMode }) {
|
||||||
</div>
|
</div>
|
||||||
<h1>Notifications</h1>
|
<h1>Notifications</h1>
|
||||||
<div class="header-side">
|
<div class="header-side">
|
||||||
{/* <Loader hidden={uiState !== 'loading'} /> */}
|
{supportsFilteredNotifications && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button plain4"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNotificationsSettings(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="settings" size="l" alt="Notifications settings" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showNew && uiState !== 'loading' && (
|
{showNew && uiState !== 'loading' && (
|
||||||
|
@ -406,6 +604,76 @@ function Notifications({ columnMode }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{supportsFilteredNotifications &&
|
||||||
|
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
|
||||||
|
<div class="shazam-container">
|
||||||
|
<div class="shazam-container-inner">
|
||||||
|
<div class="filtered-notifications">
|
||||||
|
<details
|
||||||
|
onToggle={async (e) => {
|
||||||
|
const { open } = e.target;
|
||||||
|
if (open) {
|
||||||
|
const requests = await fetchNotificationsRequest();
|
||||||
|
setNotificationsRequests(requests);
|
||||||
|
console.log({ open, requests });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
Filtered notifications from{' '}
|
||||||
|
{notificationsPolicy.summary.pendingRequestsCount} people
|
||||||
|
</summary>
|
||||||
|
{!notificationsRequests ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
<Loader abrupt />
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
notificationsRequests?.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{notificationsRequests.map((request) => (
|
||||||
|
<li key={request.id}>
|
||||||
|
<div class="request-notifcations">
|
||||||
|
{!request.lastStatus?.id && (
|
||||||
|
<AccountBlock
|
||||||
|
useAvatarStatic
|
||||||
|
showStats
|
||||||
|
account={request.account}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{request.lastStatus?.id && (
|
||||||
|
<div class="last-post">
|
||||||
|
<Link
|
||||||
|
class="status-link"
|
||||||
|
to={`/${instance}/s/${request.lastStatus.id}`}
|
||||||
|
>
|
||||||
|
<Status
|
||||||
|
status={request.lastStatus}
|
||||||
|
size="s"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<NotificationRequestModalButton
|
||||||
|
request={request}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NotificationRequestButtons
|
||||||
|
request={request}
|
||||||
|
onChange={() => {
|
||||||
|
loadNotifications(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div id="mentions-option">
|
<div id="mentions-option">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
@ -419,7 +687,7 @@ function Notifications({ columnMode }) {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="timeline-header">Today</h2>
|
<h2 class="timeline-header">Today</h2>
|
||||||
{showTodayEmpty && !!snapStates.notifications.length && (
|
{showTodayEmpty && (
|
||||||
<p class="ui-state insignificant">
|
<p class="ui-state insignificant">
|
||||||
{uiState === 'default' ? "You're all caught up." : <>…</>}
|
{uiState === 'default' ? "You're all caught up." : <>…</>}
|
||||||
</p>
|
</p>
|
||||||
|
@ -449,12 +717,12 @@ function Notifications({ columnMode }) {
|
||||||
hideTime: true,
|
hideTime: true,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<Fragment key={notification.id}>
|
<Fragment key={notification._ids || notification.id}>
|
||||||
{differentDay && <h2 class="timeline-header">{heading}</h2>}
|
{differentDay && <h2 class="timeline-header">{heading}</h2>}
|
||||||
<Notification
|
<Notification
|
||||||
instance={instance}
|
instance={instance}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
key={notification.id}
|
key={notification._ids || notification.id}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
@ -514,6 +782,109 @@ function Notifications({ columnMode }) {
|
||||||
</InView>
|
</InView>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{supportsFilteredNotifications && showNotificationsSettings && (
|
||||||
|
<Modal
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowNotificationsSettings(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="sheet" id="notifications-settings" tabIndex="-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sheet-close"
|
||||||
|
onClick={() => setShowNotificationsSettings(false)}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
<header>
|
||||||
|
<h2>Notifications settings</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const {
|
||||||
|
filterNotFollowing,
|
||||||
|
filterNotFollowers,
|
||||||
|
filterNewAccounts,
|
||||||
|
filterPrivateMentions,
|
||||||
|
} = e.target;
|
||||||
|
const allFilters = {
|
||||||
|
filterNotFollowing: filterNotFollowing.checked,
|
||||||
|
filterNotFollowers: filterNotFollowers.checked,
|
||||||
|
filterNewAccounts: filterNewAccounts.checked,
|
||||||
|
filterPrivateMentions: filterPrivateMentions.checked,
|
||||||
|
};
|
||||||
|
setNotificationsPolicy({
|
||||||
|
...notificationsPolicy,
|
||||||
|
...allFilters,
|
||||||
|
});
|
||||||
|
setShowNotificationsSettings(false);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v1.notifications.policy.update(allFilters);
|
||||||
|
showToast('Notifications settings updated');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>Filter out notifications from people:</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
switch
|
||||||
|
defaultChecked={notificationsPolicy.filterNotFollowing}
|
||||||
|
name="filterNotFollowing"
|
||||||
|
/>{' '}
|
||||||
|
You don't follow
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
switch
|
||||||
|
defaultChecked={notificationsPolicy.filterNotFollowers}
|
||||||
|
name="filterNotFollowers"
|
||||||
|
/>{' '}
|
||||||
|
Who don't follow you
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
switch
|
||||||
|
defaultChecked={notificationsPolicy.filterNewAccounts}
|
||||||
|
name="filterNewAccounts"
|
||||||
|
/>{' '}
|
||||||
|
With a new account
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
switch
|
||||||
|
defaultChecked={notificationsPolicy.filterPrivateMentions}
|
||||||
|
name="filterPrivateMentions"
|
||||||
|
/>{' '}
|
||||||
|
Who unsolicitedly private mention you
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -596,4 +967,186 @@ function AnnouncementBlock({ announcement }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchNotficationsByAccount(accountID) {
|
||||||
|
const { masto } = api();
|
||||||
|
return masto.v1.notifications.list({
|
||||||
|
accountID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function NotificationRequestModalButton({ request }) {
|
||||||
|
const { instance } = api();
|
||||||
|
const [uiState, setUIState] = useState('loading');
|
||||||
|
const { account, lastStatus } = request;
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
setShowModal(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!request?.account?.id) return;
|
||||||
|
if (!showModal) return;
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
const notifs = await fetchNotficationsByAccount(request.account.id);
|
||||||
|
setNotifications(notifs || []);
|
||||||
|
setUIState('default');
|
||||||
|
})();
|
||||||
|
}, [showModal, request?.account?.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain4 request-notifications-account"
|
||||||
|
onClick={() => {
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="notification" class="more-insignificant" />{' '}
|
||||||
|
<small>View notifications from @{account.username}</small>{' '}
|
||||||
|
<Icon icon="chevron-down" />
|
||||||
|
</button>
|
||||||
|
{showModal && (
|
||||||
|
<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>Notifications from @{account.username}</b>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{uiState === 'loading' ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
<Loader abrupt />
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
notifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
class="notification-peek"
|
||||||
|
onClick={(e) => {
|
||||||
|
const { target } = e;
|
||||||
|
// If button or links
|
||||||
|
if (
|
||||||
|
e.target.tagName === 'BUTTON' ||
|
||||||
|
e.target.tagName === 'A'
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Notification
|
||||||
|
instance={instance}
|
||||||
|
notification={notification}
|
||||||
|
isStatic
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationRequestButtons({ request, onChange }) {
|
||||||
|
const { masto } = api();
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [requestState, setRequestState] = useState(null); // accept, dismiss
|
||||||
|
const hasRequestState = requestState !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p class="notification-request-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uiState === 'loading' || hasRequestState}
|
||||||
|
onClick={() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v1.notifications.requests
|
||||||
|
.$select(request.id)
|
||||||
|
.accept();
|
||||||
|
setRequestState('accept');
|
||||||
|
setUIState('default');
|
||||||
|
onChange({
|
||||||
|
request,
|
||||||
|
state: 'accept',
|
||||||
|
});
|
||||||
|
showToast(
|
||||||
|
`Notifications from @${request.account.username} will not be filtered from now on.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setUIState('error');
|
||||||
|
console.error(error);
|
||||||
|
showToast(`Unable to accept notification request`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Allow
|
||||||
|
</button>{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uiState === 'loading' || hasRequestState}
|
||||||
|
class="light danger"
|
||||||
|
onClick={() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v1.notifications.requests
|
||||||
|
.$select(request.id)
|
||||||
|
.dismiss();
|
||||||
|
setRequestState('dismiss');
|
||||||
|
setUIState('default');
|
||||||
|
onChange({
|
||||||
|
request,
|
||||||
|
state: 'dismiss',
|
||||||
|
});
|
||||||
|
showToast(
|
||||||
|
`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setUIState('error');
|
||||||
|
console.error(error);
|
||||||
|
showToast(`Unable to dismiss notification request`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<span class="notification-request-states">
|
||||||
|
{uiState === 'loading' ? (
|
||||||
|
<Loader abrupt />
|
||||||
|
) : requestState === 'accept' ? (
|
||||||
|
<Icon
|
||||||
|
icon="check-circle"
|
||||||
|
alt="Accepted"
|
||||||
|
class="notification-accepted"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
requestState === 'dismiss' && (
|
||||||
|
<Icon
|
||||||
|
icon="x-circle"
|
||||||
|
alt="Dismissed"
|
||||||
|
class="notification-dismissed"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(Notifications);
|
export default memo(Notifications);
|
||||||
|
|
|
@ -8,8 +8,8 @@ import Menu2 from '../components/menu2';
|
||||||
import Timeline from '../components/timeline';
|
import Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { filteredItems } from '../utils/filters';
|
import { filteredItems } from '../utils/filters';
|
||||||
import states from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import { saveStatus } from '../utils/states';
|
import supports from '../utils/supports';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
@ -30,10 +30,14 @@ function Public({ local, columnMode, ...props }) {
|
||||||
const publicIterator = useRef();
|
const publicIterator = useRef();
|
||||||
async function fetchPublic(firstLoad) {
|
async function fetchPublic(firstLoad) {
|
||||||
if (firstLoad || !publicIterator.current) {
|
if (firstLoad || !publicIterator.current) {
|
||||||
publicIterator.current = masto.v1.timelines.public.list({
|
const opts = {
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
local: isLocal,
|
local: isLocal || undefined,
|
||||||
});
|
};
|
||||||
|
if (!isLocal && supports('@pixelfed/global-feed')) {
|
||||||
|
opts.remote = true;
|
||||||
|
}
|
||||||
|
publicIterator.current = masto.v1.timelines.public.list(opts);
|
||||||
}
|
}
|
||||||
const results = await publicIterator.current.next();
|
const results = await publicIterator.current.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
|
@ -63,8 +67,9 @@ function Public({ local, columnMode, ...props }) {
|
||||||
})
|
})
|
||||||
.next();
|
.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
value = filteredItems(value, 'public');
|
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
|
||||||
if (value?.length) {
|
if (value?.length && !valueContainsLatestItem) {
|
||||||
|
value = filteredItems(value, 'public');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -48,10 +48,10 @@
|
||||||
a {
|
a {
|
||||||
.icon {
|
.icon {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
transition: transform 0.2s;
|
transition: margin 0.2s;
|
||||||
}
|
}
|
||||||
&:hover .icon {
|
&:hover .icon {
|
||||||
transform: translateX(4px);
|
margin-inline-start: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -101,9 +101,8 @@ ul.link-list.hashtag-list li a {
|
||||||
}
|
}
|
||||||
.search-popover {
|
.search-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 8px;
|
inset-inline-start: 8px;
|
||||||
max-width: calc(100% - 16px);
|
max-width: calc(100% - 16px);
|
||||||
/* right: 8px; */
|
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
box-shadow: 0 4px 24px var(--drop-shadow-color);
|
box-shadow: 0 4px 24px var(--drop-shadow-color);
|
||||||
|
@ -118,7 +117,8 @@ ul.link-list.hashtag-list li a {
|
||||||
}
|
}
|
||||||
.search-popover-item {
|
.search-popover-item {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 8px 16px 8px 8px;
|
padding: 8px;
|
||||||
|
padding-inline-end: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -132,10 +132,15 @@ ul.link-list.hashtag-list li a {
|
||||||
}
|
}
|
||||||
.search-popover-item:is(:focus, .focus) {
|
.search-popover-item:is(:focus, .focus) {
|
||||||
box-shadow: inset 4px 0 0 0 var(--button-bg-color);
|
box-shadow: inset 4px 0 0 0 var(--button-bg-color);
|
||||||
|
:dir(rtl) & {
|
||||||
|
box-shadow: inset -4px 0 0 0 var(--button-bg-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.search-popover-item :is(mark, q) {
|
.search-popover-item :is(mark, q) {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: var(--link-bg-color);
|
background-color: var(--link-bg-color);
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
direction: initial;
|
||||||
}
|
}
|
||||||
.search-popover-item:is(:hover, :focus, .focus) :is(mark, q) {
|
.search-popover-item:is(:hover, :focus, .focus) :is(mark, q) {
|
||||||
background-color: var(--link-bg-color);
|
background-color: var(--link-bg-color);
|
||||||
|
|
|
@ -177,6 +177,7 @@ function Search({ columnMode, ...props }) {
|
||||||
['/', 'Slash'],
|
['/', 'Slash'],
|
||||||
(e) => {
|
(e) => {
|
||||||
searchFormRef.current?.focus?.();
|
searchFormRef.current?.focus?.();
|
||||||
|
searchFormRef.current?.select?.();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
preventDefault: true,
|
preventDefault: true,
|
||||||
|
|
|
@ -36,12 +36,12 @@
|
||||||
border-bottom: var(--hairline-width) solid var(--outline-color);
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
}
|
}
|
||||||
#settings-container section > ul > li > div:last-child {
|
#settings-container section > ul > li > div:last-child {
|
||||||
text-align: right;
|
text-align: end;
|
||||||
}
|
}
|
||||||
#settings-container section > ul > li .sub-section {
|
#settings-container section > ul > li .sub-section {
|
||||||
text-align: left !important;
|
text-align: start !important;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-left: 24px;
|
margin-inline-start: 24px;
|
||||||
}
|
}
|
||||||
#settings-container section > ul > li .sub-section p {
|
#settings-container section > ul > li .sub-section p {
|
||||||
margin-block: 0.5em;
|
margin-block: 0.5em;
|
||||||
|
@ -121,11 +121,11 @@
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 1fr;
|
||||||
|
|
||||||
> span:first-child {
|
> span:first-child {
|
||||||
text-align: left;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
> span:last-child {
|
> span:last-child {
|
||||||
text-align: right;
|
text-align: end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
import showToast from '../utils/show-toast';
|
import showToast from '../utils/show-toast';
|
||||||
import states from '../utils/states';
|
import states from '../utils/states';
|
||||||
import store from '../utils/store';
|
import store from '../utils/store';
|
||||||
|
import supports from '../utils/supports';
|
||||||
|
|
||||||
const DEFAULT_TEXT_SIZE = 16;
|
const DEFAULT_TEXT_SIZE = 16;
|
||||||
const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20];
|
const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20];
|
||||||
|
@ -28,6 +29,7 @@ const {
|
||||||
PHANPY_WEBSITE: WEBSITE,
|
PHANPY_WEBSITE: WEBSITE,
|
||||||
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
|
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
|
||||||
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
||||||
|
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
|
||||||
} = import.meta.env;
|
} = import.meta.env;
|
||||||
|
|
||||||
function Settings({ onClose }) {
|
function Settings({ onClose }) {
|
||||||
|
@ -433,6 +435,37 @@ function Settings({ onClose }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{!!GIPHY_API_KEY && authenticated && (
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={snapStates.settings.composerGIFPicker}
|
||||||
|
onChange={(e) => {
|
||||||
|
states.settings.composerGIFPicker = e.target.checked;
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
GIF Picker for composer
|
||||||
|
</label>
|
||||||
|
<div class="sub-section insignificant">
|
||||||
|
<small>
|
||||||
|
Note: This feature uses external GIF search service, powered
|
||||||
|
by{' '}
|
||||||
|
<a
|
||||||
|
href="https://developers.giphy.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
GIPHY
|
||||||
|
</a>
|
||||||
|
. G-rated (suitable for viewing by all ages), tracking
|
||||||
|
parameters are stripped, referrer information is omitted
|
||||||
|
from requests, but search queries and IP address information
|
||||||
|
will still reach their servers.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{!!IMG_ALT_API_URL && authenticated && (
|
{!!IMG_ALT_API_URL && authenticated && (
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
|
@ -464,6 +497,27 @@ function Settings({ onClose }) {
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{authenticated && supports('@mastodon/grouped-notifications') && (
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={snapStates.settings.groupedNotificationsAlpha}
|
||||||
|
onChange={(e) => {
|
||||||
|
states.settings.groupedNotificationsAlpha =
|
||||||
|
e.target.checked;
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
Server-side grouped notifications
|
||||||
|
</label>
|
||||||
|
<div class="sub-section insignificant">
|
||||||
|
<small>
|
||||||
|
Alpha-stage feature. Potentially improved grouping window
|
||||||
|
but basic grouping logic.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{authenticated && (
|
{authenticated && (
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
|
@ -571,14 +625,18 @@ function Settings({ onClose }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@phanpy
|
@phanpy
|
||||||
</a>
|
</a> (
|
||||||
|
<a href="https://git.dtth.ch/nki/phanpy" target="_blank">
|
||||||
|
DTTH Fork
|
||||||
|
</a>
|
||||||
|
)
|
||||||
<br />
|
<br />
|
||||||
<a
|
<a
|
||||||
href="https://github.com/cheeaun/phanpy"
|
href="https://github.com/cheeaun/phanpy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
Built
|
Original
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
by{' '}
|
by{' '}
|
||||||
<a
|
<a
|
||||||
|
@ -633,10 +691,10 @@ function Settings({ onClose }) {
|
||||||
type="text"
|
type="text"
|
||||||
class="version-string"
|
class="version-string"
|
||||||
readOnly
|
readOnly
|
||||||
size="18" // Manually calculated here
|
size={10 /* build time */ + (1+8) /* commit hash */ + '-dtth'.length}
|
||||||
value={`${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${
|
value={`${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${
|
||||||
__COMMIT_HASH__ ? `.${__COMMIT_HASH__}` : ''
|
__COMMIT_HASH__ ? `.${__COMMIT_HASH__.slice(0, 8)}` : ''
|
||||||
}`}
|
}-dtth`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.target.select();
|
e.target.select();
|
||||||
// Copy to clipboard
|
// Copy to clipboard
|
||||||
|
@ -653,7 +711,7 @@ function Settings({ onClose }) {
|
||||||
<span class="ib insignificant">
|
<span class="ib insignificant">
|
||||||
(
|
(
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
|
href={`https://git.dtth.ch/nki/phanpy/commit/${__COMMIT_HASH__}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
@ -690,9 +748,10 @@ function PushNotificationsSection({ onClose }) {
|
||||||
) {
|
) {
|
||||||
setAllowNotifications(true);
|
setAllowNotifications(true);
|
||||||
const { alerts, policy } = backendSubscription;
|
const { alerts, policy } = backendSubscription;
|
||||||
|
console.log('backendSubscription', backendSubscription);
|
||||||
previousPolicyRef.current = policy;
|
previousPolicyRef.current = policy;
|
||||||
const { elements } = pushFormRef.current;
|
const { elements } = pushFormRef.current;
|
||||||
const policyEl = elements.namedItem(policy);
|
const policyEl = elements.namedItem('policy');
|
||||||
if (policyEl) policyEl.value = policy;
|
if (policyEl) policyEl.value = policy;
|
||||||
// alerts is {}, iterate it
|
// alerts is {}, iterate it
|
||||||
Object.keys(alerts).forEach((alert) => {
|
Object.keys(alerts).forEach((alert) => {
|
||||||
|
@ -721,65 +780,68 @@ function PushNotificationsSection({ onClose }) {
|
||||||
<form
|
<form
|
||||||
ref={pushFormRef}
|
ref={pushFormRef}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
const values = Object.fromEntries(new FormData(pushFormRef.current));
|
setTimeout(() => {
|
||||||
const allowNotifications = !!values['policy-allow'];
|
const values = Object.fromEntries(new FormData(pushFormRef.current));
|
||||||
const params = {
|
const allowNotifications = !!values['policy-allow'];
|
||||||
policy: values.policy,
|
const params = {
|
||||||
data: {
|
data: {
|
||||||
alerts: {
|
policy: values.policy,
|
||||||
mention: !!values.mention,
|
alerts: {
|
||||||
favourite: !!values.favourite,
|
mention: !!values.mention,
|
||||||
reblog: !!values.reblog,
|
favourite: !!values.favourite,
|
||||||
follow: !!values.follow,
|
reblog: !!values.reblog,
|
||||||
follow_request: !!values.followRequest,
|
follow: !!values.follow,
|
||||||
poll: !!values.poll,
|
follow_request: !!values.followRequest,
|
||||||
update: !!values.update,
|
poll: !!values.poll,
|
||||||
status: !!values.status,
|
update: !!values.update,
|
||||||
|
status: !!values.status,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
let alertsCount = 0;
|
let alertsCount = 0;
|
||||||
// Remove false values from data.alerts
|
// Remove false values from data.alerts
|
||||||
// API defaults to false anyway
|
// API defaults to false anyway
|
||||||
Object.keys(params.data.alerts).forEach((key) => {
|
Object.keys(params.data.alerts).forEach((key) => {
|
||||||
if (!params.data.alerts[key]) {
|
if (!params.data.alerts[key]) {
|
||||||
delete params.data.alerts[key];
|
delete params.data.alerts[key];
|
||||||
} else {
|
} else {
|
||||||
alertsCount++;
|
alertsCount++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const policyChanged = previousPolicyRef.current !== params.policy;
|
const policyChanged =
|
||||||
|
previousPolicyRef.current !== params.data.policy;
|
||||||
|
|
||||||
console.log('PN Form', {
|
console.log('PN Form', {
|
||||||
values,
|
values,
|
||||||
allowNotifications: allowNotifications,
|
allowNotifications: allowNotifications,
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allowNotifications && alertsCount > 0) {
|
if (allowNotifications && alertsCount > 0) {
|
||||||
if (policyChanged) {
|
if (policyChanged) {
|
||||||
console.debug('Policy changed.');
|
console.debug('Policy changed.');
|
||||||
removeSubscription()
|
removeSubscription()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
updateSubscription(params);
|
updateSubscription(params);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
console.warn(err);
|
||||||
|
alert('Failed to update subscription. Please try again.');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateSubscription(params).catch((err) => {
|
||||||
console.warn(err);
|
console.warn(err);
|
||||||
alert('Failed to update subscription. Please try again.');
|
alert('Failed to update subscription. Please try again.');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateSubscription(params).catch((err) => {
|
removeSubscription().catch((err) => {
|
||||||
console.warn(err);
|
console.warn(err);
|
||||||
alert('Failed to update subscription. Please try again.');
|
alert('Failed to remove subscription. Please try again.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
}, 100);
|
||||||
removeSubscription().catch((err) => {
|
|
||||||
console.warn(err);
|
|
||||||
alert('Failed to remove subscription. Please try again.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3>Push Notifications (beta)</h3>
|
<h3>Push Notifications (beta)</h3>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
header h1 .deck-back {
|
header h1 .deck-back {
|
||||||
margin-left: -16px;
|
margin-inline-start: -16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-refresh .icon {
|
.button-refresh .icon {
|
||||||
|
@ -23,12 +23,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-heading {
|
.hero-heading {
|
||||||
font-size: var(--text-size);
|
font-size: var(--text-size);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -45,7 +39,7 @@
|
||||||
font-size: 70% !important;
|
font-size: 70% !important;
|
||||||
|
|
||||||
& > .avatar ~ .avatar {
|
& > .avatar ~ .avatar {
|
||||||
margin-left: -4px;
|
margin-inline-start: -4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ancestors-indicator:not([hidden]) {
|
.ancestors-indicator:not([hidden]) {
|
||||||
|
|
|
@ -12,10 +12,10 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import punycode from 'punycode/';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { matchPath, useSearchParams } from 'react-router-dom';
|
import { matchPath, useSearchParams } from 'react-router-dom';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Avatar from '../components/avatar';
|
import Avatar from '../components/avatar';
|
||||||
|
@ -122,7 +122,7 @@ function StatusPage(params) {
|
||||||
}, [showMedia]);
|
}, [showMedia]);
|
||||||
|
|
||||||
const mediaAttachments = mediaStatusID
|
const mediaAttachments = mediaStatusID
|
||||||
? mediaStatus?.mediaAttachments
|
? snapStates.statuses[statusKey(mediaStatusID, instance)]?.mediaAttachments
|
||||||
: heroStatus?.mediaAttachments;
|
: heroStatus?.mediaAttachments;
|
||||||
|
|
||||||
const handleMediaClose = useCallback(() => {
|
const handleMediaClose = useCallback(() => {
|
||||||
|
@ -153,6 +153,18 @@ function StatusPage(params) {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [showMediaOnly]);
|
}, [showMediaOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const $deckContainers = document.querySelectorAll('.deck-container');
|
||||||
|
$deckContainers.forEach(($deckContainer) => {
|
||||||
|
$deckContainer.setAttribute('inert', '');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
$deckContainers.forEach(($deckContainer) => {
|
||||||
|
$deckContainer.removeAttribute('inert');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="deck-backdrop">
|
<div class="deck-backdrop">
|
||||||
{showMedia ? (
|
{showMedia ? (
|
||||||
|
@ -557,7 +569,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
if (!heroStatus) return;
|
if (!heroStatus) return;
|
||||||
const { url } = heroStatus;
|
const { url } = heroStatus;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
return new URL(url).hostname;
|
return URL.parse(url).hostname;
|
||||||
}, [heroStatus]);
|
}, [heroStatus]);
|
||||||
const postSameInstance = useMemo(() => {
|
const postSameInstance = useMemo(() => {
|
||||||
if (!postInstance) return;
|
if (!postInstance) return;
|
||||||
|
@ -972,6 +984,18 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
[statuses, limit, renderStatus],
|
[statuses, limit, renderStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If there's spoiler in hero status, auto-expand it
|
||||||
|
useEffect(() => {
|
||||||
|
let timer = setTimeout(() => {
|
||||||
|
if (!heroStatusRef.current) return;
|
||||||
|
const spoilerButton = heroStatusRef.current.querySelector(
|
||||||
|
'.spoiler-button:not(.spoiling), .spoiler-media-button:not(.spoiling)',
|
||||||
|
);
|
||||||
|
if (spoilerButton) spoilerButton.click();
|
||||||
|
}, 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
|
@ -1208,7 +1232,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
{postInstance ? (
|
{postInstance ? (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
(<b>{postInstance}</b>)
|
(<b>{punycode.toUnicode(postInstance)}</b>)
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
|
@ -1346,6 +1370,8 @@ function SubComments({
|
||||||
const detailsRef = useRef();
|
const detailsRef = useRef();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
function handleScroll(e) {
|
function handleScroll(e) {
|
||||||
|
// NOTE: this scrollLeft works for RTL too
|
||||||
|
// Browsers do the magic for us
|
||||||
e.target.dataset.scrollLeft = e.target.scrollLeft;
|
e.target.dataset.scrollLeft = e.target.scrollLeft;
|
||||||
}
|
}
|
||||||
detailsRef.current?.addEventListener('scroll', handleScroll, {
|
detailsRef.current?.addEventListener('scroll', handleScroll, {
|
||||||
|
|
55
src/pages/trending.css
Normal file
55
src/pages/trending.css
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
#trending-page {
|
||||||
|
.timeline-header-block {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
&.blended {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--bg-faded-color),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 40em) {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-text {
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
display: block;
|
||||||
|
font-weight: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.timeline.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-link-mentions {
|
||||||
|
.status .card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,16 @@
|
||||||
import '../components/links-bar.css';
|
import '../components/links-bar.css';
|
||||||
|
import './trending.css';
|
||||||
|
|
||||||
import { MenuItem } from '@szhsin/react-menu';
|
import { MenuItem } from '@szhsin/react-menu';
|
||||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||||
import { useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import punycode from 'punycode/';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import Link from '../components/link';
|
import Link from '../components/link';
|
||||||
|
import Loader from '../components/loader';
|
||||||
import Menu2 from '../components/menu2';
|
import Menu2 from '../components/menu2';
|
||||||
import RelativeTime from '../components/relative-time';
|
import RelativeTime from '../components/relative-time';
|
||||||
import Timeline from '../components/timeline';
|
import Timeline from '../components/timeline';
|
||||||
|
@ -16,22 +19,46 @@ import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
|
||||||
import { filteredItems } from '../utils/filters';
|
import { filteredItems } from '../utils/filters';
|
||||||
import pmem from '../utils/pmem';
|
import pmem from '../utils/pmem';
|
||||||
import shortenNumber from '../utils/shorten-number';
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import states from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import { saveStatus } from '../utils/states';
|
import supports from '../utils/supports';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
const TREND_CACHE_TIME = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
const fetchLinks = pmem(
|
const fetchLinks = pmem(
|
||||||
(masto) => {
|
(masto) => {
|
||||||
return masto.v1.trends.links.list().next();
|
return masto.v1.trends.links.list().next();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// News last much longer
|
maxAge: TREND_CACHE_TIME,
|
||||||
maxAge: 10 * 60 * 1000, // 10 minutes
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fetchHashtags = pmem(
|
||||||
|
(masto) => {
|
||||||
|
return masto.v1.trends.tags.list().next();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAge: TREND_CACHE_TIME,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function fetchTrendsStatuses(masto) {
|
||||||
|
if (supports('@pixelfed/trending')) {
|
||||||
|
return masto.pixelfed.v2.discover.posts.trending.list({
|
||||||
|
range: 'daily',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return masto.v1.trends.statuses.list({
|
||||||
|
limit: LIMIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchLinkList(masto, params) {
|
||||||
|
return masto.v1.timelines.link.list(params);
|
||||||
|
}
|
||||||
|
|
||||||
function Trending({ columnMode, ...props }) {
|
function Trending({ columnMode, ...props }) {
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const params = columnMode ? {} : useParams();
|
const params = columnMode ? {} : useParams();
|
||||||
|
@ -44,39 +71,45 @@ function Trending({ columnMode, ...props }) {
|
||||||
// const navigate = useNavigate();
|
// const navigate = useNavigate();
|
||||||
const latestItem = useRef();
|
const latestItem = useRef();
|
||||||
|
|
||||||
|
const sameCurrentInstance = instance === currentInstance;
|
||||||
|
|
||||||
const [hashtags, setHashtags] = useState([]);
|
const [hashtags, setHashtags] = useState([]);
|
||||||
const [links, setLinks] = useState([]);
|
const [links, setLinks] = useState([]);
|
||||||
const trendIterator = useRef();
|
const trendIterator = useRef();
|
||||||
async function fetchTrend(firstLoad) {
|
|
||||||
|
async function fetchTrends(firstLoad) {
|
||||||
|
console.log('fetchTrend', firstLoad);
|
||||||
if (firstLoad || !trendIterator.current) {
|
if (firstLoad || !trendIterator.current) {
|
||||||
trendIterator.current = masto.v1.trends.statuses.list({
|
trendIterator.current = fetchTrendsStatuses(masto);
|
||||||
limit: LIMIT,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get hashtags
|
// Get hashtags
|
||||||
try {
|
if (supports('@mastodon/trending-hashtags')) {
|
||||||
const iterator = masto.v1.trends.tags.list();
|
try {
|
||||||
const { value: tags } = await iterator.next();
|
// const iterator = masto.v1.trends.tags.list();
|
||||||
console.log('tags', tags);
|
const { value: tags } = await fetchHashtags(masto);
|
||||||
if (tags?.length) {
|
console.log('tags', tags);
|
||||||
setHashtags(tags);
|
if (tags?.length) {
|
||||||
|
setHashtags(tags);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get links
|
// Get links
|
||||||
try {
|
if (supports('@mastodon/trending-links')) {
|
||||||
const { value } = await fetchLinks(masto, instance);
|
try {
|
||||||
// 4 types available: link, photo, video, rich
|
const { value } = await fetchLinks(masto, instance);
|
||||||
// Only want links for now
|
// 4 types available: link, photo, video, rich
|
||||||
const links = value?.filter?.((link) => link.type === 'link');
|
// Only want links for now
|
||||||
console.log('links', links);
|
const links = value?.filter?.((link) => link.type === 'link');
|
||||||
if (links?.length) {
|
console.log('links', links);
|
||||||
setLinks(links);
|
if (links?.length) {
|
||||||
|
setLinks(links);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const results = await trendIterator.current.next();
|
const results = await trendIterator.current.next();
|
||||||
|
@ -97,6 +130,53 @@ function Trending({ columnMode, ...props }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link mentions
|
||||||
|
// https://github.com/mastodon/mastodon/pull/30381
|
||||||
|
const [currentLinkMentionsLoading, setCurrentLinkMentionsLoading] =
|
||||||
|
useState(false);
|
||||||
|
const currentLinkMentionsIterator = useRef();
|
||||||
|
const [currentLink, setCurrentLink] = useState(null);
|
||||||
|
const hasCurrentLink = !!currentLink;
|
||||||
|
const currentLinkRef = useRef();
|
||||||
|
const supportsTrendingLinkPosts =
|
||||||
|
sameCurrentInstance && supports('@mastodon/trending-hashtags');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentLink && currentLinkRef.current) {
|
||||||
|
currentLinkRef.current.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'center',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentLink]);
|
||||||
|
|
||||||
|
const prevCurrentLink = useRef();
|
||||||
|
async function fetchLinkMentions(firstLoad) {
|
||||||
|
if (firstLoad || !currentLinkMentionsIterator.current) {
|
||||||
|
setCurrentLinkMentionsLoading(true);
|
||||||
|
currentLinkMentionsIterator.current = fetchLinkList(masto, {
|
||||||
|
url: currentLink,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevCurrentLink.current = currentLink;
|
||||||
|
const results = await currentLinkMentionsIterator.current.next();
|
||||||
|
let { value } = results;
|
||||||
|
if (value?.length) {
|
||||||
|
value = filteredItems(value, 'public');
|
||||||
|
value.forEach((item) => {
|
||||||
|
saveStatus(item, instance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (prevCurrentLink.current === currentLink) {
|
||||||
|
setCurrentLinkMentionsLoading(false);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...results,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
try {
|
try {
|
||||||
const results = await masto.v1.trends.statuses
|
const results = await masto.v1.trends.statuses
|
||||||
|
@ -129,7 +209,7 @@ function Trending({ columnMode, ...props }) {
|
||||||
const total = history.reduce((acc, cur) => acc + +cur.uses, 0);
|
const total = history.reduce((acc, cur) => acc + +cur.uses, 0);
|
||||||
return (
|
return (
|
||||||
<Link to={`/${instance}/t/${name}`} key={name}>
|
<Link to={`/${instance}/t/${name}`} key={name}>
|
||||||
<span>
|
<span dir="auto">
|
||||||
<span class="more-insignificant">#</span>
|
<span class="more-insignificant">#</span>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
|
@ -161,9 +241,11 @@ function Trending({ columnMode, ...props }) {
|
||||||
url,
|
url,
|
||||||
width,
|
width,
|
||||||
} = link;
|
} = link;
|
||||||
const domain = new URL(url).hostname
|
const domain = punycode.toUnicode(
|
||||||
.replace(/^www\./, '')
|
URL.parse(url)
|
||||||
.replace(/\/$/, '');
|
.hostname.replace(/^www\./, '')
|
||||||
|
.replace(/\/$/, ''),
|
||||||
|
);
|
||||||
let accentColor;
|
let accentColor;
|
||||||
if (blurhash) {
|
if (blurhash) {
|
||||||
const averageColor = getBlurHashAverageColor(blurhash);
|
const averageColor = getBlurHashAverageColor(blurhash);
|
||||||
|
@ -176,67 +258,134 @@ function Trending({ columnMode, ...props }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<div key={url}>
|
||||||
key={url}
|
<a
|
||||||
href={url}
|
ref={currentLink === url ? currentLinkRef : null}
|
||||||
target="_blank"
|
href={url}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
style={
|
rel="noopener noreferrer"
|
||||||
accentColor
|
class={
|
||||||
? {
|
hasCurrentLink
|
||||||
'--accent-color': `rgb(${accentColor.join(',')})`,
|
? currentLink === url
|
||||||
'--accent-alpha-color': `rgba(${accentColor.join(
|
? 'active'
|
||||||
',',
|
: 'inactive'
|
||||||
)}, 0.4)`,
|
: ''
|
||||||
}
|
}
|
||||||
: {}
|
style={
|
||||||
}
|
accentColor
|
||||||
>
|
? {
|
||||||
<article>
|
'--accent-color': `rgb(${accentColor.join(',')})`,
|
||||||
<figure>
|
'--accent-alpha-color': `rgba(${accentColor.join(
|
||||||
<img
|
',',
|
||||||
src={image}
|
)}, 0.4)`,
|
||||||
alt={imageDescription}
|
}
|
||||||
width={width}
|
: {}
|
||||||
height={height}
|
}
|
||||||
loading="lazy"
|
>
|
||||||
/>
|
<article>
|
||||||
</figure>
|
<figure>
|
||||||
<div class="article-body">
|
<img
|
||||||
<header>
|
src={image}
|
||||||
<div class="article-meta">
|
alt={imageDescription}
|
||||||
<span class="domain">{domain}</span>{' '}
|
width={width}
|
||||||
{!!publishedAt && <>· </>}
|
height={height}
|
||||||
{!!publishedAt && (
|
loading="lazy"
|
||||||
<>
|
/>
|
||||||
<RelativeTime
|
</figure>
|
||||||
datetime={publishedAt}
|
<div class="article-body">
|
||||||
format="micro"
|
<header>
|
||||||
/>
|
<div class="article-meta">
|
||||||
</>
|
<span class="domain">{domain}</span>{' '}
|
||||||
|
{!!publishedAt && <>· </>}
|
||||||
|
{!!publishedAt && (
|
||||||
|
<>
|
||||||
|
<RelativeTime
|
||||||
|
datetime={publishedAt}
|
||||||
|
format="micro"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!!title && (
|
||||||
|
<h1
|
||||||
|
class="title"
|
||||||
|
lang={language}
|
||||||
|
dir="auto"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
)}
|
)}
|
||||||
</div>
|
</header>
|
||||||
{!!title && (
|
{!!description && (
|
||||||
<h1 class="title" lang={language} dir="auto">
|
<p
|
||||||
{title}
|
class="description"
|
||||||
</h1>
|
lang={language}
|
||||||
|
dir="auto"
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</div>
|
||||||
{!!description && (
|
</article>
|
||||||
<p class="description" lang={language} dir="auto">
|
</a>
|
||||||
{description}
|
{supportsTrendingLinkPosts && (
|
||||||
</p>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</div>
|
class="small plain4 block"
|
||||||
</article>
|
onClick={() => {
|
||||||
</a>
|
setCurrentLink(url);
|
||||||
|
}}
|
||||||
|
disabled={url === currentLink}
|
||||||
|
>
|
||||||
|
<Icon icon="comment2" /> <span>Mentions</span>{' '}
|
||||||
|
<Icon icon="chevron-down" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{supportsTrendingLinkPosts && !!links.length && (
|
||||||
|
<div
|
||||||
|
class={`timeline-header-block ${hasCurrentLink ? 'blended' : ''}`}
|
||||||
|
>
|
||||||
|
{hasCurrentLink ? (
|
||||||
|
<>
|
||||||
|
<div style={{ width: 50, flexShrink: 0, textAlign: 'center' }}>
|
||||||
|
{currentLinkMentionsLoading ? (
|
||||||
|
<Loader abrupt />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="light"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentLink(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Showing posts mentioning{' '}
|
||||||
|
<span class="link-text">
|
||||||
|
{currentLink
|
||||||
|
.replace(/^https?:\/\/(www\.)?/i, '')
|
||||||
|
.replace(/\/$/, '')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p class="insignificant">Trending posts</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [hashtags, links]);
|
}, [hashtags, links, currentLink, currentLinkMentionsLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Timeline
|
<Timeline
|
||||||
|
@ -252,8 +401,8 @@ function Trending({ columnMode, ...props }) {
|
||||||
instance={instance}
|
instance={instance}
|
||||||
emptyText="No trending posts."
|
emptyText="No trending posts."
|
||||||
errorText="Unable to load posts"
|
errorText="Unable to load posts"
|
||||||
fetchItems={fetchTrend}
|
fetchItems={hasCurrentLink ? fetchLinkMentions : fetchTrends}
|
||||||
checkForUpdates={checkForUpdates}
|
checkForUpdates={hasCurrentLink ? undefined : checkForUpdates}
|
||||||
checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes
|
checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes
|
||||||
useItemID
|
useItemID
|
||||||
headerStart={<></>}
|
headerStart={<></>}
|
||||||
|
@ -261,6 +410,9 @@ function Trending({ columnMode, ...props }) {
|
||||||
// allowFilters
|
// allowFilters
|
||||||
filterContext="public"
|
filterContext="public"
|
||||||
timelineStart={TimelineStart}
|
timelineStart={TimelineStart}
|
||||||
|
refresh={currentLink}
|
||||||
|
clearWhenRefresh
|
||||||
|
view={hasCurrentLink ? 'link-mentions' : undefined}
|
||||||
headerEnd={
|
headerEnd={
|
||||||
<Menu2
|
<Menu2
|
||||||
portal
|
portal
|
||||||
|
|
|
@ -140,7 +140,7 @@
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
inset-inline-start: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
|
@ -153,8 +153,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#why-container {
|
#why-container {
|
||||||
padding: 32px 32px 32px 8px;
|
padding: 32px;
|
||||||
margin-left: 50%;
|
padding-inline-start: 8px;
|
||||||
|
margin-inline-start: 50%;
|
||||||
|
|
||||||
/* overflow: auto;
|
/* overflow: auto;
|
||||||
mask-image: linear-gradient(to top, transparent 16px, black 64px); */
|
mask-image: linear-gradient(to top, transparent 16px, black 64px); */
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue