Merge pull request #122 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-05-11 21:59:39 +08:00 committed by GitHub
commit 27a999f733
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1968 additions and 643 deletions

View file

@ -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
View file

@ -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": {

View file

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -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;

View file

@ -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) {

View file

@ -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,

View file

@ -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);

View file

@ -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"

View file

@ -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}
&rsquo;s status &rsquo;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}

View 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;

View file

@ -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');

View file

@ -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}

View file

@ -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>

View file

@ -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
> >

View 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;

View file

@ -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) => {

View file

@ -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);

View file

@ -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}

View file

@ -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;

View file

@ -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;
}

View file

@ -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}

View file

@ -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);

View file

@ -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}
/> />
); );
} }

View 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;
}

View file

@ -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);
}

View file

@ -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">
{' '} {' '}
{' '} &bull;{' '}
<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);

View file

@ -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;
}

View file

@ -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">&nbsp;</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>
);
});

View file

@ -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

View file

@ -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);

View file

@ -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) {

View file

@ -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, '&lt;').replace(/>/g, '&gt;'); let html = node.nodeValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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, '&lt;').replace(/>/g, '&gt;'); let html = node.nodeValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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, '&lt;').replace(/>/g, '&gt;'); let html = node.nodeValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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,

View 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;

View file

@ -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
); );
} }

View file

@ -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);
} }

View file

@ -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 = [];

View file

@ -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;

View file

@ -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 = () => {