commit
27a999f733
45
README.md
45
README.md
|
@ -9,6 +9,8 @@ Phanpy
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
**🗣️ Pronunciation**: [`/fænpi/`](https://ythi.net/how-do-you-pronounce/phanpy/english/) ([`FAN-pee`](https://www.smogon.com/forums/threads/the-official-name-pronunciation-guide.3474941/)) [🔊 Listen](https://www.youtube.com/watch?v=DIUbWe-ysJI)
|
||||||
|
|
||||||
This is an alternative web client for [Mastodon](https://joinmastodon.org/).
|
This is an alternative web client for [Mastodon](https://joinmastodon.org/).
|
||||||
|
|
||||||
- 🏢 **Production**: https://phanpy.social
|
- 🏢 **Production**: https://phanpy.social
|
||||||
|
@ -23,13 +25,7 @@ This is an alternative web client for [Mastodon](https://joinmastodon.org/).
|
||||||
|
|
||||||
🐘 Follow [@phanpy on Mastodon](https://hachyderm.io/@phanpy) for updates ✨
|
🐘 Follow [@phanpy on Mastodon](https://hachyderm.io/@phanpy) for updates ✨
|
||||||
|
|
||||||
Everything is designed and engineered for my own use case, following my taste and vision. This is a personal side project for me to learn about Mastodon and experiment with new UI/UX ideas.
|
Everything is designed and engineered following my taste and vision. This is a personal side project for me to learn about Mastodon and experiment with new UI/UX ideas.
|
||||||
|
|
||||||
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
|
|
||||||
|
|
||||||
**🐘 This is an early ALPHA project. Many features are missing, many bugs are present. Please report issues as detailed as possible. Thanks 🙏**
|
|
||||||
|
|
||||||
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
@ -37,7 +33,7 @@ Everything is designed and engineered for my own use case, following my taste an
|
||||||
- 🪟 Compose window pop-out/in
|
- 🪟 Compose window pop-out/in
|
||||||
- 🌗 Light/dark/auto theme
|
- 🌗 Light/dark/auto theme
|
||||||
- 🔔 Grouped notifications
|
- 🔔 Grouped notifications
|
||||||
- 🪺 Nested replies view
|
- 🪺 Nested comments thread
|
||||||
- 📬 Unsent draft recovery
|
- 📬 Unsent draft recovery
|
||||||
- 🎠 Boosts Carousel™️
|
- 🎠 Boosts Carousel™️
|
||||||
- ⚡ Shortcuts™️ with view modes like multi-column or tab bar
|
- ⚡ Shortcuts™️ with view modes like multi-column or tab bar
|
||||||
|
@ -51,6 +47,33 @@ Everything is designed and engineered for my own use case, following my taste an
|
||||||
- **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.
|
||||||
|
|
||||||
|
## Subtle UI implementations
|
||||||
|
|
||||||
|
### User name display
|
||||||
|
|
||||||
|
![User name display](readme-assets/user-name-display.jpg)
|
||||||
|
|
||||||
|
- On the timeline, the user name is displayed as `[NAME] @[username]`.
|
||||||
|
- For the `@[username]`, always exclude the instance domain name.
|
||||||
|
- If the `[NAME]` *looks the same* as the `@[username]`, then the `@[username]` is excluded as well.
|
||||||
|
|
||||||
|
### Boosts Carousel
|
||||||
|
|
||||||
|
![Boosts Carousel](readme-assets/boosts-carousel.jpg)
|
||||||
|
|
||||||
|
- From the fetched posts (e.g. 20 posts per fetch), if number of boosts are more than quarter of total posts or more than 3 consecutive boosts, boosts carousel UI will be triggered.
|
||||||
|
- If number of boosts are more than 3 quarters of total posts, boosts carousel UI will be slotted at the end of total posts fetched (per "page").
|
||||||
|
- Else, boosts carousel UI will be slotted in between the posts.
|
||||||
|
|
||||||
|
### Thread number badge (e.g. Thread 1/X)
|
||||||
|
|
||||||
|
![Thread number badge](readme-assets/thread-number-badge.jpg)
|
||||||
|
|
||||||
|
- Check every post for `inReplyToId` from cache or additional API requests, until the root post is found.
|
||||||
|
- If root post is found, badge will show the index number of the post in the thread.
|
||||||
|
- Limit up to 3 API requests as the root post may be very old or the thread is super long.
|
||||||
|
- If index number couldn't be found, badge will fallback to showing `Thread` without the number.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Prerequisites: Node.js 18+
|
Prerequisites: Node.js 18+
|
||||||
|
@ -63,6 +86,12 @@ Prerequisites: Node.js 18+
|
||||||
- requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set
|
- requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set
|
||||||
- `npm run sourcemap` - Run `source-map-explorer` on the production build
|
- `npm run sourcemap` - Run `source-map-explorer` on the production build
|
||||||
|
|
||||||
|
## Self-hosting
|
||||||
|
|
||||||
|
This is a **pure static web app**. You can host it anywhere you want. Build it by running `npm run build` and serve the `dist` folder.
|
||||||
|
|
||||||
|
Try search for "how to self-host static sites" as there are many ways to do it.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- [Vite](https://vitejs.dev/) - Build tool
|
- [Vite](https://vitejs.dev/) - Build tool
|
||||||
|
|
116
package-lock.json
generated
116
package-lock.json
generated
|
@ -10,16 +10,16 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-localematcher": "~0.2.32",
|
"@formatjs/intl-localematcher": "~0.2.32",
|
||||||
"@github/text-expander-element": "~2.3.0",
|
"@github/text-expander-element": "~2.3.0",
|
||||||
"@iconify-icons/mingcute": "~1.2.4",
|
"@iconify-icons/mingcute": "~1.2.5",
|
||||||
"@justinribeiro/lite-youtube": "~1.5.0",
|
"@justinribeiro/lite-youtube": "~1.5.0",
|
||||||
"@szhsin/react-menu": "~3.5.2",
|
"@szhsin/react-menu": "~3.5.3",
|
||||||
"dayjs": "~1.11.7",
|
"dayjs": "~1.11.7",
|
||||||
"dayjs-twitter": "~0.5.0",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
"fast-deep-equal": "~3.1.3",
|
"fast-deep-equal": "~3.1.3",
|
||||||
"idb-keyval": "~6.2.0",
|
"idb-keyval": "~6.2.1",
|
||||||
"just-debounce-it": "~3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"masto": "~5.11.2",
|
"masto": "~5.11.3",
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
"p-retry": "~5.1.2",
|
"p-retry": "~5.1.2",
|
||||||
"p-throttle": "~5.0.0",
|
"p-throttle": "~5.0.0",
|
||||||
|
@ -33,7 +33,7 @@
|
||||||
"toastify-js": "~1.12.0",
|
"toastify-js": "~1.12.0",
|
||||||
"uid": "~2.0.2",
|
"uid": "~2.0.2",
|
||||||
"use-debounce": "~9.0.4",
|
"use-debounce": "~9.0.4",
|
||||||
"use-long-press": "~3.1.0",
|
"use-long-press": "~3.1.3",
|
||||||
"use-resize-observer": "~9.1.0",
|
"use-resize-observer": "~9.1.0",
|
||||||
"valtio": "1.9.0"
|
"valtio": "1.9.0"
|
||||||
},
|
},
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
"postcss-dark-theme-class": "~0.7.3",
|
"postcss-dark-theme-class": "~0.7.3",
|
||||||
"postcss-preset-env": "~8.3.2",
|
"postcss-preset-env": "~8.3.2",
|
||||||
"twitter-text": "~3.1.0",
|
"twitter-text": "~3.1.0",
|
||||||
"vite": "~4.3.3",
|
"vite": "~4.3.5",
|
||||||
"vite-plugin-generate-file": "~0.0.4",
|
"vite-plugin-generate-file": "~0.0.4",
|
||||||
"vite-plugin-html-config": "~1.0.11",
|
"vite-plugin-html-config": "~1.0.11",
|
||||||
"vite-plugin-pwa": "~0.14.7",
|
"vite-plugin-pwa": "~0.14.7",
|
||||||
|
@ -2667,9 +2667,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@iconify-icons/mingcute": {
|
"node_modules/@iconify-icons/mingcute": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.5.tgz",
|
||||||
"integrity": "sha512-4aaWYa6GxSdYmJg8iBVx6VDuKUcTDEbio929+GrswoxfyTsPUkOOgw2wffUDHjE3JDUAnrWj9teQTnBkFm7Gyg==",
|
"integrity": "sha512-fTzhb0TVYuD89Do9sUR9mu2RYZ2XGOyOxzoZL2W53R9w+j0myuo0bnRpoGlbnXPV+/N+P8vUBJEsQRWvmEPQfw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/types": "*"
|
"@iconify/types": "*"
|
||||||
}
|
}
|
||||||
|
@ -2962,9 +2962,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@szhsin/react-menu": {
|
"node_modules/@szhsin/react-menu": {
|
||||||
"version": "3.5.2",
|
"version": "3.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.3.tgz",
|
||||||
"integrity": "sha512-eR7dzDBrwlt9RSgGmLXjfA1Rd5tYqD5mnqjQgZJysf3Jt3vBPkrbDT1oW21nLpfUCkyUQOuZ38n2IdhWl9KkzQ==",
|
"integrity": "sha512-jxo8oaRwxmVjUzkyOi/ZJiXaZiuFPMIxFzyJdUKfnhBLYiEOVTU9M2CiPuEkirILoareR2GJj2K3y8a81CBPlw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-transition-state": "^1.1.5"
|
"react-transition-state": "^1.1.5"
|
||||||
|
@ -4293,12 +4293,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/idb-keyval": {
|
"node_modules/idb-keyval": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
|
||||||
"integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==",
|
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
|
||||||
"dependencies": {
|
|
||||||
"safari-14-idb-fix": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/inflight": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
|
@ -4860,9 +4857,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/masto": {
|
"node_modules/masto": {
|
||||||
"version": "5.11.2",
|
"version": "5.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/masto/-/masto-5.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/masto/-/masto-5.11.3.tgz",
|
||||||
"integrity": "sha512-1F2O4itZXCchqmGh/Dor+AfgyxKGfUrquLK81H+Adw9vs5BoOtdNThuFhXf1m2enXzbVEvJUwxBVd82s2x5FSg==",
|
"integrity": "sha512-GtSnrqm5fHPaaU0iwag4LCmvpp82rDng6yOZinmOJHHlUfo6Gnq5QY6x3lJCxCnsPIXpTu1yaX42bWrSQyoQPA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mastojs/ponyfills": "^1.0.4",
|
"@mastojs/ponyfills": "^1.0.4",
|
||||||
"change-case": "^4.1.2",
|
"change-case": "^4.1.2",
|
||||||
|
@ -4870,7 +4867,7 @@
|
||||||
"isomorphic-ws": "^5.0.0",
|
"isomorphic-ws": "^5.0.0",
|
||||||
"qs": "^6.11.0",
|
"qs": "^6.11.0",
|
||||||
"semver": "^7.3.7",
|
"semver": "^7.3.7",
|
||||||
"ws": "^8.12.0"
|
"ws": "^8.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/masto/node_modules/semver": {
|
"node_modules/masto/node_modules/semver": {
|
||||||
|
@ -6198,11 +6195,6 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safari-14-idb-fix": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog=="
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
@ -6739,9 +6731,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-long-press": {
|
"node_modules/use-long-press": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.1.3.tgz",
|
||||||
"integrity": "sha512-SSCCgsIlGql/gWRf5v/5CoWxUkSccFuLiMO2tjggUdf0qt5FGb/TD1l0aJ2j+G3J0BT2a7jsJQIDsmUiUuhRTg==",
|
"integrity": "sha512-RAK+i3mIPAFL10Q9wVqfzjDTIg/oXSB60c+bbwNkc1GzIWNF7UfRydJ2VX8IQ+yG2eptzEuWb1CmJc2UNu6fOg==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
|
@ -6793,9 +6785,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.3.5.tgz",
|
||||||
"integrity": "sha512-MwFlLBO4udZXd+VBcezo3u8mC77YQk+ik+fbc0GZWGgzfbPP+8Kf0fldhARqvSYmtIWoAJ5BXPClUbMTlqFxrA==",
|
"integrity": "sha512-0gEnL9wiRFxgz40o/i/eTBwm+NEbpUeTWhzKrZDSdKm6nplj+z4lKz8ANDgildxHm47Vg8EUia0aicKbawUVVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.17.5",
|
"esbuild": "^0.17.5",
|
||||||
|
@ -7334,9 +7326,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.12.0",
|
"version": "8.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
|
||||||
"integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==",
|
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
},
|
},
|
||||||
|
@ -8964,9 +8956,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@iconify-icons/mingcute": {
|
"@iconify-icons/mingcute": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.5.tgz",
|
||||||
"integrity": "sha512-4aaWYa6GxSdYmJg8iBVx6VDuKUcTDEbio929+GrswoxfyTsPUkOOgw2wffUDHjE3JDUAnrWj9teQTnBkFm7Gyg==",
|
"integrity": "sha512-fTzhb0TVYuD89Do9sUR9mu2RYZ2XGOyOxzoZL2W53R9w+j0myuo0bnRpoGlbnXPV+/N+P8vUBJEsQRWvmEPQfw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@iconify/types": "*"
|
"@iconify/types": "*"
|
||||||
}
|
}
|
||||||
|
@ -9198,9 +9190,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@szhsin/react-menu": {
|
"@szhsin/react-menu": {
|
||||||
"version": "3.5.2",
|
"version": "3.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.3.tgz",
|
||||||
"integrity": "sha512-eR7dzDBrwlt9RSgGmLXjfA1Rd5tYqD5mnqjQgZJysf3Jt3vBPkrbDT1oW21nLpfUCkyUQOuZ38n2IdhWl9KkzQ==",
|
"integrity": "sha512-jxo8oaRwxmVjUzkyOi/ZJiXaZiuFPMIxFzyJdUKfnhBLYiEOVTU9M2CiPuEkirILoareR2GJj2K3y8a81CBPlw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react-transition-state": "^1.1.5"
|
"react-transition-state": "^1.1.5"
|
||||||
|
@ -10204,12 +10196,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"idb-keyval": {
|
"idb-keyval": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
|
||||||
"integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==",
|
"integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
|
||||||
"requires": {
|
|
||||||
"safari-14-idb-fix": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"inflight": {
|
"inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
|
@ -10621,9 +10610,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"masto": {
|
"masto": {
|
||||||
"version": "5.11.2",
|
"version": "5.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/masto/-/masto-5.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/masto/-/masto-5.11.3.tgz",
|
||||||
"integrity": "sha512-1F2O4itZXCchqmGh/Dor+AfgyxKGfUrquLK81H+Adw9vs5BoOtdNThuFhXf1m2enXzbVEvJUwxBVd82s2x5FSg==",
|
"integrity": "sha512-GtSnrqm5fHPaaU0iwag4LCmvpp82rDng6yOZinmOJHHlUfo6Gnq5QY6x3lJCxCnsPIXpTu1yaX42bWrSQyoQPA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@mastojs/ponyfills": "^1.0.4",
|
"@mastojs/ponyfills": "^1.0.4",
|
||||||
"change-case": "^4.1.2",
|
"change-case": "^4.1.2",
|
||||||
|
@ -10631,7 +10620,7 @@
|
||||||
"isomorphic-ws": "^5.0.0",
|
"isomorphic-ws": "^5.0.0",
|
||||||
"qs": "^6.11.0",
|
"qs": "^6.11.0",
|
||||||
"semver": "^7.3.7",
|
"semver": "^7.3.7",
|
||||||
"ws": "^8.12.0"
|
"ws": "^8.13.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": {
|
"semver": {
|
||||||
|
@ -11455,11 +11444,6 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"safari-14-idb-fix": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog=="
|
|
||||||
},
|
|
||||||
"safe-buffer": {
|
"safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
@ -11857,9 +11841,9 @@
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"use-long-press": {
|
"use-long-press": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.1.3.tgz",
|
||||||
"integrity": "sha512-SSCCgsIlGql/gWRf5v/5CoWxUkSccFuLiMO2tjggUdf0qt5FGb/TD1l0aJ2j+G3J0BT2a7jsJQIDsmUiUuhRTg==",
|
"integrity": "sha512-RAK+i3mIPAFL10Q9wVqfzjDTIg/oXSB60c+bbwNkc1GzIWNF7UfRydJ2VX8IQ+yG2eptzEuWb1CmJc2UNu6fOg==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"use-resize-observer": {
|
"use-resize-observer": {
|
||||||
|
@ -11892,9 +11876,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vite": {
|
"vite": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-4.3.5.tgz",
|
||||||
"integrity": "sha512-MwFlLBO4udZXd+VBcezo3u8mC77YQk+ik+fbc0GZWGgzfbPP+8Kf0fldhARqvSYmtIWoAJ5BXPClUbMTlqFxrA==",
|
"integrity": "sha512-0gEnL9wiRFxgz40o/i/eTBwm+NEbpUeTWhzKrZDSdKm6nplj+z4lKz8ANDgildxHm47Vg8EUia0aicKbawUVVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"esbuild": "^0.17.5",
|
"esbuild": "^0.17.5",
|
||||||
|
@ -12314,9 +12298,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ws": {
|
"ws": {
|
||||||
"version": "8.12.0",
|
"version": "8.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
|
||||||
"integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==",
|
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"yallist": {
|
"yallist": {
|
||||||
|
|
12
package.json
12
package.json
|
@ -12,16 +12,16 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-localematcher": "~0.2.32",
|
"@formatjs/intl-localematcher": "~0.2.32",
|
||||||
"@github/text-expander-element": "~2.3.0",
|
"@github/text-expander-element": "~2.3.0",
|
||||||
"@iconify-icons/mingcute": "~1.2.4",
|
"@iconify-icons/mingcute": "~1.2.5",
|
||||||
"@justinribeiro/lite-youtube": "~1.5.0",
|
"@justinribeiro/lite-youtube": "~1.5.0",
|
||||||
"@szhsin/react-menu": "~3.5.2",
|
"@szhsin/react-menu": "~3.5.3",
|
||||||
"dayjs": "~1.11.7",
|
"dayjs": "~1.11.7",
|
||||||
"dayjs-twitter": "~0.5.0",
|
"dayjs-twitter": "~0.5.0",
|
||||||
"fast-blurhash": "~1.1.2",
|
"fast-blurhash": "~1.1.2",
|
||||||
"fast-deep-equal": "~3.1.3",
|
"fast-deep-equal": "~3.1.3",
|
||||||
"idb-keyval": "~6.2.0",
|
"idb-keyval": "~6.2.1",
|
||||||
"just-debounce-it": "~3.2.0",
|
"just-debounce-it": "~3.2.0",
|
||||||
"masto": "~5.11.2",
|
"masto": "~5.11.3",
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
"p-retry": "~5.1.2",
|
"p-retry": "~5.1.2",
|
||||||
"p-throttle": "~5.0.0",
|
"p-throttle": "~5.0.0",
|
||||||
|
@ -35,7 +35,7 @@
|
||||||
"toastify-js": "~1.12.0",
|
"toastify-js": "~1.12.0",
|
||||||
"uid": "~2.0.2",
|
"uid": "~2.0.2",
|
||||||
"use-debounce": "~9.0.4",
|
"use-debounce": "~9.0.4",
|
||||||
"use-long-press": "~3.1.0",
|
"use-long-press": "~3.1.3",
|
||||||
"use-resize-observer": "~9.1.0",
|
"use-resize-observer": "~9.1.0",
|
||||||
"valtio": "1.9.0"
|
"valtio": "1.9.0"
|
||||||
},
|
},
|
||||||
|
@ -46,7 +46,7 @@
|
||||||
"postcss-dark-theme-class": "~0.7.3",
|
"postcss-dark-theme-class": "~0.7.3",
|
||||||
"postcss-preset-env": "~8.3.2",
|
"postcss-preset-env": "~8.3.2",
|
||||||
"twitter-text": "~3.1.0",
|
"twitter-text": "~3.1.0",
|
||||||
"vite": "~4.3.3",
|
"vite": "~4.3.5",
|
||||||
"vite-plugin-generate-file": "~0.0.4",
|
"vite-plugin-generate-file": "~0.0.4",
|
||||||
"vite-plugin-html-config": "~1.0.11",
|
"vite-plugin-html-config": "~1.0.11",
|
||||||
"vite-plugin-pwa": "~0.14.7",
|
"vite-plugin-pwa": "~0.14.7",
|
||||||
|
|
BIN
readme-assets/boosts-carousel.jpg
Normal file
BIN
readme-assets/boosts-carousel.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 92 KiB |
BIN
readme-assets/thread-number-badge.jpg
Normal file
BIN
readme-assets/thread-number-badge.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
BIN
readme-assets/user-name-display.jpg
Normal file
BIN
readme-assets/user-name-display.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
42
src/app.css
42
src/app.css
|
@ -256,6 +256,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
.timeline.contextual > li.descendant:not(.thread) > .status-link {
|
.timeline.contextual > li.descendant:not(.thread) > .status-link {
|
||||||
padding-left: 40px;
|
padding-left: 40px;
|
||||||
}
|
}
|
||||||
|
.timeline.contextual .replies[data-scroll-left]:not([data-scroll-left='0']) {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
box-shadow: inset 0 -3px var(--comment-line-color),
|
||||||
|
inset 0 3px var(--comment-line-color);
|
||||||
|
}
|
||||||
.timeline.contextual .replies[data-comments-level='4'] {
|
.timeline.contextual .replies[data-comments-level='4'] {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
@ -272,7 +277,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
.replies[data-comments-level='4']
|
.replies[data-comments-level='4']
|
||||||
.replies[data-comments-level-overflow='true']
|
.replies[data-comments-level-overflow='true']
|
||||||
.status {
|
.status {
|
||||||
min-width: min(15em, 75vw);
|
min-width: min(20em, 80vw);
|
||||||
}
|
}
|
||||||
.timeline.contextual
|
.timeline.contextual
|
||||||
> li.descendant.thread
|
> li.descendant.thread
|
||||||
|
@ -432,7 +437,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
.timeline.contextual > li .replies {
|
.timeline.contextual > li .replies {
|
||||||
margin-top: -12px;
|
margin-top: -12px;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies :is(ul, li) {
|
.timeline.contextual > li .replies :is(ul, li):not(.content *) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
@ -478,10 +483,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
.timeline.contextual > li .replies .replies-summary[hidden] {
|
.timeline.contextual > li .replies .replies-summary[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li {
|
.timeline.contextual > li .replies li:not(.content li) {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li {
|
.timeline.contextual > li .replies li:not(.content li) {
|
||||||
--line-start: calc(
|
--line-start: calc(
|
||||||
var(--thread-start) + var(--line-margin-end) * var(--comments-level)
|
var(--thread-start) + var(--line-margin-end) * var(--comments-level)
|
||||||
);
|
);
|
||||||
|
@ -503,7 +508,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
.timeline.contextual > li .replies .replies .replies li {
|
.timeline.contextual > li .replies .replies .replies li {
|
||||||
--line-start: calc(var(--thread-start) + (var(--line-margin-end) * 3));
|
--line-start: calc(var(--thread-start) + (var(--line-margin-end) * 3));
|
||||||
} */
|
} */
|
||||||
.timeline.contextual > li.thread .replies li {
|
.timeline.contextual > li.thread .replies li:not(.content li) {
|
||||||
--line-start: calc(
|
--line-start: calc(
|
||||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
(var(--line-margin-end) * (var(--comments-level) - 1))
|
(var(--line-margin-end) * (var(--comments-level) - 1))
|
||||||
|
@ -521,10 +526,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
(var(--line-margin-end) * 2)
|
(var(--line-margin-end) * 2)
|
||||||
);
|
);
|
||||||
} */
|
} */
|
||||||
.timeline.contextual > li .replies li:last-child {
|
.timeline.contextual > li .replies li:not(.content li):last-child {
|
||||||
background-size: 100% 20px;
|
background-size: 100% 20px;
|
||||||
}
|
}
|
||||||
.timeline.contextual > li .replies li:before {
|
.timeline.contextual > li .replies li:not(.content li):before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
|
@ -547,7 +552,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
|
var(--thread-start) + var(--line-margin-end) + (var(--line-margin-end) * 2)
|
||||||
);
|
);
|
||||||
} */
|
} */
|
||||||
.timeline.contextual > li.thread .replies li:before {
|
.timeline.contextual > li.thread .replies li:not(.content li):before {
|
||||||
--line-start: calc(
|
--line-start: calc(
|
||||||
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) +
|
||||||
(var(--line-margin-end) * (var(--comments-level) - 1))
|
(var(--line-margin-end) * (var(--comments-level) - 1))
|
||||||
|
@ -709,7 +714,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
color: var(--carousel-color);
|
color: var(--carousel-color);
|
||||||
text-shadow: 0 1px var(--bg-color);
|
text-shadow: 0 1px var(--bg-color);
|
||||||
}
|
}
|
||||||
.status-carousel ul {
|
.status-carousel > ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
@ -721,7 +726,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
counter-reset: index;
|
counter-reset: index;
|
||||||
}
|
}
|
||||||
.status-carousel ul > li {
|
.status-carousel > ul > li {
|
||||||
scroll-snap-align: center;
|
scroll-snap-align: center;
|
||||||
scroll-snap-stop: always;
|
scroll-snap-stop: always;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
@ -736,8 +741,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
counter-increment: index;
|
counter-increment: index;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.status-carousel > ul > li:is(:empty, :has(> a:empty)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@media (hover: hover) or (pointer: fine) or (min-width: 40em) {
|
@media (hover: hover) or (pointer: fine) or (min-width: 40em) {
|
||||||
.status-carousel ul {
|
.status-carousel > ul {
|
||||||
scroll-snap-type: none;
|
scroll-snap-type: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -759,7 +767,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
.status-carousel.boosts-carousel .status-reblog {
|
.status-carousel.boosts-carousel .status-reblog {
|
||||||
background-image: none;
|
background-image: none;
|
||||||
}
|
}
|
||||||
.status-carousel.boosts-carousel ul > li:before {
|
.status-carousel.boosts-carousel > ul > li:before {
|
||||||
content: counter(index);
|
content: counter(index);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
@ -1158,12 +1166,12 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: var(--button-bg-blur-color);
|
background-color: var(--button-bg-blur-color);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
z-index: 1;
|
z-index: 10;
|
||||||
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
|
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
|
||||||
0 10px 36px -4px var(--button-bg-blur-color);
|
0 10px 36px -4px var(--button-bg-blur-color);
|
||||||
transition: all 0.3s ease-in-out;
|
transition: all 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
#home-page:has(header[hidden]) ~ #compose-button,
|
.deck-container:has(header[hidden]) ~ #compose-button,
|
||||||
#compose-button[hidden] {
|
#compose-button[hidden] {
|
||||||
transform: translateY(200%);
|
transform: translateY(200%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
@ -1309,6 +1317,9 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
||||||
.tag .icon {
|
.tag .icon {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
.tag.collapsed {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* MENU POPUP */
|
/* MENU POPUP */
|
||||||
|
|
||||||
|
@ -1897,6 +1908,9 @@ ul.link-list li a .icon {
|
||||||
);
|
);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.filter-bar.centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@media (min-width: 40em) {
|
@media (min-width: 40em) {
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
33
src/app.jsx
33
src/app.jsx
|
@ -21,6 +21,7 @@ import { useSnapshot } from 'valtio';
|
||||||
import AccountSheet from './components/account-sheet';
|
import AccountSheet from './components/account-sheet';
|
||||||
import Compose from './components/compose';
|
import Compose from './components/compose';
|
||||||
import Drafts from './components/drafts';
|
import Drafts from './components/drafts';
|
||||||
|
import Icon from './components/icon';
|
||||||
import Loader from './components/loader';
|
import Loader from './components/loader';
|
||||||
import MediaModal from './components/media-modal';
|
import MediaModal from './components/media-modal';
|
||||||
import Modal from './components/modal';
|
import Modal from './components/modal';
|
||||||
|
@ -55,6 +56,7 @@ import {
|
||||||
initPreferences,
|
initPreferences,
|
||||||
} from './utils/api';
|
} from './utils/api';
|
||||||
import { getAccessToken } from './utils/auth';
|
import { getAccessToken } from './utils/auth';
|
||||||
|
import openCompose from './utils/open-compose';
|
||||||
import showToast from './utils/show-toast';
|
import showToast from './utils/show-toast';
|
||||||
import states, { getStatus, saveStatus } from './utils/states';
|
import states, { getStatus, saveStatus } from './utils/states';
|
||||||
import store from './utils/store';
|
import store from './utils/store';
|
||||||
|
@ -110,7 +112,7 @@ function App() {
|
||||||
|
|
||||||
const masto = initClient({ instance: instanceURL, accessToken });
|
const masto = initClient({ instance: instanceURL, accessToken });
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
initInstance(masto),
|
initInstance(masto, instanceURL),
|
||||||
initAccount(masto, instanceURL, accessToken),
|
initAccount(masto, instanceURL, accessToken),
|
||||||
]);
|
]);
|
||||||
initPreferences(masto);
|
initPreferences(masto);
|
||||||
|
@ -122,13 +124,13 @@ function App() {
|
||||||
const account = getCurrentAccount();
|
const account = getCurrentAccount();
|
||||||
if (account) {
|
if (account) {
|
||||||
store.session.set('currentAccount', account.info.id);
|
store.session.set('currentAccount', account.info.id);
|
||||||
const { masto } = api({ account });
|
const { masto, instance } = api({ account });
|
||||||
console.log('masto', masto);
|
console.log('masto', masto);
|
||||||
initPreferences(masto);
|
initPreferences(masto);
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await initInstance(masto);
|
await initInstance(masto, instance);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
|
@ -263,13 +265,30 @@ function App() {
|
||||||
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
|
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)}
|
)}
|
||||||
<div>
|
{isLoggedIn && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="compose-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.shiftKey) {
|
||||||
|
const newWin = openCompose();
|
||||||
|
if (!newWin) {
|
||||||
|
alert('Looks like your browser is blocking popups.');
|
||||||
|
states.showCompose = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
states.showCompose = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="quill" size="xl" alt="Compose" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isLoggedIn &&
|
{isLoggedIn &&
|
||||||
!snapStates.settings.shortcutsColumnsMode &&
|
!snapStates.settings.shortcutsColumnsMode &&
|
||||||
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
|
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
|
||||||
<Shortcuts />
|
<Shortcuts />
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{!!snapStates.showCompose && (
|
{!!snapStates.showCompose && (
|
||||||
<Modal>
|
<Modal>
|
||||||
<Compose
|
<Compose
|
||||||
|
@ -295,7 +314,7 @@ function App() {
|
||||||
if (newStatus) {
|
if (newStatus) {
|
||||||
states.reloadStatusPage++;
|
states.reloadStatusPage++;
|
||||||
showToast({
|
showToast({
|
||||||
text: 'Status posted. Check it out.',
|
text: 'Post published. Check it out.',
|
||||||
delay: 1000,
|
delay: 1000,
|
||||||
duration: 10_000, // 10 seconds
|
duration: 10_000, // 10 seconds
|
||||||
onClick: (toast) => {
|
onClick: (toast) => {
|
||||||
|
@ -484,7 +503,7 @@ function BackgroundService({ isLoggedIn }) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
useInterval(() => checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
|
useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
|
||||||
usePageVisibility((visible) => {
|
usePageVisibility((visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
if (!lastCheckDate.current) {
|
if (!lastCheckDate.current) {
|
||||||
|
|
|
@ -2,6 +2,7 @@ body.cloak .name-text,
|
||||||
body.cloak .name-text *,
|
body.cloak .name-text *,
|
||||||
body.cloak .status .content-container,
|
body.cloak .status .content-container,
|
||||||
body.cloak .status .content-container *,
|
body.cloak .status .content-container *,
|
||||||
|
body.cloak .status .content-compact,
|
||||||
body.cloak .account-container :is(header, main > *:not(.actions)),
|
body.cloak .account-container :is(header, main > *:not(.actions)),
|
||||||
body.cloak .account-container :is(header, main > *:not(.actions)) *,
|
body.cloak .account-container :is(header, main > *:not(.actions)) *,
|
||||||
body.cloak .header-account,
|
body.cloak .header-account,
|
||||||
|
@ -11,6 +12,11 @@ body.cloak .account-block {
|
||||||
text-rendering: optimizeSpeed;
|
text-rendering: optimizeSpeed;
|
||||||
filter: opacity(0.5);
|
filter: opacity(0.5);
|
||||||
}
|
}
|
||||||
|
body.cloak .name-text *,
|
||||||
|
body.cloak .status .content-container *,
|
||||||
|
body.cloak .account-container :is(header, main > *:not(.actions)) * {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
body.cloak .status :is(img, video, audio),
|
body.cloak .status :is(img, video, audio),
|
||||||
body.cloak .avatar,
|
body.cloak .avatar,
|
||||||
|
|
|
@ -199,17 +199,6 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-container .common-followers {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 1fr;
|
|
||||||
transition: grid-template-rows 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
.account-container .common-followers[hidden] {
|
|
||||||
grid-template-rows: 0fr;
|
|
||||||
}
|
|
||||||
.account-container .common-followers > .common-followers-inner {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.account-container .common-followers p {
|
.account-container .common-followers p {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
|
|
|
@ -487,8 +487,11 @@ function RelatedActions({ info, instance, authenticated }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="common-followers" hidden={!familiarFollowers?.length}>
|
<div
|
||||||
<div class="common-followers-inner">
|
class="common-followers shazam-container no-animation"
|
||||||
|
hidden={!familiarFollowers?.length}
|
||||||
|
>
|
||||||
|
<div class="shazam-container-inner">
|
||||||
<p>
|
<p>
|
||||||
Also followed by{' '}
|
Also followed by{' '}
|
||||||
<span class="ib">
|
<span class="ib">
|
||||||
|
@ -521,7 +524,7 @@ function RelatedActions({ info, instance, authenticated }) {
|
||||||
<span class="tag">Following you</span>
|
<span class="tag">Following you</span>
|
||||||
) : !!lastStatusAt ? (
|
) : !!lastStatusAt ? (
|
||||||
<small class="insignificant">
|
<small class="insignificant">
|
||||||
Last status:{' '}
|
Last post:{' '}
|
||||||
{niceDateTime(lastStatusAt, {
|
{niceDateTime(lastStatusAt, {
|
||||||
hideTime: true,
|
hideTime: true,
|
||||||
})}
|
})}
|
||||||
|
@ -778,6 +781,9 @@ function RelatedActions({ info, instance, authenticated }) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
{!relationship && relationshipUIState === 'loading' && (
|
||||||
|
<Loader abrupt />
|
||||||
|
)}
|
||||||
{!!relationship && (
|
{!!relationship && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
@ -642,14 +642,14 @@ function Compose({
|
||||||
<div class="status-preview-legend reply-to">
|
<div class="status-preview-legend reply-to">
|
||||||
Replying to @
|
Replying to @
|
||||||
{replyToStatus.account.acct || replyToStatus.account.username}
|
{replyToStatus.account.acct || replyToStatus.account.username}
|
||||||
’s status
|
’s post
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!editStatus && (
|
{!!editStatus && (
|
||||||
<div class="status-preview">
|
<div class="status-preview">
|
||||||
<Status status={editStatus} size="s" previewMode />
|
<Status status={editStatus} size="s" previewMode />
|
||||||
<div class="status-preview-legend">Editing source status</div>
|
<div class="status-preview-legend">Editing source post</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form
|
<form
|
||||||
|
@ -904,7 +904,7 @@ function Compose({
|
||||||
replyToStatus
|
replyToStatus
|
||||||
? 'Post your reply'
|
? 'Post your reply'
|
||||||
: editStatus
|
: editStatus
|
||||||
? 'Edit your status'
|
? 'Edit your post'
|
||||||
: 'What are you doing?'
|
: 'What are you doing?'
|
||||||
}
|
}
|
||||||
required={mediaAttachments?.length === 0}
|
required={mediaAttachments?.length === 0}
|
||||||
|
|
54
src/components/follow-request-buttons.jsx
Normal file
54
src/components/follow-request-buttons.jsx
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
|
||||||
|
import Loader from './loader';
|
||||||
|
|
||||||
|
function FollowRequestButtons({ accountID, onChange }) {
|
||||||
|
const { masto } = api();
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
return (
|
||||||
|
<p class="follow-request-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v1.followRequests.authorize(accountID);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('default');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
class="light danger"
|
||||||
|
onClick={() => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await masto.v1.followRequests.reject(accountID);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('default');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FollowRequestButtons;
|
|
@ -79,6 +79,7 @@ const ICONS = {
|
||||||
react: 'mingcute:react-line',
|
react: 'mingcute:react-line',
|
||||||
layout4: 'mingcute:layout-4-line',
|
layout4: 'mingcute:layout-4-line',
|
||||||
layout5: 'mingcute:layout-5-line',
|
layout5: 'mingcute:layout-5-line',
|
||||||
|
announce: 'mingcute:announcement-line',
|
||||||
};
|
};
|
||||||
|
|
||||||
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
|
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
|
||||||
|
|
|
@ -19,7 +19,7 @@ const Link = forwardRef((props, ref) => {
|
||||||
let hash = (location.hash || '').replace(/^#/, '').trim();
|
let hash = (location.hash || '').replace(/^#/, '').trim();
|
||||||
if (hash === '') hash = '/';
|
if (hash === '') hash = '/';
|
||||||
const { to, ...restProps } = props;
|
const { to, ...restProps } = props;
|
||||||
const isActive = hash === to;
|
const isActive = decodeURIComponent(hash) === to;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -17,13 +17,24 @@ audio = Audio track
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||||
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
|
const {
|
||||||
media;
|
blurhash,
|
||||||
|
description,
|
||||||
|
meta,
|
||||||
|
previewRemoteUrl,
|
||||||
|
previewUrl,
|
||||||
|
remoteUrl,
|
||||||
|
url,
|
||||||
|
type,
|
||||||
|
} = media;
|
||||||
const { original = {}, small, focus } = meta || {};
|
const { original = {}, small, focus } = meta || {};
|
||||||
|
|
||||||
const width = showOriginal ? original?.width : small?.width;
|
const width = showOriginal ? original?.width : small?.width;
|
||||||
const height = showOriginal ? original?.height : small?.height;
|
const height = showOriginal ? original?.height : small?.height;
|
||||||
const mediaURL = showOriginal ? url : previewUrl;
|
const mediaURL = showOriginal ? url : previewUrl || url;
|
||||||
|
const remoteMediaURL = showOriginal
|
||||||
|
? remoteUrl
|
||||||
|
: previewRemoteUrl || remoteUrl;
|
||||||
const orientation = width >= height ? 'landscape' : 'portrait';
|
const orientation = width >= height ? 'landscape' : 'portrait';
|
||||||
|
|
||||||
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||||
|
@ -113,6 +124,12 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||||
e.target.closest('.media-zoom').style.display = '';
|
e.target.closest('.media-zoom').style.display = '';
|
||||||
setPinchZoomEnabled(true);
|
setPinchZoomEnabled(true);
|
||||||
}}
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
const { src } = e.target;
|
||||||
|
if (src === mediaURL) {
|
||||||
|
e.target.src = remoteMediaURL;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</QuickPinchZoom>
|
</QuickPinchZoom>
|
||||||
) : (
|
) : (
|
||||||
|
@ -131,6 +148,12 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
e.target.closest('.media-image').style.backgroundImage = '';
|
e.target.closest('.media-image').style.backgroundImage = '';
|
||||||
}}
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
const { src } = e.target;
|
||||||
|
if (src === mediaURL) {
|
||||||
|
e.target.src = remoteMediaURL;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Parent>
|
</Parent>
|
||||||
|
|
|
@ -97,6 +97,8 @@ function NavMenu(props) {
|
||||||
{...props}
|
{...props}
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
viewScroll="close"
|
viewScroll="close"
|
||||||
|
position="anchor"
|
||||||
|
align="center"
|
||||||
boundingBoxPadding="8 8 8 8"
|
boundingBoxPadding="8 8 8 8"
|
||||||
unmountOnClose
|
unmountOnClose
|
||||||
>
|
>
|
||||||
|
|
209
src/components/notification.jsx
Normal file
209
src/components/notification.jsx
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
import states from '../utils/states';
|
||||||
|
import store from '../utils/store';
|
||||||
|
|
||||||
|
import Avatar from './avatar';
|
||||||
|
import FollowRequestButtons from './follow-request-buttons';
|
||||||
|
import Icon from './icon';
|
||||||
|
import Link from './link';
|
||||||
|
import NameText from './name-text';
|
||||||
|
import RelativeTime from './relative-time';
|
||||||
|
import Status from './status';
|
||||||
|
|
||||||
|
const NOTIFICATION_ICONS = {
|
||||||
|
mention: 'comment',
|
||||||
|
status: 'notification',
|
||||||
|
reblog: 'rocket',
|
||||||
|
follow: 'follow',
|
||||||
|
follow_request: 'follow-add',
|
||||||
|
favourite: 'heart',
|
||||||
|
poll: 'poll',
|
||||||
|
update: 'pencil',
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Notification types
|
||||||
|
==================
|
||||||
|
mention = Someone mentioned you in their status
|
||||||
|
status = Someone you enabled notifications for has posted a status
|
||||||
|
reblog = Someone boosted one of your statuses
|
||||||
|
follow = Someone followed you
|
||||||
|
follow_request = Someone requested to follow you
|
||||||
|
favourite = Someone favourited one of your statuses
|
||||||
|
poll = A poll you have voted in or created has ended
|
||||||
|
update = A status you interacted with has been edited
|
||||||
|
admin.sign_up = Someone signed up (optionally sent to admins)
|
||||||
|
admin.report = A new report has been filed
|
||||||
|
*/
|
||||||
|
|
||||||
|
const contentText = {
|
||||||
|
mention: 'mentioned you in their post.',
|
||||||
|
status: 'published a post.',
|
||||||
|
reblog: 'boosted your post.',
|
||||||
|
follow: 'followed you.',
|
||||||
|
follow_request: 'requested to follow you.',
|
||||||
|
favourite: 'favourited your post.',
|
||||||
|
poll: 'A poll you have voted in or created has ended.',
|
||||||
|
'poll-self': 'A poll you have created has ended.',
|
||||||
|
'poll-voted': 'A poll you have voted in has ended.',
|
||||||
|
update: 'A post you interacted with has been edited.',
|
||||||
|
'favourite+reblog': 'boosted & favourited your post.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Notification({ notification, instance, reload }) {
|
||||||
|
const { id, status, account, _accounts } = notification;
|
||||||
|
let { type } = notification;
|
||||||
|
|
||||||
|
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
||||||
|
const actualStatusID = status?.reblog?.id || status?.id;
|
||||||
|
|
||||||
|
const currentAccount = store.session.get('currentAccount');
|
||||||
|
const isSelf = currentAccount === account?.id;
|
||||||
|
const isVoted = status?.poll?.voted;
|
||||||
|
|
||||||
|
let favsCount = 0;
|
||||||
|
let reblogsCount = 0;
|
||||||
|
if (type === 'favourite+reblog') {
|
||||||
|
for (const account of _accounts) {
|
||||||
|
if (account._types?.includes('favourite')) {
|
||||||
|
favsCount++;
|
||||||
|
}
|
||||||
|
if (account._types?.includes('reblog')) {
|
||||||
|
reblogsCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!reblogsCount && favsCount) type = 'favourite';
|
||||||
|
if (!favsCount && reblogsCount) type = 'reblog';
|
||||||
|
}
|
||||||
|
|
||||||
|
const text =
|
||||||
|
type === 'poll'
|
||||||
|
? contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']
|
||||||
|
: contentText[type];
|
||||||
|
|
||||||
|
if (type === 'mention' && !status) {
|
||||||
|
// Could be deleted
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`notification notification-${type}`} tabIndex="0">
|
||||||
|
<div
|
||||||
|
class={`notification-type notification-${type}`}
|
||||||
|
title={new Date(notification.createdAt).toLocaleString()}
|
||||||
|
>
|
||||||
|
{type === 'favourite+reblog' ? (
|
||||||
|
<>
|
||||||
|
<Icon icon="rocket" size="xl" alt={type} class="reblog-icon" />
|
||||||
|
<Icon icon="heart" size="xl" alt={type} class="favourite-icon" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
icon={NOTIFICATION_ICONS[type] || 'notification'}
|
||||||
|
size="xl"
|
||||||
|
alt={type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
{type !== 'mention' && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{!/poll|update/i.test(type) && (
|
||||||
|
<>
|
||||||
|
{_accounts?.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<b>{_accounts.length} people</b>{' '}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<NameText account={account} showAvatar />{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{text}
|
||||||
|
{type === 'mention' && (
|
||||||
|
<span class="insignificant">
|
||||||
|
{' '}
|
||||||
|
•{' '}
|
||||||
|
<RelativeTime
|
||||||
|
datetime={notification.createdAt}
|
||||||
|
format="micro"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{type === 'follow_request' && (
|
||||||
|
<FollowRequestButtons
|
||||||
|
accountID={account.id}
|
||||||
|
onChange={() => {
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{_accounts?.length > 1 && (
|
||||||
|
<p class="avatars-stack">
|
||||||
|
{_accounts.map((account, i) => (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={account.url}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="account-avatar-stack"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
states.showAccount = account;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
url={account.avatarStatic}
|
||||||
|
size={
|
||||||
|
_accounts.length <= 10
|
||||||
|
? 'xxl'
|
||||||
|
: _accounts.length < 100
|
||||||
|
? 'xl'
|
||||||
|
: _accounts.length < 1000
|
||||||
|
? 'l'
|
||||||
|
: _accounts.length < 2000
|
||||||
|
? 'm'
|
||||||
|
: 's' // My god, this person is popular!
|
||||||
|
}
|
||||||
|
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>{' '}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status && (
|
||||||
|
<Link
|
||||||
|
class={`status-link status-type-${type}`}
|
||||||
|
to={
|
||||||
|
instance
|
||||||
|
? `/${instance}/s/${actualStatusID}`
|
||||||
|
: `/s/${actualStatusID}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Status statusID={actualStatusID} size="s" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Notification;
|
|
@ -87,6 +87,7 @@ export default function Poll({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(showResults && optionsHaveVoteCounts) || voted || expired ? (
|
{(showResults && optionsHaveVoteCounts) || voted || expired ? (
|
||||||
|
<>
|
||||||
<div class="poll-options">
|
<div class="poll-options">
|
||||||
{options.map((option, i) => {
|
{options.map((option, i) => {
|
||||||
const { title, votesCount: optionVotesCount } = option;
|
const { title, votesCount: optionVotesCount } = option;
|
||||||
|
@ -135,6 +136,18 @@ export default function Poll({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{!expired && !voted && (
|
||||||
|
<button
|
||||||
|
class="poll-vote-button plain2"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => {
|
||||||
|
setShowResults(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-left" /> Hide results
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
|
|
|
@ -514,6 +514,12 @@
|
||||||
padding: 0 0 0 8px;
|
padding: 0 0 0 8px;
|
||||||
border-left: 4px solid var(--link-faded-color);
|
border-left: 4px solid var(--link-faded-color);
|
||||||
}
|
}
|
||||||
|
.status .content > :is(ul, ol),
|
||||||
|
.status .content > div > :is(ul, ol) {
|
||||||
|
margin-block: min(0.75em, 12px);
|
||||||
|
margin-inline: 0;
|
||||||
|
padding-inline-start: 1em;
|
||||||
|
}
|
||||||
.status .content .invisible {
|
.status .content .invisible {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -1035,11 +1041,13 @@ a.card:is(:hover, :focus) {
|
||||||
}
|
}
|
||||||
.poll-vote-button {
|
.poll-vote-button {
|
||||||
margin: 8px 8px 0 12px;
|
margin: 8px 8px 0 12px;
|
||||||
padding-inline: 24px;
|
/* padding-inline: 24px; */
|
||||||
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
.poll-meta {
|
.poll-meta {
|
||||||
margin: 8px 16px;
|
margin: 8px 16px;
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.poll-option-title {
|
.poll-option-title {
|
||||||
text-shadow: 0 1px var(--bg-color);
|
text-shadow: 0 1px var(--bg-color);
|
||||||
|
|
|
@ -79,6 +79,7 @@ function Status({
|
||||||
readOnly,
|
readOnly,
|
||||||
contentTextWeight,
|
contentTextWeight,
|
||||||
enableTranslate,
|
enableTranslate,
|
||||||
|
forceTranslate: _forceTranslate,
|
||||||
previewMode,
|
previewMode,
|
||||||
allowFilters,
|
allowFilters,
|
||||||
onMediaClick,
|
onMediaClick,
|
||||||
|
@ -233,7 +234,7 @@ function Status({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [forceTranslate, setForceTranslate] = useState(false);
|
const [forceTranslate, setForceTranslate] = useState(_forceTranslate);
|
||||||
const targetLanguage = getTranslateTargetLanguage(true);
|
const targetLanguage = getTranslateTargetLanguage(true);
|
||||||
const contentTranslationHideLanguages =
|
const contentTranslationHideLanguages =
|
||||||
snapStates.settings.contentTranslationHideLanguages || [];
|
snapStates.settings.contentTranslationHideLanguages || [];
|
||||||
|
@ -280,7 +281,7 @@ function Status({
|
||||||
|
|
||||||
const statusRef = useRef(null);
|
const statusRef = useRef(null);
|
||||||
|
|
||||||
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this status from another instance.`;
|
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this post from another instance.`;
|
||||||
|
|
||||||
const textWeight = () =>
|
const textWeight = () =>
|
||||||
Math.max(
|
Math.max(
|
||||||
|
@ -403,6 +404,14 @@ function Status({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const differentLanguage =
|
||||||
|
language &&
|
||||||
|
language !== targetLanguage &&
|
||||||
|
!match([language], [targetLanguage]) &&
|
||||||
|
!contentTranslationHideLanguages.find(
|
||||||
|
(l) => language === l || match([language], [l]),
|
||||||
|
);
|
||||||
|
|
||||||
const menuInstanceRef = useRef();
|
const menuInstanceRef = useRef();
|
||||||
const StatusMenuItems = (
|
const StatusMenuItems = (
|
||||||
<>
|
<>
|
||||||
|
@ -530,7 +539,7 @@ function Status({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{enableTranslate && (
|
{enableTranslate ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
disabled={forceTranslate}
|
disabled={forceTranslate}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -540,6 +549,15 @@ function Status({
|
||||||
<Icon icon="translate" />
|
<Icon icon="translate" />
|
||||||
<span>Translate</span>
|
<span>Translate</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
) : (
|
||||||
|
(!language || differentLanguage) && (
|
||||||
|
<MenuLink
|
||||||
|
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
|
||||||
|
>
|
||||||
|
<Icon icon="translate" />
|
||||||
|
<span>Translate</span>
|
||||||
|
</MenuLink>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{((!isSizeLarge && sameInstance) || enableTranslate) && <MenuDivider />}
|
{((!isSizeLarge && sameInstance) || enableTranslate) && <MenuDivider />}
|
||||||
<MenuItem href={url} target="_blank">
|
<MenuItem href={url} target="_blank">
|
||||||
|
@ -996,14 +1014,7 @@ function Status({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{((enableTranslate &&
|
{((enableTranslate && !!content.trim() && differentLanguage) ||
|
||||||
!!content.trim() &&
|
|
||||||
language &&
|
|
||||||
language !== targetLanguage &&
|
|
||||||
!match([language], [targetLanguage]) &&
|
|
||||||
!contentTranslationHideLanguages.find(
|
|
||||||
(l) => language === l || match([language], [l]),
|
|
||||||
)) ||
|
|
||||||
forceTranslate) && (
|
forceTranslate) && (
|
||||||
<TranslationBlock
|
<TranslationBlock
|
||||||
forceTranslate={forceTranslate}
|
forceTranslate={forceTranslate}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, 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';
|
||||||
|
@ -35,6 +35,7 @@ function Timeline({
|
||||||
allowFilters,
|
allowFilters,
|
||||||
refresh,
|
refresh,
|
||||||
}) {
|
}) {
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
@ -203,41 +204,51 @@ function Timeline({
|
||||||
}
|
}
|
||||||
}, [nearReachEnd, showMore]);
|
}, [nearReachEnd, showMore]);
|
||||||
|
|
||||||
|
const isHovering = useRef(false);
|
||||||
|
const loadOrCheckUpdates = useCallback(
|
||||||
|
async ({ disableHoverCheck = false } = {}) => {
|
||||||
|
console.log('✨ Load or check updates', snapStates.settings.autoRefresh);
|
||||||
|
if (
|
||||||
|
snapStates.settings.autoRefresh &&
|
||||||
|
scrollableRef.current.scrollTop === 0 &&
|
||||||
|
(disableHoverCheck || !isHovering.current) &&
|
||||||
|
!inBackground()
|
||||||
|
) {
|
||||||
|
console.log('✨ Load updates', snapStates.settings.autoRefresh);
|
||||||
|
loadItems(true);
|
||||||
|
} else {
|
||||||
|
console.log('✨ Check updates', snapStates.settings.autoRefresh);
|
||||||
|
const hasUpdate = await checkForUpdates();
|
||||||
|
if (hasUpdate) {
|
||||||
|
console.log('✨ Has new updates', id);
|
||||||
|
setShowNew(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id, loadItems, checkForUpdates, snapStates.settings.autoRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
const lastHiddenTime = useRef();
|
const lastHiddenTime = useRef();
|
||||||
usePageVisibility(
|
usePageVisibility(
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
const timeDiff = Date.now() - lastHiddenTime.current;
|
const timeDiff = Date.now() - lastHiddenTime.current;
|
||||||
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
|
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
|
||||||
(async () => {
|
loadOrCheckUpdates({
|
||||||
console.log('✨ Check updates');
|
disableHoverCheck: true,
|
||||||
const hasUpdate = await checkForUpdates();
|
});
|
||||||
if (hasUpdate) {
|
|
||||||
console.log('✨ Has new updates', id);
|
|
||||||
setShowNew(true);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lastHiddenTime.current = Date.now();
|
lastHiddenTime.current = Date.now();
|
||||||
}
|
}
|
||||||
setVisible(visible);
|
setVisible(visible);
|
||||||
},
|
},
|
||||||
[checkForUpdates],
|
[checkForUpdates, loadOrCheckUpdates, snapStates.settings.autoRefresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
// checkForUpdates interval
|
// checkForUpdates interval
|
||||||
useInterval(
|
useInterval(
|
||||||
() => {
|
loadOrCheckUpdates,
|
||||||
(async () => {
|
|
||||||
console.log('✨ Check updates');
|
|
||||||
const hasUpdate = await checkForUpdates();
|
|
||||||
if (hasUpdate) {
|
|
||||||
console.log('✨ Has new updates', id);
|
|
||||||
setShowNew(true);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
visible && !showNew ? checkForUpdatesInterval : null,
|
visible && !showNew ? checkForUpdatesInterval : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -254,6 +265,12 @@ function Timeline({
|
||||||
oRef.current = node;
|
oRef.current = node;
|
||||||
}}
|
}}
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
isHovering.current = true;
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
isHovering.current = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="timeline-deck deck">
|
<div class="timeline-deck deck">
|
||||||
<header
|
<header
|
||||||
|
@ -597,4 +614,8 @@ function TimelineStatusCompact({ status, instance }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inBackground() {
|
||||||
|
return !!document.querySelector('.deck-backdrop, #modal-container > *');
|
||||||
|
}
|
||||||
|
|
||||||
export default Timeline;
|
export default Timeline;
|
||||||
|
|
|
@ -391,3 +391,26 @@ code {
|
||||||
object-position: 50% 50%;
|
object-position: 50% 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes shazam {
|
||||||
|
0% {
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.shazam-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
transition: grid-template-rows 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
.shazam-container:not(.no-animation) {
|
||||||
|
animation: shazam 0.5s ease-in-out both !important;
|
||||||
|
}
|
||||||
|
.shazam-container[hidden] {
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
}
|
||||||
|
.shazam-container-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
|
@ -248,7 +248,7 @@ function AccountStatuses() {
|
||||||
id="account-statuses"
|
id="account-statuses"
|
||||||
instance={instance}
|
instance={instance}
|
||||||
emptyText="Nothing to see here yet."
|
emptyText="Nothing to see here yet."
|
||||||
errorText="Unable to load statuses"
|
errorText="Unable to load posts"
|
||||||
fetchItems={fetchAccountStatuses}
|
fetchItems={fetchAccountStatuses}
|
||||||
useItemID
|
useItemID
|
||||||
boostsCarousel={snapStates.settings.boostsCarousel}
|
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
|
import './notifications-menu.css';
|
||||||
|
|
||||||
|
import { ControlledMenu } from '@szhsin/react-menu';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Columns from '../components/columns';
|
import Columns from '../components/columns';
|
||||||
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 Notification from '../components/notification';
|
||||||
|
import { api } from '../utils/api';
|
||||||
import db from '../utils/db';
|
import db from '../utils/db';
|
||||||
|
import groupNotifications from '../utils/group-notifications';
|
||||||
import openCompose from '../utils/open-compose';
|
import openCompose from '../utils/open-compose';
|
||||||
import states 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';
|
||||||
|
@ -39,22 +46,10 @@ function Home() {
|
||||||
path="/"
|
path="/"
|
||||||
id="home"
|
id="home"
|
||||||
headerStart={false}
|
headerStart={false}
|
||||||
headerEnd={
|
headerEnd={<NotificationsLink />}
|
||||||
<Link
|
|
||||||
to="/notifications"
|
|
||||||
class={`button plain notifications-button ${
|
|
||||||
snapStates.notificationsShowNew ? 'has-badge' : ''
|
|
||||||
}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="notification" size="l" alt="Notifications" />
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
{/* <button
|
||||||
// hidden={scrollDirection === 'end' && !nearReachStart}
|
// hidden={scrollDirection === 'end' && !nearReachStart}
|
||||||
type="button"
|
type="button"
|
||||||
id="compose-button"
|
id="compose-button"
|
||||||
|
@ -71,9 +66,166 @@ function Home() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon icon="quill" size="xl" alt="Compose" />
|
<Icon icon="quill" size="xl" alt="Compose" />
|
||||||
</button>
|
</button> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationsLink() {
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
const notificationLinkRef = useRef();
|
||||||
|
const [menuState, setMenuState] = useState(undefined);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
ref={notificationLinkRef}
|
||||||
|
to="/notifications"
|
||||||
|
class={`button plain notifications-button ${
|
||||||
|
snapStates.notificationsShowNew ? 'has-badge' : ''
|
||||||
|
} ${menuState}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.matchMedia('(min-width: calc(40em))').matches) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuState((state) => (!state ? 'open' : undefined));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="notification" size="l" alt="Notifications" />
|
||||||
|
</Link>
|
||||||
|
<NotificationsMenu
|
||||||
|
state={menuState}
|
||||||
|
anchorRef={notificationLinkRef}
|
||||||
|
onClose={() => setMenuState(undefined)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIFICATIONS_LIMIT = 30;
|
||||||
|
const NOTIFICATIONS_DISPLAY_LIMIT = 5;
|
||||||
|
function NotificationsMenu({ anchorRef, state, onClose }) {
|
||||||
|
const { masto, instance } = api();
|
||||||
|
const snapStates = useSnapshot(states);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
|
||||||
|
const notificationsIterator = masto.v1.notifications.list({
|
||||||
|
limit: NOTIFICATIONS_LIMIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchNotifications() {
|
||||||
|
const allNotifications = await notificationsIterator.next();
|
||||||
|
const notifications = allNotifications.value;
|
||||||
|
|
||||||
|
if (notifications?.length) {
|
||||||
|
notifications.forEach((notification) => {
|
||||||
|
saveStatus(notification.status, instance, {
|
||||||
|
skipThreading: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedNotifications = groupNotifications(notifications);
|
||||||
|
|
||||||
|
states.notificationsLast = notifications[0];
|
||||||
|
states.notifications = groupedNotifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
states.notificationsShowNew = false;
|
||||||
|
states.notificationsLastFetchTime = Date.now();
|
||||||
|
return allNotifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hasFollowRequests, setHasFollowRequests] = useState(false);
|
||||||
|
function fetchFollowRequests() {
|
||||||
|
return masto.v1.followRequests.list({
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotifications() {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await fetchNotifications();
|
||||||
|
const followRequests = await fetchFollowRequests();
|
||||||
|
setHasFollowRequests(!!followRequests?.length);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === 'open') loadNotifications();
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ControlledMenu
|
||||||
|
menuClassName="notifications-menu"
|
||||||
|
state={state}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
onClose={onClose}
|
||||||
|
portal={{
|
||||||
|
target: document.body,
|
||||||
|
}}
|
||||||
|
overflow="auto"
|
||||||
|
viewScroll="close"
|
||||||
|
position="anchor"
|
||||||
|
align="center"
|
||||||
|
boundingBoxPadding="8 8 8 8"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2>Notifications</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{snapStates.notifications.length ? (
|
||||||
|
<>
|
||||||
|
{snapStates.notifications
|
||||||
|
.slice(0, NOTIFICATIONS_DISPLAY_LIMIT)
|
||||||
|
.map((notification) => (
|
||||||
|
<Notification
|
||||||
|
key={notification.id}
|
||||||
|
instance={instance}
|
||||||
|
notification={notification}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : uiState === 'loading' ? (
|
||||||
|
<div class="ui-state">
|
||||||
|
<Loader abrupt />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
uiState === 'error' && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<p>Unable to fetch notifications.</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" onClick={loadNotifications}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<Link to="/mentions" class="button plain">
|
||||||
|
<Icon icon="at" /> <span>Mentions</span>
|
||||||
|
</Link>
|
||||||
|
<Link to="/notifications" class="button plain2">
|
||||||
|
{hasFollowRequests ? (
|
||||||
|
<>
|
||||||
|
<span class="tag collapsed">New</span>{' '}
|
||||||
|
<span>Follow Requests</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<b>See all</b>
|
||||||
|
)}{' '}
|
||||||
|
<Icon icon="arrow-right" />
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
|
</ControlledMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(Home);
|
export default memo(Home);
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { useRef } from 'preact/hooks';
|
import { useMemo, useRef } from 'preact/hooks';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
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 { saveStatus } from '../utils/states';
|
import { saveStatus } from '../utils/states';
|
||||||
|
@ -7,9 +9,12 @@ import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
|
||||||
function Mentions() {
|
function Mentions(props) {
|
||||||
useTitle('Mentions', '/mentions');
|
useTitle('Mentions', '/mentions');
|
||||||
const { masto, instance } = api();
|
const { masto, instance } = api();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const type = props?.type || searchParams.get('type');
|
||||||
|
|
||||||
const mentionsIterator = useRef();
|
const mentionsIterator = useRef();
|
||||||
const latestItem = useRef();
|
const latestItem = useRef();
|
||||||
|
|
||||||
|
@ -34,11 +39,68 @@ function Mentions() {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...results,
|
...results,
|
||||||
value: value.map((item) => item.status),
|
value: value?.map((item) => item.status),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conversationsIterator = useRef();
|
||||||
|
const latestConversationItem = useRef();
|
||||||
|
async function fetchConversations(firstLoad) {
|
||||||
|
if (firstLoad || !conversationsIterator.current) {
|
||||||
|
conversationsIterator.current = masto.v1.conversations.list({
|
||||||
|
limit: LIMIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const results = await conversationsIterator.current.next();
|
||||||
|
let { value } = results;
|
||||||
|
if (value?.length) {
|
||||||
|
if (firstLoad) {
|
||||||
|
latestConversationItem.current = value[0].lastStatus.id;
|
||||||
|
console.log('First load', latestConversationItem.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
value.forEach(({ lastStatus: item }) => {
|
||||||
|
saveStatus(item, instance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('results', results);
|
||||||
|
return {
|
||||||
|
...results,
|
||||||
|
value: value?.map((item) => item.lastStatus),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchItems(...args) {
|
||||||
|
if (type === 'private') {
|
||||||
|
return fetchConversations(...args);
|
||||||
|
}
|
||||||
|
return fetchMentions(...args);
|
||||||
|
}
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
|
if (type === 'private') {
|
||||||
|
try {
|
||||||
|
const results = await masto.v1.conversations
|
||||||
|
.list({
|
||||||
|
limit: 1,
|
||||||
|
since_id: latestConversationItem.current,
|
||||||
|
})
|
||||||
|
.next();
|
||||||
|
let { value } = results;
|
||||||
|
console.log(
|
||||||
|
'checkForUpdates PRIVATE',
|
||||||
|
latestConversationItem.current,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
if (value?.length) {
|
||||||
|
latestConversationItem.current = value[0].lastStatus.id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
const results = await masto.v1.notifications
|
const results = await masto.v1.notifications
|
||||||
.list({
|
.list({
|
||||||
|
@ -48,7 +110,7 @@ function Mentions() {
|
||||||
})
|
})
|
||||||
.next();
|
.next();
|
||||||
let { value } = results;
|
let { value } = results;
|
||||||
console.log('checkForUpdates', latestItem.current, value);
|
console.log('checkForUpdates ALL', latestItem.current, value);
|
||||||
if (value?.length) {
|
if (value?.length) {
|
||||||
latestItem.current = value[0].id;
|
latestItem.current = value[0].id;
|
||||||
return true;
|
return true;
|
||||||
|
@ -58,6 +120,23 @@ function Mentions() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimelineStart = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<div class="filter-bar centered">
|
||||||
|
<Link to="/mentions" class={!type ? 'is-active' : ''}>
|
||||||
|
All
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/mentions?type=private"
|
||||||
|
class={type === 'private' ? 'is-active' : ''}
|
||||||
|
>
|
||||||
|
Private
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Timeline
|
<Timeline
|
||||||
|
@ -66,9 +145,11 @@ function Mentions() {
|
||||||
emptyText="No one mentioned you :("
|
emptyText="No one mentioned you :("
|
||||||
errorText="Unable to load mentions."
|
errorText="Unable to load mentions."
|
||||||
instance={instance}
|
instance={instance}
|
||||||
fetchItems={fetchMentions}
|
fetchItems={fetchItems}
|
||||||
checkForUpdates={checkForUpdates}
|
checkForUpdates={checkForUpdates}
|
||||||
useItemID
|
useItemID
|
||||||
|
timelineStart={TimelineStart}
|
||||||
|
refresh={type}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
57
src/pages/notifications-menu.css
Normal file
57
src/pages/notifications-menu.css
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
@keyframes bell {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
33% {
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.notifications-button.open {
|
||||||
|
animation: bell 0.3s ease-out both;
|
||||||
|
transform-origin: 50% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-menu {
|
||||||
|
width: 28em;
|
||||||
|
font-size: 90%;
|
||||||
|
padding: 0;
|
||||||
|
height: 40em;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.notifications-menu .status {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.notifications-menu header {
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
}
|
||||||
|
.notifications-menu header h2 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
.notifications-menu main {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.notifications-menu .notification {
|
||||||
|
animation: appear-smooth 0.3s ease-out 0.1s both;
|
||||||
|
}
|
||||||
|
.notifications-menu footer {
|
||||||
|
animation: slide-up 0.3s ease-out 0.2s both;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
border-top: var(--hairline-width) solid var(--outline-color);
|
||||||
|
background-color: var(--bg-blur-color);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
padding: 16px;
|
||||||
|
gap: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
padding: 16px !important;
|
padding: 16px !important;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
animation: appear 0.2s ease-out;
|
animation: appear 0.2s ease-out;
|
||||||
|
clear: both;
|
||||||
}
|
}
|
||||||
.notification.notification-mention {
|
.notification.notification-mention {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
@ -152,3 +153,176 @@
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* FOLLOW REQUESTS */
|
||||||
|
|
||||||
|
.follow-requests {
|
||||||
|
padding-block-end: 16px;
|
||||||
|
}
|
||||||
|
.follow-requests ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 50vh;
|
||||||
|
max-height: 50dvh;
|
||||||
|
overflow: auto;
|
||||||
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
}
|
||||||
|
.follow-requests ul li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: var(--hairline-width) solid var(--outline-color);
|
||||||
|
justify-content: space-between;
|
||||||
|
column-gap: 16px;
|
||||||
|
row-gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.follow-requests ul li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.follow-requests ul li .follow-request-buttons {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.follow-requests ul li .follow-request-buttons .loader-container {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ANNOUNCEMENTS */
|
||||||
|
|
||||||
|
.announcements {
|
||||||
|
border: 1px solid var(--outline-color);
|
||||||
|
background-color: var(--bg-blur-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
margin: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.announcements summary {
|
||||||
|
list-style: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
user-select: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.announcements summary .announcement-icon {
|
||||||
|
color: var(--red-color);
|
||||||
|
}
|
||||||
|
.announcements[open] summary {
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
}
|
||||||
|
.announcements summary > span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
@keyframes wiggle {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(-25deg) scale(1.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.announcements summary .announcements-nav-buttons {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.announcements[open] summary .announcements-nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.announcements summary:hover .announcement-icon {
|
||||||
|
animation: wiggle 0.5s 1;
|
||||||
|
}
|
||||||
|
.announcements:not([open]):hover {
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
}
|
||||||
|
.announcements[open] summary {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.announcements summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.announcements > ul {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
background-color: var(--bg-faded-color);
|
||||||
|
}
|
||||||
|
.announcements > ul > li {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
scroll-snap-align: center;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 16px -4px var(--drop-shadow-color);
|
||||||
|
}
|
||||||
|
.announcements > ul.announcements-list-multiple > li {
|
||||||
|
width: calc(100% - 16px);
|
||||||
|
}
|
||||||
|
.announcements > ul > li:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.announcements .announcement-block {
|
||||||
|
padding: 16px;
|
||||||
|
max-height: 50vh;
|
||||||
|
max-height: 50dvh;
|
||||||
|
overflow: auto;
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to top,
|
||||||
|
transparent 1px,
|
||||||
|
black 48px,
|
||||||
|
black calc(100% - 16px),
|
||||||
|
transparent calc(100% - 1px)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.announcements .announcement-content {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.announcements .announcement-content p {
|
||||||
|
margin-block: min(0.75em, 12px);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
.announcements .announcement-reactions:not(:hidden) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.announcements .announcement-reactions button.reacted {
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--link-faded-color);
|
||||||
|
}
|
||||||
|
|
|
@ -1,64 +1,27 @@
|
||||||
import './notifications.css';
|
import './notifications.css';
|
||||||
|
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
import Avatar from '../components/avatar';
|
import AccountBlock from '../components/account-block';
|
||||||
|
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 NameText from '../components/name-text';
|
|
||||||
import NavMenu from '../components/nav-menu';
|
import NavMenu from '../components/nav-menu';
|
||||||
import RelativeTime from '../components/relative-time';
|
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 groupNotifications from '../utils/group-notifications';
|
||||||
|
import handleContentLinks from '../utils/handle-content-links';
|
||||||
import niceDateTime from '../utils/nice-date-time';
|
import niceDateTime from '../utils/nice-date-time';
|
||||||
|
import shortenNumber from '../utils/shorten-number';
|
||||||
import states, { saveStatus } from '../utils/states';
|
import states, { saveStatus } from '../utils/states';
|
||||||
import store from '../utils/store';
|
import { getCurrentInstance } from '../utils/store-utils';
|
||||||
import useScroll from '../utils/useScroll';
|
import useScroll from '../utils/useScroll';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
/*
|
|
||||||
Notification types
|
|
||||||
==================
|
|
||||||
mention = Someone mentioned you in their status
|
|
||||||
status = Someone you enabled notifications for has posted a status
|
|
||||||
reblog = Someone boosted one of your statuses
|
|
||||||
follow = Someone followed you
|
|
||||||
follow_request = Someone requested to follow you
|
|
||||||
favourite = Someone favourited one of your statuses
|
|
||||||
poll = A poll you have voted in or created has ended
|
|
||||||
update = A status you interacted with has been edited
|
|
||||||
admin.sign_up = Someone signed up (optionally sent to admins)
|
|
||||||
admin.report = A new report has been filed
|
|
||||||
*/
|
|
||||||
|
|
||||||
const contentText = {
|
|
||||||
mention: 'mentioned you in their status.',
|
|
||||||
status: 'posted a status.',
|
|
||||||
reblog: 'boosted your status.',
|
|
||||||
follow: 'followed you.',
|
|
||||||
follow_request: 'requested to follow you.',
|
|
||||||
favourite: 'favourited your status.',
|
|
||||||
poll: 'A poll you have voted in or created has ended.',
|
|
||||||
'poll-self': 'A poll you have created has ended.',
|
|
||||||
'poll-voted': 'A poll you have voted in has ended.',
|
|
||||||
update: 'A status you interacted with has been edited.',
|
|
||||||
'favourite+reblog': 'boosted & favourited your status.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const NOTIFICATION_ICONS = {
|
|
||||||
mention: 'comment',
|
|
||||||
status: 'notification',
|
|
||||||
reblog: 'rocket',
|
|
||||||
follow: 'follow',
|
|
||||||
follow_request: 'follow-add',
|
|
||||||
favourite: 'heart',
|
|
||||||
poll: 'poll',
|
|
||||||
update: 'pencil',
|
|
||||||
};
|
|
||||||
|
|
||||||
const LIMIT = 30; // 30 is the maximum limit :(
|
const LIMIT = 30; // 30 is the maximum limit :(
|
||||||
|
|
||||||
function Notifications() {
|
function Notifications() {
|
||||||
|
@ -74,6 +37,8 @@ function Notifications() {
|
||||||
scrollableRef,
|
scrollableRef,
|
||||||
});
|
});
|
||||||
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
|
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
|
||||||
|
const [followRequests, setFollowRequests] = useState([]);
|
||||||
|
const [announcements, setAnnouncements] = useState([]);
|
||||||
|
|
||||||
console.debug('RENDER Notifications');
|
console.debug('RENDER Notifications');
|
||||||
|
|
||||||
|
@ -110,12 +75,52 @@ function Notifications() {
|
||||||
return allNotifications;
|
return allNotifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fetchFollowRequests() {
|
||||||
|
// Note: no pagination here yet because this better be on a separate page. Should be rare use-case???
|
||||||
|
return masto.v1.followRequests.list({
|
||||||
|
limit: 80,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFollowRequests = () => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const requests = await fetchFollowRequests();
|
||||||
|
setFollowRequests(requests);
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
function fetchAnnouncements() {
|
||||||
|
return masto.v1.announcements.list();
|
||||||
|
}
|
||||||
|
|
||||||
const loadNotifications = (firstLoad) => {
|
const loadNotifications = (firstLoad) => {
|
||||||
setUIState('loading');
|
setUIState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
const fetchFollowRequestsPromise = fetchFollowRequests();
|
||||||
|
const fetchAnnouncementsPromise = fetchAnnouncements();
|
||||||
const { done } = await fetchNotifications(firstLoad);
|
const { done } = await fetchNotifications(firstLoad);
|
||||||
setShowMore(!done);
|
setShowMore(!done);
|
||||||
|
|
||||||
|
if (firstLoad) {
|
||||||
|
const requests = await fetchFollowRequestsPromise;
|
||||||
|
setFollowRequests(requests);
|
||||||
|
const announcements = await fetchAnnouncementsPromise;
|
||||||
|
announcements.sort((a, b) => {
|
||||||
|
// Sort by updatedAt first, then createdAt
|
||||||
|
const aDate = new Date(a.updatedAt || a.createdAt);
|
||||||
|
const bDate = new Date(b.updatedAt || b.createdAt);
|
||||||
|
return bDate - aDate;
|
||||||
|
});
|
||||||
|
setAnnouncements(announcements);
|
||||||
|
}
|
||||||
|
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
|
@ -138,6 +143,33 @@ function Notifications() {
|
||||||
}
|
}
|
||||||
}, [nearReachEnd, showMore]);
|
}, [nearReachEnd, showMore]);
|
||||||
|
|
||||||
|
const isHovering = useRef(false);
|
||||||
|
const loadUpdates = useCallback(() => {
|
||||||
|
console.log('✨ Load updates', {
|
||||||
|
autoRefresh: snapStates.settings.autoRefresh,
|
||||||
|
scrollTop: scrollableRef.current?.scrollTop === 0,
|
||||||
|
isHovering: isHovering.current,
|
||||||
|
inBackground: inBackground(),
|
||||||
|
notificationsShowNew: snapStates.notificationsShowNew,
|
||||||
|
uiState,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
snapStates.settings.autoRefresh &&
|
||||||
|
scrollableRef.current?.scrollTop === 0 &&
|
||||||
|
!isHovering.current &&
|
||||||
|
!inBackground() &&
|
||||||
|
snapStates.notificationsShowNew &&
|
||||||
|
uiState !== 'loading'
|
||||||
|
) {
|
||||||
|
loadNotifications(true);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
snapStates.notificationsShowNew,
|
||||||
|
snapStates.settings.autoRefresh,
|
||||||
|
uiState,
|
||||||
|
]);
|
||||||
|
useEffect(loadUpdates, [snapStates.notificationsShowNew]);
|
||||||
|
|
||||||
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);
|
||||||
let currentDay = new Date();
|
let currentDay = new Date();
|
||||||
|
@ -147,12 +179,22 @@ function Notifications() {
|
||||||
todayDate.toDateString(),
|
todayDate.toDateString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const announcementsListRef = useRef();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="notifications-page"
|
id="notifications-page"
|
||||||
class="deck-container"
|
class="deck-container"
|
||||||
ref={scrollableRef}
|
ref={scrollableRef}
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
|
onPointerEnter={() => {
|
||||||
|
console.log('👆 Pointer enter');
|
||||||
|
isHovering.current = true;
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
console.log('👇 Pointer leave');
|
||||||
|
isHovering.current = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
||||||
<header
|
<header
|
||||||
|
@ -192,6 +234,69 @@ function Notifications() {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
{announcements.length > 0 && (
|
||||||
|
<div class="shazam-container">
|
||||||
|
<div class="shazam-container-inner">
|
||||||
|
<details class="announcements">
|
||||||
|
<summary>
|
||||||
|
<span>
|
||||||
|
<Icon icon="announce" class="announcement-icon" size="l" />{' '}
|
||||||
|
<b>Announcement{announcements.length > 1 ? 's' : ''}</b>{' '}
|
||||||
|
<small class="insignificant">{instance}</small>
|
||||||
|
</span>
|
||||||
|
{announcements.length > 1 && (
|
||||||
|
<span class="announcements-nav-buttons">
|
||||||
|
{announcements.map((announcement, index) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain2 small"
|
||||||
|
onClick={() => {
|
||||||
|
announcementsListRef.current?.children[
|
||||||
|
index
|
||||||
|
].scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</summary>
|
||||||
|
<ul
|
||||||
|
class={`announcements-list-${
|
||||||
|
announcements.length > 1 ? 'multiple' : 'single'
|
||||||
|
}`}
|
||||||
|
ref={announcementsListRef}
|
||||||
|
>
|
||||||
|
{announcements.map((announcement) => (
|
||||||
|
<li>
|
||||||
|
<AnnouncementBlock announcement={announcement} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{followRequests.length > 0 && (
|
||||||
|
<div class="follow-requests">
|
||||||
|
<h2 class="timeline-header">Follow requests</h2>
|
||||||
|
<ul>
|
||||||
|
{followRequests.map((account) => (
|
||||||
|
<li>
|
||||||
|
<AccountBlock account={account} />
|
||||||
|
<FollowRequestButtons
|
||||||
|
accountID={account.id}
|
||||||
|
onChange={() => {
|
||||||
|
loadFollowRequests();
|
||||||
|
loadNotifications(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div id="mentions-option">
|
<div id="mentions-option">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
@ -237,6 +342,10 @@ function Notifications() {
|
||||||
instance={instance}
|
instance={instance}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
|
reload={() => {
|
||||||
|
loadNotifications(true);
|
||||||
|
loadFollowRequests();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -287,245 +396,83 @@ function Notifications() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function Notification({ notification, instance }) {
|
|
||||||
const { id, status, account, _accounts } = notification;
|
|
||||||
let { type } = notification;
|
|
||||||
|
|
||||||
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
|
function inBackground() {
|
||||||
const actualStatusID = status?.reblog?.id || status?.id;
|
return !!document.querySelector('.deck-backdrop, #modal-container > *');
|
||||||
|
|
||||||
const currentAccount = store.session.get('currentAccount');
|
|
||||||
const isSelf = currentAccount === account?.id;
|
|
||||||
const isVoted = status?.poll?.voted;
|
|
||||||
|
|
||||||
let favsCount = 0;
|
|
||||||
let reblogsCount = 0;
|
|
||||||
if (type === 'favourite+reblog') {
|
|
||||||
for (const account of _accounts) {
|
|
||||||
if (account._types?.includes('favourite')) {
|
|
||||||
favsCount++;
|
|
||||||
}
|
|
||||||
if (account._types?.includes('reblog')) {
|
|
||||||
reblogsCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!reblogsCount && favsCount) type = 'favourite';
|
|
||||||
if (!favsCount && reblogsCount) type = 'reblog';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const text =
|
function AnnouncementBlock({ announcement }) {
|
||||||
type === 'poll'
|
const { instance } = api();
|
||||||
? contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']
|
const { contact } = getCurrentInstance();
|
||||||
: contentText[type];
|
const contactAccount = contact?.account;
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
startsAt,
|
||||||
|
endsAt,
|
||||||
|
published,
|
||||||
|
allDay,
|
||||||
|
publishedAt,
|
||||||
|
updatedAt,
|
||||||
|
read,
|
||||||
|
mentions,
|
||||||
|
statuses,
|
||||||
|
tags,
|
||||||
|
emojis,
|
||||||
|
reactions,
|
||||||
|
} = announcement;
|
||||||
|
|
||||||
|
const publishedAtDate = new Date(publishedAt);
|
||||||
|
const publishedDateText = niceDateTime(publishedAtDate);
|
||||||
|
const updatedAtDate = new Date(updatedAt);
|
||||||
|
const updatedAtText = niceDateTime(updatedAtDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`notification notification-${type}`} tabIndex="0">
|
<div class="announcement-block">
|
||||||
|
<AccountBlock account={contactAccount} />
|
||||||
<div
|
<div
|
||||||
class={`notification-type notification-${type}`}
|
class="announcement-content"
|
||||||
title={new Date(notification.createdAt).toLocaleString()}
|
onClick={handleContentLinks({ mentions, instance })}
|
||||||
>
|
dangerouslySetInnerHTML={{
|
||||||
{type === 'favourite+reblog' ? (
|
__html: enhanceContent(content, {
|
||||||
<>
|
emojis,
|
||||||
<Icon icon="rocket" size="xl" alt={type} class="reblog-icon" />
|
}),
|
||||||
<Icon icon="heart" size="xl" alt={type} class="favourite-icon" />
|
}}
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Icon
|
|
||||||
icon={NOTIFICATION_ICONS[type] || 'notification'}
|
|
||||||
size="xl"
|
|
||||||
alt={type}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<p class="insignificant">
|
||||||
</div>
|
<time datetime={publishedAtDate.toISOString()}>
|
||||||
<div class="notification-content">
|
{niceDateTime(publishedAtDate)}
|
||||||
{type !== 'mention' && (
|
</time>
|
||||||
|
{updatedAt && updatedAtText !== publishedDateText && (
|
||||||
<>
|
<>
|
||||||
<p>
|
|
||||||
{!/poll|update/i.test(type) && (
|
|
||||||
<>
|
|
||||||
{_accounts?.length > 1 ? (
|
|
||||||
<>
|
|
||||||
<b>{_accounts.length} people</b>{' '}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<NameText account={account} showAvatar />{' '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{text}
|
|
||||||
{type === 'mention' && (
|
|
||||||
<span class="insignificant">
|
|
||||||
{' '}
|
{' '}
|
||||||
•{' '}
|
•{' '}
|
||||||
<RelativeTime
|
<span class="ib">
|
||||||
datetime={notification.createdAt}
|
Updated{' '}
|
||||||
format="micro"
|
<time datetime={updatedAtDate.toISOString()}>
|
||||||
/>
|
{niceDateTime(updatedAtDate)}
|
||||||
|
</time>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{type === 'follow_request' && (
|
|
||||||
<FollowRequestButtons
|
|
||||||
accountID={account.id}
|
|
||||||
onChange={() => {
|
|
||||||
loadNotifications(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{_accounts?.length > 1 && (
|
|
||||||
<p class="avatars-stack">
|
|
||||||
{_accounts.map((account, i) => (
|
|
||||||
<>
|
|
||||||
<a
|
|
||||||
href={account.url}
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="account-avatar-stack"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
states.showAccount = account;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
url={account.avatarStatic}
|
|
||||||
size={
|
|
||||||
_accounts.length <= 10
|
|
||||||
? 'xxl'
|
|
||||||
: _accounts.length < 100
|
|
||||||
? 'xl'
|
|
||||||
: _accounts.length < 1000
|
|
||||||
? 'l'
|
|
||||||
: _accounts.length < 2000
|
|
||||||
? 'm'
|
|
||||||
: 's' // My god, this person is popular!
|
|
||||||
}
|
|
||||||
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>{' '}
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<div class="announcement-reactions" hidden>
|
||||||
{status && (
|
{reactions.map((reaction) => {
|
||||||
<Link
|
const { name, count, me, staticUrl, url } = reaction;
|
||||||
class={`status-link status-type-${type}`}
|
|
||||||
to={
|
|
||||||
instance
|
|
||||||
? `/${instance}/s/${actualStatusID}`
|
|
||||||
: `/s/${actualStatusID}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Status statusID={actualStatusID} size="s" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FollowRequestButtons({ accountID, onChange }) {
|
|
||||||
const { masto } = api();
|
|
||||||
const [uiState, setUIState] = useState('default');
|
|
||||||
return (
|
return (
|
||||||
<p>
|
<button type="button" class={`plain4 small ${me ? 'reacted' : ''}`}>
|
||||||
<button
|
{url || staticUrl ? (
|
||||||
type="button"
|
<img src={url || staticUrl} alt={name} width="16" height="16" />
|
||||||
disabled={uiState === 'loading'}
|
) : (
|
||||||
onClick={() => {
|
<span>{name}</span>
|
||||||
setUIState('loading');
|
)}{' '}
|
||||||
(async () => {
|
<span class="count">{shortenNumber(count)}</span>
|
||||||
try {
|
|
||||||
await masto.v1.followRequests.authorize(accountID);
|
|
||||||
onChange();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setUIState('default');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</button>{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={uiState === 'loading'}
|
|
||||||
class="light danger"
|
|
||||||
onClick={() => {
|
|
||||||
setUIState('loading');
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await masto.v1.followRequests.reject(accountID);
|
|
||||||
onChange();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setUIState('default');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reject
|
|
||||||
</button>
|
</button>
|
||||||
<Loader hidden={uiState !== 'loading'} />
|
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
|
</div>
|
||||||
function groupNotifications(notifications) {
|
</div>
|
||||||
// Create new flat list of notifications
|
|
||||||
// Combine sibling notifications based on type and status id
|
|
||||||
// Concat all notification.account into an array of _accounts
|
|
||||||
const notificationsMap = {};
|
|
||||||
const cleanNotifications = [];
|
|
||||||
for (let i = 0, j = 0; i < notifications.length; i++) {
|
|
||||||
const notification = notifications[i];
|
|
||||||
const { status, account, type, createdAt } = notification;
|
|
||||||
const date = new Date(createdAt).toLocaleDateString();
|
|
||||||
let virtualType = type;
|
|
||||||
if (type === 'favourite' || type === 'reblog') {
|
|
||||||
virtualType = 'favourite+reblog';
|
|
||||||
}
|
|
||||||
const key = `${status?.id}-${virtualType}-${date}`;
|
|
||||||
const mappedNotification = notificationsMap[key];
|
|
||||||
if (virtualType === 'follow_request') {
|
|
||||||
cleanNotifications[j++] = notification;
|
|
||||||
} else if (mappedNotification?.account) {
|
|
||||||
const mappedAccount = mappedNotification._accounts.find(
|
|
||||||
(a) => a.id === account.id,
|
|
||||||
);
|
);
|
||||||
if (mappedAccount) {
|
|
||||||
mappedAccount._types.push(type);
|
|
||||||
mappedAccount._types.sort().reverse();
|
|
||||||
} else {
|
|
||||||
account._types = [type];
|
|
||||||
mappedNotification._accounts.push(account);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
account._types = [type];
|
|
||||||
let n = (notificationsMap[key] = {
|
|
||||||
...notification,
|
|
||||||
type: virtualType,
|
|
||||||
_accounts: [account],
|
|
||||||
});
|
|
||||||
cleanNotifications[j++] = n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cleanNotifications;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(Notifications);
|
export default memo(Notifications);
|
||||||
|
|
|
@ -43,3 +43,61 @@ ul.link-list.hashtag-list li a {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-popover-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.search-popover {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
/* right: 8px; */
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border: 1px solid var(--outline-color);
|
||||||
|
box-shadow: 0 4px 24px var(--drop-shadow-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: appear-smooth 0.2s ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.search-popover[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search-popover-item {
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 16px 8px 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search-popover-item[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search-popover-item:is(:hover, :focus, .focus) {
|
||||||
|
background-color: var(--button-bg-color);
|
||||||
|
color: var(--button-text-color);
|
||||||
|
}
|
||||||
|
.search-popover-item :is(mark, q) {
|
||||||
|
background-color: var(--bg-faded-blur-color);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.search-popover-item:is(:hover, :focus, .focus) :is(mark, q) {
|
||||||
|
background-color: var(--button-bg-color);
|
||||||
|
}
|
||||||
|
.search-popover:hover .search-popover-item.focus:not(:hover, :focus),
|
||||||
|
.search-popover:hover
|
||||||
|
.search-popover-item.focus:not(:hover, :focus)
|
||||||
|
:is(mark, q) {
|
||||||
|
background-color: unset;
|
||||||
|
color: unset;
|
||||||
|
}
|
||||||
|
.search-popover-item > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.search-popover-item:is(:hover, :focus, .focus) > .icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import './search.css';
|
import './search.css';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { forwardRef } from 'preact/compat';
|
||||||
|
import { useEffect, useImperativeHandle, useRef, useState } from 'preact/hooks';
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import AccountBlock from '../components/account-block';
|
import AccountBlock from '../components/account-block';
|
||||||
|
@ -18,25 +19,44 @@ function Search(props) {
|
||||||
instance: params.instance,
|
instance: params.instance,
|
||||||
});
|
});
|
||||||
const [uiState, setUiState] = useState('default');
|
const [uiState, setUiState] = useState('default');
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const searchFieldRef = useRef();
|
const searchFormRef = useRef();
|
||||||
const q = props?.query || searchParams.get('q');
|
const q = props?.query || searchParams.get('q');
|
||||||
useTitle(q ? `Search: ${q}` : 'Search', `/search`);
|
const type = props?.type || searchParams.get('type');
|
||||||
|
useTitle(
|
||||||
|
q
|
||||||
|
? `Search: ${q}${
|
||||||
|
type
|
||||||
|
? ` (${
|
||||||
|
{
|
||||||
|
statuses: 'Posts',
|
||||||
|
accounts: 'Accounts',
|
||||||
|
hashtags: 'Hashtags',
|
||||||
|
}[type]
|
||||||
|
})`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
: 'Search',
|
||||||
|
`/search`,
|
||||||
|
);
|
||||||
|
|
||||||
const [statusResults, setStatusResults] = useState([]);
|
const [statusResults, setStatusResults] = useState([]);
|
||||||
const [accountResults, setAccountResults] = useState([]);
|
const [accountResults, setAccountResults] = useState([]);
|
||||||
const [hashtagResults, setHashtagResults] = useState([]);
|
const [hashtagResults, setHashtagResults] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
searchFieldRef.current?.focus?.();
|
// searchFieldRef.current?.focus?.();
|
||||||
|
// searchFormRef.current?.focus?.();
|
||||||
if (q) {
|
if (q) {
|
||||||
searchFieldRef.current.value = q;
|
// searchFieldRef.current.value = q;
|
||||||
|
searchFormRef.current?.setValue?.(q);
|
||||||
|
|
||||||
setUiState('loading');
|
setUiState('loading');
|
||||||
(async () => {
|
(async () => {
|
||||||
const results = await masto.v2.search({
|
const results = await masto.v2.search({
|
||||||
q,
|
q,
|
||||||
limit: 20,
|
limit: type ? 40 : 5,
|
||||||
resolve: authenticated,
|
resolve: authenticated,
|
||||||
|
type,
|
||||||
});
|
});
|
||||||
console.log(results);
|
console.log(results);
|
||||||
setStatusResults(results.statuses);
|
setStatusResults(results.statuses);
|
||||||
|
@ -45,7 +65,7 @@ function Search(props) {
|
||||||
setUiState('default');
|
setUiState('default');
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}, [q, instance]);
|
}, [q, type, instance]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="search-page" class="deck-container">
|
<div id="search-page" class="deck-container">
|
||||||
|
@ -55,50 +75,83 @@ function Search(props) {
|
||||||
<div class="header-side">
|
<div class="header-side">
|
||||||
<NavMenu />
|
<NavMenu />
|
||||||
</div>
|
</div>
|
||||||
<form
|
<SearchForm ref={searchFormRef} />
|
||||||
onSubmit={(e) => {
|
<div class="header-side"> </div>
|
||||||
e.preventDefault();
|
|
||||||
const { q } = e.target;
|
|
||||||
if (q.value) {
|
|
||||||
setSearchParams({ q: q.value });
|
|
||||||
} else {
|
|
||||||
setSearchParams({});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={searchFieldRef}
|
|
||||||
name="q"
|
|
||||||
type="search"
|
|
||||||
autofocus
|
|
||||||
placeholder="Search"
|
|
||||||
onSearch={(e) => {
|
|
||||||
if (!e.target.value) {
|
|
||||||
setSearchParams({});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<div class="header-side" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
{!!q && (
|
||||||
|
<div class="filter-bar">
|
||||||
|
{!!type && <Link to={`/search${q ? `?q=${q}` : ''}`}>‹ All</Link>}
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: 'Accounts',
|
||||||
|
type: 'accounts',
|
||||||
|
to: `/search?q=${q}&type=accounts`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hashtags',
|
||||||
|
type: 'hashtags',
|
||||||
|
to: `/search?q=${q}&type=hashtags`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Posts',
|
||||||
|
type: 'statuses',
|
||||||
|
to: `/search?q=${q}&type=statuses`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.type === type) return -1;
|
||||||
|
if (b.type === type) return 1;
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.map((link) => (
|
||||||
|
<Link to={link.to}>{link.label}</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!!q && uiState !== 'loading' ? (
|
{!!q && uiState !== 'loading' ? (
|
||||||
<>
|
<>
|
||||||
|
{(!type || type === 'accounts') && (
|
||||||
|
<>
|
||||||
|
{type !== 'accounts' && (
|
||||||
<h2 class="timeline-header">Accounts</h2>
|
<h2 class="timeline-header">Accounts</h2>
|
||||||
|
)}
|
||||||
{accountResults.length > 0 ? (
|
{accountResults.length > 0 ? (
|
||||||
|
<>
|
||||||
<ul class="timeline flat accounts-list">
|
<ul class="timeline flat accounts-list">
|
||||||
{accountResults.map((account) => (
|
{accountResults.map((account) => (
|
||||||
<li>
|
<li>
|
||||||
<AccountBlock account={account} instance={instance} />
|
<AccountBlock
|
||||||
|
account={account}
|
||||||
|
instance={instance}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{type !== 'accounts' && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<Link
|
||||||
|
class="plain button"
|
||||||
|
to={`/search?q=${q}&type=accounts`}
|
||||||
|
>
|
||||||
|
See more accounts <Icon icon="arrow-right" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p class="ui-state">No accounts found.</p>
|
<p class="ui-state">No accounts found.</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(!type || type === 'hashtags') && (
|
||||||
|
<>
|
||||||
|
{type !== 'hashtags' && (
|
||||||
<h2 class="timeline-header">Hashtags</h2>
|
<h2 class="timeline-header">Hashtags</h2>
|
||||||
|
)}
|
||||||
{hashtagResults.length > 0 ? (
|
{hashtagResults.length > 0 ? (
|
||||||
|
<>
|
||||||
<ul class="link-list hashtag-list">
|
<ul class="link-list hashtag-list">
|
||||||
{hashtagResults.map((hashtag) => (
|
{hashtagResults.map((hashtag) => (
|
||||||
<li>
|
<li>
|
||||||
|
@ -115,11 +168,29 @@ function Search(props) {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{type !== 'hashtags' && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<Link
|
||||||
|
class="plain button"
|
||||||
|
to={`/search?q=${q}&type=hashtags`}
|
||||||
|
>
|
||||||
|
See more hashtags <Icon icon="arrow-right" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p class="ui-state">No hashtags found.</p>
|
<p class="ui-state">No hashtags found.</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(!type || type === 'statuses') && (
|
||||||
|
<>
|
||||||
|
{type !== 'statuses' && (
|
||||||
<h2 class="timeline-header">Posts</h2>
|
<h2 class="timeline-header">Posts</h2>
|
||||||
|
)}
|
||||||
{statusResults.length > 0 ? (
|
{statusResults.length > 0 ? (
|
||||||
|
<>
|
||||||
<ul class="timeline">
|
<ul class="timeline">
|
||||||
{statusResults.map((status) => (
|
{statusResults.map((status) => (
|
||||||
<li>
|
<li>
|
||||||
|
@ -136,10 +207,23 @@ function Search(props) {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{type !== 'statuses' && (
|
||||||
|
<div class="ui-state">
|
||||||
|
<Link
|
||||||
|
class="plain button"
|
||||||
|
to={`/search?q=${q}&type=statuses`}
|
||||||
|
>
|
||||||
|
See more posts <Icon icon="arrow-right" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p class="ui-state">No posts found.</p>
|
<p class="ui-state">No posts found.</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : uiState === 'loading' ? (
|
) : uiState === 'loading' ? (
|
||||||
<p class="ui-state">
|
<p class="ui-state">
|
||||||
<Loader abrupt />
|
<Loader abrupt />
|
||||||
|
@ -156,3 +240,209 @@ function Search(props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Search;
|
export default Search;
|
||||||
|
|
||||||
|
const SearchForm = forwardRef((props, ref) => {
|
||||||
|
const { instance } = api();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [searchMenuOpen, setSearchMenuOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState(searchParams.q || '');
|
||||||
|
const formRef = useRef(null);
|
||||||
|
|
||||||
|
const searchFieldRef = useRef(null);
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
setValue: (value) => {
|
||||||
|
setQuery(value);
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
searchFieldRef.current.focus();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
class="search-popover-container"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
setSearchParams({
|
||||||
|
q: query,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSearchParams({});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={searchFieldRef}
|
||||||
|
value={query}
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
// autofocus
|
||||||
|
placeholder="Search"
|
||||||
|
onSearch={(e) => {
|
||||||
|
if (!e.target.value) {
|
||||||
|
setSearchParams({});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onInput={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setSearchMenuOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setSearchMenuOpen(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
setSearchMenuOpen(false);
|
||||||
|
}, 100);
|
||||||
|
formRef.current
|
||||||
|
?.querySelector('.search-popover-item.focus')
|
||||||
|
?.classList.remove('focus');
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
const { key } = e;
|
||||||
|
switch (key) {
|
||||||
|
case 'Escape':
|
||||||
|
setSearchMenuOpen(false);
|
||||||
|
break;
|
||||||
|
case 'Down':
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchMenuOpen) {
|
||||||
|
const focusItem = formRef.current.querySelector(
|
||||||
|
'.search-popover-item.focus',
|
||||||
|
);
|
||||||
|
if (focusItem) {
|
||||||
|
let nextItem = focusItem.nextElementSibling;
|
||||||
|
while (nextItem && nextItem.hidden) {
|
||||||
|
nextItem = nextItem.nextElementSibling;
|
||||||
|
}
|
||||||
|
if (nextItem) {
|
||||||
|
nextItem.classList.add('focus');
|
||||||
|
const siblings = Array.from(
|
||||||
|
nextItem.parentElement.children,
|
||||||
|
).filter((el) => el !== nextItem);
|
||||||
|
siblings.forEach((el) => {
|
||||||
|
el.classList.remove('focus');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const firstItem = formRef.current.querySelector(
|
||||||
|
'.search-popover-item',
|
||||||
|
);
|
||||||
|
if (firstItem) {
|
||||||
|
firstItem.classList.add('focus');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Up':
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchMenuOpen) {
|
||||||
|
const focusItem = document.querySelector(
|
||||||
|
'.search-popover-item.focus',
|
||||||
|
);
|
||||||
|
if (focusItem) {
|
||||||
|
let prevItem = focusItem.previousElementSibling;
|
||||||
|
while (prevItem && prevItem.hidden) {
|
||||||
|
prevItem = prevItem.previousElementSibling;
|
||||||
|
}
|
||||||
|
if (prevItem) {
|
||||||
|
prevItem.classList.add('focus');
|
||||||
|
const siblings = Array.from(
|
||||||
|
prevItem.parentElement.children,
|
||||||
|
).filter((el) => el !== prevItem);
|
||||||
|
siblings.forEach((el) => {
|
||||||
|
el.classList.remove('focus');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const lastItem = document.querySelector(
|
||||||
|
'.search-popover-item:last-child',
|
||||||
|
);
|
||||||
|
if (lastItem) {
|
||||||
|
lastItem.classList.add('focus');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
if (searchMenuOpen) {
|
||||||
|
const focusItem = document.querySelector(
|
||||||
|
'.search-popover-item.focus',
|
||||||
|
);
|
||||||
|
if (focusItem) {
|
||||||
|
e.preventDefault();
|
||||||
|
focusItem.click();
|
||||||
|
}
|
||||||
|
setSearchMenuOpen(false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="search-popover" hidden={!searchMenuOpen || !query}>
|
||||||
|
{!!query &&
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Posts with <q>{query}</q>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
|
||||||
|
hidden: /^https?:/.test(query),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Posts tagged with <mark>#{query.replace(/^#/, '')}</mark>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
to: `/${instance}/t/${query.replace(/^#/, '')}`,
|
||||||
|
hidden:
|
||||||
|
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
|
||||||
|
top: /^#/.test(query),
|
||||||
|
type: 'link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Look up <mark>{query}</mark>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
to: `/${query}`,
|
||||||
|
hidden: !/^https?:/.test(query),
|
||||||
|
top: /^https?:/.test(query),
|
||||||
|
type: 'link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Accounts with <q>{query}</q>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.top && !b.top) return -1;
|
||||||
|
if (!a.top && b.top) return 1;
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.map(({ label, to, hidden, type }) => (
|
||||||
|
<Link to={to} class="search-popover-item" hidden={hidden}>
|
||||||
|
<Icon
|
||||||
|
icon={type === 'link' ? 'arrow-right' : 'search'}
|
||||||
|
class="more-insignificant"
|
||||||
|
/>
|
||||||
|
<span>{label}</span>{' '}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
@ -147,6 +147,18 @@ function Settings({ onClose }) {
|
||||||
<h3>Experiments</h3>
|
<h3>Experiments</h3>
|
||||||
<section>
|
<section>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={snapStates.settings.autoRefresh}
|
||||||
|
onChange={(e) => {
|
||||||
|
states.settings.autoRefresh = e.target.checked;
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
Auto refresh timeline posts
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -42,7 +42,8 @@ import useTitle from '../utils/useTitle';
|
||||||
import getInstanceStatusURL from './../utils/get-instance-status-url';
|
import getInstanceStatusURL from './../utils/get-instance-status-url';
|
||||||
|
|
||||||
const LIMIT = 40;
|
const LIMIT = 40;
|
||||||
const THREAD_LIMIT = 20;
|
const SUBCOMMENTS_OPEN_ALL_LIMIT = 10;
|
||||||
|
const MAX_WEIGHT = 5;
|
||||||
|
|
||||||
let cachedRepliesToggle = {};
|
let cachedRepliesToggle = {};
|
||||||
let cachedStatusesMap = {};
|
let cachedStatusesMap = {};
|
||||||
|
@ -95,7 +96,7 @@ function StatusPage(params) {
|
||||||
setHeroStatus(status);
|
setHeroStatus(status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert('Unable to load status.');
|
alert('Unable to load post.');
|
||||||
location.hash = closeLink;
|
location.hash = closeLink;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
@ -149,6 +150,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
const mediaParam = searchParams.get('media');
|
const mediaParam = searchParams.get('media');
|
||||||
const showMedia = parseInt(mediaParam, 10) > 0;
|
const showMedia = parseInt(mediaParam, 10) > 0;
|
||||||
const [viewMode, setViewMode] = useState(searchParams.get('view'));
|
const [viewMode, setViewMode] = useState(searchParams.get('view'));
|
||||||
|
const translate = !!parseInt(searchParams.get('translate'));
|
||||||
const { masto, instance } = api({ instance: propInstance });
|
const { masto, instance } = api({ instance: propInstance });
|
||||||
const {
|
const {
|
||||||
masto: currentMasto,
|
masto: currentMasto,
|
||||||
|
@ -161,6 +163,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
const [uiState, setUIState] = useState('default');
|
const [uiState, setUIState] = useState('default');
|
||||||
const heroStatusRef = useRef();
|
const heroStatusRef = useRef();
|
||||||
const sKey = statusKey(id, instance);
|
const sKey = statusKey(id, instance);
|
||||||
|
const totalDescendants = useRef(0);
|
||||||
|
|
||||||
const scrollableRef = useRef();
|
const scrollableRef = useRef();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -243,6 +246,8 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
const context = await contextFetch;
|
const context = await contextFetch;
|
||||||
const { ancestors, descendants } = context;
|
const { ancestors, descendants } = context;
|
||||||
|
|
||||||
|
totalDescendants.current = descendants?.length || 0;
|
||||||
|
|
||||||
ancestors.forEach((status) => {
|
ancestors.forEach((status) => {
|
||||||
saveStatus(status, instance, {
|
saveStatus(status, instance, {
|
||||||
skipThreading: true,
|
skipThreading: true,
|
||||||
|
@ -292,6 +297,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
account: _r.account,
|
account: _r.account,
|
||||||
repliesCount: _r.repliesCount,
|
repliesCount: _r.repliesCount,
|
||||||
content: _r.content,
|
content: _r.content,
|
||||||
|
weight: calcStatusWeight(_r),
|
||||||
replies: expandReplies(_r.__replies),
|
replies: expandReplies(_r.__replies),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -303,13 +309,19 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
isThread: ancestorsIsThread,
|
isThread: ancestorsIsThread,
|
||||||
accountID: s.account.id,
|
accountID: s.account.id,
|
||||||
repliesCount: s.repliesCount,
|
repliesCount: s.repliesCount,
|
||||||
|
weight: calcStatusWeight(s),
|
||||||
})),
|
})),
|
||||||
{ id, accountID: heroStatus.account.id },
|
{
|
||||||
|
id,
|
||||||
|
accountID: heroStatus.account.id,
|
||||||
|
weight: calcStatusWeight(heroStatus),
|
||||||
|
},
|
||||||
...nestedDescendants.map((s) => ({
|
...nestedDescendants.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
accountID: s.account.id,
|
accountID: s.account.id,
|
||||||
descendant: true,
|
descendant: true,
|
||||||
thread: s.account.id === heroStatus.account.id,
|
thread: s.account.id === heroStatus.account.id,
|
||||||
|
weight: calcStatusWeight(s),
|
||||||
replies: expandReplies(s.__replies),
|
replies: expandReplies(s.__replies),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
@ -411,6 +423,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
states.reloadStatusPage = 0;
|
states.reloadStatusPage = 0;
|
||||||
cachedStatusesMap = {};
|
cachedStatusesMap = {};
|
||||||
cachedRepliesToggle = {};
|
cachedRepliesToggle = {};
|
||||||
|
statusWeightCache.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -457,7 +470,6 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
return statuses.length - limit;
|
return statuses.length - limit;
|
||||||
}, [statuses.length, limit]);
|
}, [statuses.length, limit]);
|
||||||
|
|
||||||
const hasManyStatuses = statuses.length > THREAD_LIMIT;
|
|
||||||
const hasDescendants = statuses.some((s) => s.descendant);
|
const hasDescendants = statuses.some((s) => s.descendant);
|
||||||
const ancestors = statuses.filter((s) => s.ancestor);
|
const ancestors = statuses.filter((s) => s.ancestor);
|
||||||
|
|
||||||
|
@ -650,7 +662,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Status{' '}
|
Post{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ancestors-indicator light small"
|
class="ancestors-indicator light small"
|
||||||
|
@ -777,6 +789,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
thread,
|
thread,
|
||||||
replies,
|
replies,
|
||||||
repliesCount,
|
repliesCount,
|
||||||
|
weight,
|
||||||
} = status;
|
} = status;
|
||||||
const isHero = statusID === id;
|
const isHero = statusID === id;
|
||||||
return (
|
return (
|
||||||
|
@ -801,6 +814,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
withinContext
|
withinContext
|
||||||
size="l"
|
size="l"
|
||||||
enableTranslate
|
enableTranslate
|
||||||
|
forceTranslate={translate}
|
||||||
/>
|
/>
|
||||||
</InView>
|
</InView>
|
||||||
{uiState !== 'loading' && !authenticated ? (
|
{uiState !== 'loading' && !authenticated ? (
|
||||||
|
@ -896,10 +910,13 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
{descendant && replies?.length > 0 && (
|
{descendant && replies?.length > 0 && (
|
||||||
<SubComments
|
<SubComments
|
||||||
instance={instance}
|
instance={instance}
|
||||||
hasManyStatuses={hasManyStatuses}
|
|
||||||
replies={replies}
|
replies={replies}
|
||||||
hasParentThread={thread}
|
hasParentThread={thread}
|
||||||
level={1}
|
level={1}
|
||||||
|
accWeight={weight}
|
||||||
|
openAll={
|
||||||
|
totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{uiState === 'loading' &&
|
{uiState === 'loading' &&
|
||||||
|
@ -959,7 +976,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
)}
|
)}
|
||||||
{uiState === 'error' && (
|
{uiState === 'error' && (
|
||||||
<p class="ui-state">
|
<p class="ui-state">
|
||||||
Unable to load status
|
Unable to load post
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<button
|
<button
|
||||||
|
@ -979,31 +996,14 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubComments({
|
function SubComments({
|
||||||
hasManyStatuses,
|
|
||||||
replies,
|
replies,
|
||||||
instance,
|
instance,
|
||||||
hasParentThread,
|
hasParentThread,
|
||||||
level,
|
level,
|
||||||
|
accWeight,
|
||||||
|
openAll,
|
||||||
}) {
|
}) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Set isBrief = true:
|
|
||||||
// - if less than or 2 replies
|
|
||||||
// - if replies have no sub-replies
|
|
||||||
// - if total number of characters of content from replies is less than 500
|
|
||||||
let isBrief = false;
|
|
||||||
if (replies.length <= 2) {
|
|
||||||
const containsSubReplies = replies.some(
|
|
||||||
(r) => r.repliesCount > 0 || r.replies?.length > 0,
|
|
||||||
);
|
|
||||||
if (!containsSubReplies) {
|
|
||||||
let totalLength = replies.reduce((acc, reply) => {
|
|
||||||
const { content } = reply;
|
|
||||||
const length = htmlContentLength(content);
|
|
||||||
return acc + length;
|
|
||||||
}, 0);
|
|
||||||
isBrief = totalLength < 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total comments count, including sub-replies
|
// Total comments count, including sub-replies
|
||||||
const diveDeep = (replies) => {
|
const diveDeep = (replies) => {
|
||||||
|
@ -1022,8 +1022,21 @@ function SubComments({
|
||||||
.filter((a, i, arr) => arr.findIndex((b) => b.id === a.id) === i)
|
.filter((a, i, arr) => arr.findIndex((b) => b.id === a.id) === i)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
|
|
||||||
const open =
|
const totalWeight = useMemo(() => {
|
||||||
(!hasParentThread || replies.length === 1) && (isBrief || !hasManyStatuses);
|
return replies?.reduce((acc, reply) => {
|
||||||
|
return acc + reply?.weight;
|
||||||
|
}, accWeight);
|
||||||
|
}, [accWeight, replies?.length]);
|
||||||
|
|
||||||
|
let open = false;
|
||||||
|
if (openAll) {
|
||||||
|
open = true;
|
||||||
|
} else if (totalWeight <= MAX_WEIGHT) {
|
||||||
|
open = true;
|
||||||
|
} else if (!hasParentThread && totalComments === 1) {
|
||||||
|
const shortReply = calcStatusWeight(replies[0]) < 2;
|
||||||
|
if (shortReply) open = true;
|
||||||
|
}
|
||||||
const openBefore = cachedRepliesToggle[replies[0].id];
|
const openBefore = cachedRepliesToggle[replies[0].id];
|
||||||
|
|
||||||
const handleMediaClick = useCallback((e, i, media, status) => {
|
const handleMediaClick = useCallback((e, i, media, status) => {
|
||||||
|
@ -1035,8 +1048,22 @@ function SubComments({
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const detailsRef = useRef();
|
||||||
|
useEffect(() => {
|
||||||
|
function handleScroll(e) {
|
||||||
|
e.target.dataset.scrollLeft = e.target.scrollLeft;
|
||||||
|
}
|
||||||
|
detailsRef.current?.addEventListener('scroll', handleScroll, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
detailsRef.current?.removeEventListener('scroll', handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details
|
<details
|
||||||
|
ref={detailsRef}
|
||||||
class="replies"
|
class="replies"
|
||||||
open={openBefore || open}
|
open={openBefore || open}
|
||||||
onToggle={(e) => {
|
onToggle={(e) => {
|
||||||
|
@ -1108,9 +1135,10 @@ function SubComments({
|
||||||
{r.replies?.length && (
|
{r.replies?.length && (
|
||||||
<SubComments
|
<SubComments
|
||||||
instance={instance}
|
instance={instance}
|
||||||
hasManyStatuses={hasManyStatuses}
|
|
||||||
replies={r.replies}
|
replies={r.replies}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
|
accWeight={!open ? r.weight : totalWeight}
|
||||||
|
openAll={openAll}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
@ -1120,4 +1148,26 @@ function SubComments({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MEDIA_VIRTUAL_LENGTH = 140;
|
||||||
|
const POLL_VIRTUAL_LENGTH = 35;
|
||||||
|
const CARD_VIRTUAL_LENGTH = 70;
|
||||||
|
const WEIGHT_SEGMENT = 140;
|
||||||
|
const statusWeightCache = new Map();
|
||||||
|
function calcStatusWeight(status) {
|
||||||
|
const cachedWeight = statusWeightCache.get(status.id);
|
||||||
|
if (cachedWeight) return cachedWeight;
|
||||||
|
const { spoilerText, content, mediaAttachments, poll, card } = status;
|
||||||
|
const length = htmlContentLength(spoilerText + content);
|
||||||
|
const mediaLength = mediaAttachments?.length ? MEDIA_VIRTUAL_LENGTH : 0;
|
||||||
|
const pollLength = (poll?.options?.length || 0) * POLL_VIRTUAL_LENGTH;
|
||||||
|
const cardLength =
|
||||||
|
card && (mediaAttachments?.length || poll?.options?.length)
|
||||||
|
? 0
|
||||||
|
: CARD_VIRTUAL_LENGTH;
|
||||||
|
const totalLength = length + mediaLength + pollLength + cardLength;
|
||||||
|
const weight = totalLength / WEIGHT_SEGMENT;
|
||||||
|
statusWeightCache.set(status.id, weight);
|
||||||
|
return weight;
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(StatusPage);
|
export default memo(StatusPage);
|
||||||
|
|
|
@ -48,7 +48,7 @@ export function initClient({ instance, accessToken }) {
|
||||||
|
|
||||||
// Get the instance information
|
// Get the instance information
|
||||||
// The config is needed for composing
|
// The config is needed for composing
|
||||||
export async function initInstance(client) {
|
export async function initInstance(client, instance) {
|
||||||
const masto = client;
|
const masto = client;
|
||||||
// Request v2, fallback to v1 if fail
|
// Request v2, fallback to v1 if fail
|
||||||
let info;
|
let info;
|
||||||
|
@ -70,16 +70,19 @@ export async function initInstance(client) {
|
||||||
domain,
|
domain,
|
||||||
configuration: { urls: { streaming } = {} } = {},
|
configuration: { urls: { streaming } = {} } = {},
|
||||||
} = info;
|
} = info;
|
||||||
if (uri || domain) {
|
|
||||||
const instances = store.local.getJSON('instances') || {};
|
const instances = store.local.getJSON('instances') || {};
|
||||||
|
if (uri || domain) {
|
||||||
instances[
|
instances[
|
||||||
(domain || uri)
|
(domain || uri)
|
||||||
.replace(/^https?:\/\//, '')
|
.replace(/^https?:\/\//, '')
|
||||||
.replace(/\/+$/, '')
|
.replace(/\/+$/, '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
] = info;
|
] = info;
|
||||||
store.local.setJSON('instances', instances);
|
|
||||||
}
|
}
|
||||||
|
if (instance) {
|
||||||
|
instances[instance.toLowerCase()] = info;
|
||||||
|
}
|
||||||
|
store.local.setJSON('instances', instances);
|
||||||
// This is a weird place to put this but here's updating the masto instance with the streaming API URL set in the configuration
|
// This is a weird place to put this but here's updating the masto instance with the streaming API URL set in the configuration
|
||||||
// Reason: Streaming WebSocket URL may change, unlike the standard API REST URLs
|
// Reason: Streaming WebSocket URL may change, unlike the standard API REST URLs
|
||||||
if (streamingApi || streaming) {
|
if (streamingApi || streaming) {
|
||||||
|
|
|
@ -41,7 +41,10 @@ function enhanceContent(content, opts = {}) {
|
||||||
// Convert :shortcode: to <img />
|
// Convert :shortcode: to <img />
|
||||||
let textNodes = extractTextNodes(dom);
|
let textNodes = extractTextNodes(dom);
|
||||||
textNodes.forEach((node) => {
|
textNodes.forEach((node) => {
|
||||||
let html = node.nodeValue.replace(/</g, '<').replace(/>/g, '>');
|
let html = node.nodeValue
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
if (emojis) {
|
if (emojis) {
|
||||||
html = emojifyText(html, emojis);
|
html = emojifyText(html, emojis);
|
||||||
}
|
}
|
||||||
|
@ -106,7 +109,10 @@ function enhanceContent(content, opts = {}) {
|
||||||
// Convert `code` to <code>code</code>
|
// Convert `code` to <code>code</code>
|
||||||
textNodes = extractTextNodes(dom);
|
textNodes = extractTextNodes(dom);
|
||||||
textNodes.forEach((node) => {
|
textNodes.forEach((node) => {
|
||||||
let html = node.nodeValue.replace(/</g, '<').replace(/>/g, '>');
|
let html = node.nodeValue
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
if (/`[^`]+`/g.test(html)) {
|
if (/`[^`]+`/g.test(html)) {
|
||||||
html = html.replaceAll(/(`[^]+?`)/g, '<code>$1</code>');
|
html = html.replaceAll(/(`[^]+?`)/g, '<code>$1</code>');
|
||||||
}
|
}
|
||||||
|
@ -122,7 +128,10 @@ function enhanceContent(content, opts = {}) {
|
||||||
rejectFilter: ['A'],
|
rejectFilter: ['A'],
|
||||||
});
|
});
|
||||||
textNodes.forEach((node) => {
|
textNodes.forEach((node) => {
|
||||||
let html = node.nodeValue.replace(/</g, '<').replace(/>/g, '>');
|
let html = node.nodeValue
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
if (/@[a-zA-Z0-9_]+@twitter\.com/g.test(html)) {
|
if (/@[a-zA-Z0-9_]+@twitter\.com/g.test(html)) {
|
||||||
html = html.replaceAll(
|
html = html.replaceAll(
|
||||||
/(@([a-zA-Z0-9_]+)@twitter\.com)/g,
|
/(@([a-zA-Z0-9_]+)@twitter\.com)/g,
|
||||||
|
|
43
src/utils/group-notifications.jsx
Normal file
43
src/utils/group-notifications.jsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
function groupNotifications(notifications) {
|
||||||
|
// Create new flat list of notifications
|
||||||
|
// Combine sibling notifications based on type and status id
|
||||||
|
// Concat all notification.account into an array of _accounts
|
||||||
|
const notificationsMap = {};
|
||||||
|
const cleanNotifications = [];
|
||||||
|
for (let i = 0, j = 0; i < notifications.length; i++) {
|
||||||
|
const notification = notifications[i];
|
||||||
|
const { status, account, type, createdAt } = notification;
|
||||||
|
const date = new Date(createdAt).toLocaleDateString();
|
||||||
|
let virtualType = type;
|
||||||
|
if (type === 'favourite' || type === 'reblog') {
|
||||||
|
virtualType = 'favourite+reblog';
|
||||||
|
}
|
||||||
|
const key = `${status?.id}-${virtualType}-${date}`;
|
||||||
|
const mappedNotification = notificationsMap[key];
|
||||||
|
if (virtualType === 'follow_request') {
|
||||||
|
cleanNotifications[j++] = notification;
|
||||||
|
} else if (mappedNotification?.account) {
|
||||||
|
const mappedAccount = mappedNotification._accounts.find(
|
||||||
|
(a) => a.id === account.id,
|
||||||
|
);
|
||||||
|
if (mappedAccount) {
|
||||||
|
mappedAccount._types.push(type);
|
||||||
|
mappedAccount._types.sort().reverse();
|
||||||
|
} else {
|
||||||
|
account._types = [type];
|
||||||
|
mappedNotification._accounts.push(account);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
account._types = [type];
|
||||||
|
let n = (notificationsMap[key] = {
|
||||||
|
...notification,
|
||||||
|
type: virtualType,
|
||||||
|
_accounts: [account],
|
||||||
|
});
|
||||||
|
cleanNotifications[j++] = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanNotifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default groupNotifications;
|
|
@ -1,6 +1,6 @@
|
||||||
export default function isMastodonLinkMaybe(url) {
|
export default function isMastodonLinkMaybe(url) {
|
||||||
|
const { pathname } = new URL(url);
|
||||||
return (
|
return (
|
||||||
/^https:\/\/.*\/\d+$/i.test(url) ||
|
/^\/.*\/\d+$/i.test(pathname) || /^\/notes\/[a-z0-9]+$/i.test(pathname) // Misskey, Calckey
|
||||||
/^https:\/\/.*\/notes\/[a-z0-9]+$/i.test(url) // Misskey, Calckey
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ const states = proxy({
|
||||||
shortcuts: store.account.get('shortcuts') ?? [],
|
shortcuts: store.account.get('shortcuts') ?? [],
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
|
autoRefresh: store.account.get('settings-autoRefresh') ?? false,
|
||||||
shortcutsViewMode: store.account.get('settings-shortcutsViewMode') ?? null,
|
shortcutsViewMode: store.account.get('settings-shortcutsViewMode') ?? null,
|
||||||
shortcutsColumnsMode:
|
shortcutsColumnsMode:
|
||||||
store.account.get('settings-shortcutsColumnsMode') ?? false,
|
store.account.get('settings-shortcutsColumnsMode') ?? false,
|
||||||
|
@ -64,6 +65,9 @@ subscribeKey(states, 'notificationsLast', (v) => {
|
||||||
subscribe(states, (changes) => {
|
subscribe(states, (changes) => {
|
||||||
console.debug('STATES change', changes);
|
console.debug('STATES change', changes);
|
||||||
for (const [action, path, value, prevValue] of changes) {
|
for (const [action, path, value, prevValue] of changes) {
|
||||||
|
if (path.join('.') === 'settings.autoRefresh') {
|
||||||
|
store.account.set('settings-autoRefresh', !!value);
|
||||||
|
}
|
||||||
if (path.join('.') === 'settings.boostsCarousel') {
|
if (path.join('.') === 'settings.boostsCarousel') {
|
||||||
store.account.set('settings-boostsCarousel', !!value);
|
store.account.set('settings-boostsCarousel', !!value);
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,6 +97,39 @@ export function groupContext(items) {
|
||||||
contexts[contextIndex++] = [item, repliedItem];
|
contexts[contextIndex++] = [item, repliedItem];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check for cross-item contexts
|
||||||
|
// Merge contexts into one if they have a common item (same id)
|
||||||
|
for (let i = 0; i < contexts.length; i++) {
|
||||||
|
for (let j = i + 1; j < contexts.length; j++) {
|
||||||
|
const commonItem = contexts[i].find((t) => contexts[j].includes(t));
|
||||||
|
if (commonItem) {
|
||||||
|
contexts[i] = [...contexts[i], ...contexts[j]];
|
||||||
|
// Remove duplicate items
|
||||||
|
contexts[i] = contexts[i].filter(
|
||||||
|
(item, index, self) =>
|
||||||
|
self.findIndex((t) => t.id === item.id) === index,
|
||||||
|
);
|
||||||
|
contexts.splice(j, 1);
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort items by checking inReplyToId
|
||||||
|
contexts.forEach((context) => {
|
||||||
|
context.sort((a, b) => {
|
||||||
|
if (!a.inReplyToId && !b.inReplyToId) {
|
||||||
|
return new Date(a.createdAt) - new Date(b.createdAt);
|
||||||
|
}
|
||||||
|
if (a.inReplyToId === b.id) return 1;
|
||||||
|
if (b.inReplyToId === a.id) return -1;
|
||||||
|
if (!a.inReplyToId) return -1;
|
||||||
|
if (!b.inReplyToId) return 1;
|
||||||
|
return new Date(a.createdAt) - new Date(b.createdAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (contexts.length) console.log('🧵 Contexts', contexts);
|
if (contexts.length) console.log('🧵 Contexts', contexts);
|
||||||
|
|
||||||
const newItems = [];
|
const newItems = [];
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
import { useEffect, useRef } from 'preact/hooks';
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
|
|
||||||
const noop = () => {};
|
function useInterval(fn, delay, deps, immediate) {
|
||||||
|
const savedCallback = useRef(fn);
|
||||||
function useInterval(callback, delay, immediate) {
|
|
||||||
const savedCallback = useRef(noop);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
savedCallback.current = callback;
|
savedCallback.current = fn;
|
||||||
}, []);
|
}, [deps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!immediate || delay === null || delay === false) return;
|
if (!immediate || delay === null || delay === false) return;
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import { useEffect, useRef } from 'preact/hooks';
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
|
|
||||||
export default function usePageVisibility(fn = () => {}, deps = []) {
|
export default function usePageVisibility(fn = () => {}, deps = []) {
|
||||||
const savedCallback = useRef(fn, deps);
|
const savedCallback = useRef(fn);
|
||||||
|
useEffect(() => {
|
||||||
|
savedCallback.current = fn;
|
||||||
|
}, [deps]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
|
|
Loading…
Reference in a new issue