Compare commits

..

82 commits

Author SHA1 Message Date
Natsu Kagami 9d813802a9
Merge remote-tracking branch 'upstream/main' 2023-09-27 23:37:40 +02:00
Lim Chee Aun 173cad2275 So all this while been using the wrong API for autocomplete mentions
🫣🫣🫣
2023-09-27 13:37:12 +08:00
Lim Chee Aun 0403fc35f4 Update enafore link + slight README change 2023-09-27 13:36:14 +08:00
Lim Chee Aun 077b655c44 Don't translate posts with only custom emojis 2023-09-26 16:23:41 +08:00
Lim Chee Aun eeb89212d2 noopener noreferrer all the links 2023-09-26 10:55:36 +08:00
Lim Chee Aun cb04659ab1 Allow filters for posts in carousels 2023-09-25 10:20:32 +08:00
Lim Chee Aun d478dbddba Remove new lines from newline-separated hashtag stuffing
Uses even less vertical space
2023-09-24 18:33:08 +08:00
Lim Chee Aun cb36308790 Collapse grouped conversations too 2023-09-24 18:11:23 +08:00
Lim Chee Aun d4dca0e81f Support non-rectangular custom emojis 😩
Platforms like Misskey have irregularly-shaped custom emojis (emojos?)

- So far this handles horizontally-wide emojis, not tall ones (haven't seen any)
- text-overflow: ellipsis is not used because it can't ellipsis-fy wide emoji images
2023-09-24 15:45:01 +08:00
Lim Chee Aun f8fc24aca4 Fix Read More wrongly positioned on Safari 2023-09-24 10:18:01 +08:00
Lim Chee Aun 7ba5ee5fe2 Don't call familiar_followers if not same instance as logged-in instance 2023-09-23 22:38:29 +08:00
Lim Chee Aun 4c3666df6a Remove isHovering 2023-09-23 19:51:53 +08:00
Lim Chee Aun e3b0c31798 Add Post Translations to Privacy Policy 2023-09-23 19:46:14 +08:00
Lim Chee Aun da03de4115 Add multiple translation instances as fallbacks with retries 2023-09-23 19:45:54 +08:00
Lim Chee Aun 34fcf5e8bd Fix result undefined 2023-09-23 19:45:18 +08:00
Lim Chee Aun d6499cf7fd Subtle text shadowing 2023-09-23 19:16:44 +08:00
Lim Chee Aun 1e9f0bdf39 Slight restyle for shiny pill 2023-09-23 19:16:32 +08:00
Lim Chee Aun cd3ab50a18 Make 'Read more' buttons look more consistent everywhere
Too many cooks spoil the broth
2023-09-23 19:14:11 +08:00
Lim Chee Aun f6ab5e9afa Thicker badge icon
And somehow the old one is too pixelated
2023-09-23 15:59:41 +08:00
Lim Chee Aun b1dec8810b Change video icon style again, might as well make it more consistent this time 2023-09-23 14:39:05 +08:00
Lim Chee Aun a10e2804ba Allow RTL for text inside cards 2023-09-23 12:58:12 +08:00
Lim Chee Aun bd7e099f6e Larger status card inside large status 2023-09-23 12:57:19 +08:00
Lim Chee Aun 3d06662559 Prevent nested 'Read more's 2023-09-23 12:56:55 +08:00
Lim Chee Aun 1f584f945a Disable all the auto*** in search field 2023-09-22 20:39:05 +08:00
Lim Chee Aun a816b69ee9 Remove the @ if short or empty display name
Experimental as the '@' seems superfluous
2023-09-22 20:38:36 +08:00
Lim Chee Aun 85a4b382da Beautify play icon a bit 2023-09-22 00:15:17 +08:00
Lim Chee Aun 7ec1cd1e3d Add a span 2023-09-22 00:15:03 +08:00
Lim Chee Aun 5661729748 Select input text whenever open global search command UI 2023-09-21 22:31:12 +08:00
Lim Chee Aun 551de5a37c Embrace :visited because it's the web 2023-09-21 22:01:00 +08:00
Lim Chee Aun 38bd5c0b5d A bit more aesthetic touches for 'Read more' buttons 2023-09-21 21:56:04 +08:00
Lim Chee Aun 9387e37baa Lower contrast for shiny pill, higher contrast for toasts
Maybe shouldn't call it shiny pill anymore lol
2023-09-21 21:55:30 +08:00
Lim Chee Aun baca2b5851 For debugging 2023-09-21 19:44:26 +08:00
Lim Chee Aun 7e01b4a33a Ignore cmd/ctrl/shift/alt keys + middle clicks 2023-09-21 13:03:16 +08:00
Lim Chee Aun 674c99a05d Fix Lemmy post links not working
Because it's self-referential
2023-09-21 13:02:40 +08:00
Lim Chee Aun b3501d158f Fix push notification badge showing white box on Android 2023-09-20 17:28:42 +08:00
Lim Chee Aun c955427d8f Handle moved account cases 2023-09-20 17:28:08 +08:00
Lim Chee Aun 56e846bec6 Add more data-read-more UIs 2023-09-20 17:27:54 +08:00
Lim Chee Aun 4acfb2a1cf Use useTruncated for notification items 2023-09-19 21:53:59 +08:00
Lim Chee Aun f9b2ab3b94 Refactor truncated class
Also removed the hack fix, not sure why/how it's even fixed.
Don't even know how to explain the logic.
Will revisit and investigate more if the bug happens.

This `useTruncated` can now be reusable.
2023-09-19 16:27:22 +08:00
Lim Chee Aun 42f9483491 Test propagate contextmenu event
No long press yet
2023-09-19 00:46:14 +08:00
Lim Chee Aun fe80215325 Prevent repeated description for alt+figcaption 2023-09-19 00:45:43 +08:00
Lim Chee Aun f7ffce1b46 Add tooltip to show percentage values of posting stats 2023-09-18 19:23:49 +08:00
Lim Chee Aun 64db69af63 Add small gaps between bars 2023-09-18 19:23:29 +08:00
Lim Chee Aun 59dae782b2 Fix typo 🙈🙈🙈 2023-09-17 12:54:48 +08:00
Lim Chee Aun dafff4b635 Show remaining count if exceed the avatars limit 2023-09-16 23:42:49 +08:00
Lim Chee Aun 887503e40b Auto-list composing
Automatically create lists like "- " or "12. " when press Enter
2023-09-16 22:57:35 +08:00
Lim Chee Aun 1a714d214b Fix not all classes removed
This is due to DomTokenList being dynamic, looping it while removing items from it cause wrong indices
2023-09-16 15:45:09 +08:00
Lim Chee Aun 941d2efeb1 Convert posting stats box into a link to account page 2023-09-16 14:48:31 +08:00
Lim Chee Aun 908efb17ff Use onClose 2023-09-16 14:47:55 +08:00
Lim Chee Aun 7d28744234 Fix some links have same class names from the app itself
Srsly need to sanitize the HTML one day
2023-09-16 14:47:35 +08:00
Lim Chee Aun 679fba4f66 Make relationship ui state update faster 2023-09-16 09:43:26 +08:00
Lim Chee Aun ad831fae35 Fix disabled follow button 2023-09-16 08:52:24 +08:00
Lim Chee Aun e102a9f925 Combine familiar followers into followers section 2023-09-15 23:59:27 +08:00
Lim Chee Aun 9571271d83 Experimental posting stats for non-following accounts
Also recode+redesign the multiple metadata boxes in account info
2023-09-15 22:15:41 +08:00
Lim Chee Aun b116cbfe8c Only set data attr if there are shortcuts 2023-09-15 21:12:04 +08:00
Lim Chee Aun b1030cb38a Make figcaption blur too if under content warning 2023-09-15 18:06:55 +08:00
Lim Chee Aun 72438bbf06 Search results pagination not allowed when not authed 2023-09-15 13:08:34 +08:00
Lim Chee Aun f3b81bc540 Fix focus gone wrong 2023-09-15 01:10:58 +08:00
Lim Chee Aun 020d8e3631 Allow settings for unauthenticated sessions 2023-09-15 00:28:20 +08:00
Lim Chee Aun dac07a35d8 Remove unneeded import 2023-09-14 23:28:01 +08:00
Lim Chee Aun 6db40d7d3e Fix ref not defined 2023-09-14 23:23:22 +08:00
Lim Chee Aun 0b5693ae27 First step in caching assets 2023-09-14 23:21:43 +08:00
Lim Chee Aun 7a30cc4b12 Clear badge when onmount too 2023-09-14 22:31:16 +08:00
Lim Chee Aun d18db56032 Experiment show inline desc for videos in timelines
Reason: a video takes more time & effort to watch, so a quick desc would be helpful
2023-09-14 20:41:03 +08:00
Lim Chee Aun 27274eeab1 Rework the modal close + focus logic
- 'Esc' a modal will focus on "behind" nested modal
- All modals will have 'esc'
2023-09-14 20:39:23 +08:00
Lim Chee Aun fce5e45bc9 Respect 'reading:expand:spoilers' pref
Note this doesn't follow 'reading:expand:media' pref separately, so media will be spoiled too
2023-09-14 11:23:41 +08:00
Lim Chee Aun fa145d3ed0 Subtle blockquote styling 2023-09-14 00:25:04 +08:00
Lim Chee Aun 244f3325ae Always tilde 2023-09-13 18:47:11 +08:00
Lim Chee Aun ec57c75fa0 Upgrade p-retry 2023-09-13 18:46:16 +08:00
Lim Chee Aun 5ac255f808 If self, don't need to get familiar followers 2023-09-13 18:43:46 +08:00
Lim Chee Aun 62201b0250 Use _types as key too 2023-09-13 18:43:25 +08:00
Lim Chee Aun f02cd50d7b Fix unknown media not working 2023-09-13 18:10:20 +08:00
Lim Chee Aun 61e1a5042f Fix location invocation bug 2023-09-13 16:38:55 +08:00
Lim Chee Aun 2145f761b5 Fix wrong API call when switch to account's instance 2023-09-12 23:56:01 +08:00
Lim Chee Aun 979c3b1498 Add this to hideAllModals 2023-09-12 23:55:41 +08:00
Lim Chee Aun c4961b26bb Upgrade intl-localematcher 2023-09-12 20:54:40 +08:00
Lim Chee Aun aa3033b4ff Fix bugs with fetching followers/followings 2023-09-12 19:20:22 +08:00
Lim Chee Aun 641d274d7b Handle very-popular cases
- Shorten number
- Limit avatars to 50 since we have the Accounts sheet now
2023-09-12 18:50:46 +08:00
Lim Chee Aun 3fc3641437 Prevent infinite overlapping of Account & Accounts sheets 2023-09-12 18:00:19 +08:00
Lim Chee Aun b57d8adf18 Add Generic Accounts modal
Also refactored whole bunch of stuff
2023-09-12 11:27:54 +08:00
Lim Chee Aun dd2ca7bf35 Animate ancestor indicator 2023-09-12 11:22:01 +08:00
Lim Chee Aun f5184bd608 Prevent propagation from nested links 2023-09-12 11:21:31 +08:00
46 changed files with 1699 additions and 668 deletions

View file

@ -6,6 +6,10 @@ Phanpy does not collect or process any personal information from its users. The
Phanpy is hosted on [Cloudflare Pages](https://pages.cloudflare.com/) as a static website. Read more about [Cloudflare's privacy policy](https://www.cloudflare.com/privacypolicy/). Phanpy is hosted on [Cloudflare Pages](https://pages.cloudflare.com/) as a static website. Read more about [Cloudflare's privacy policy](https://www.cloudflare.com/privacypolicy/).
## Post translations
Phanpy uses [Lingva Translate](https://github.com/thedaviddelta/lingva-translate) to translate posts.
## Error logging ## Error logging
Phanpy dev site (*dev.phanpy.social*) uses [Rollbar](https://rollbar.com/) to log errors for debugging purposes. Read more about [Rollbar's privacy policy](https://rollbar.com/privacy/). The production site (*phanpy.social*) does not use error logging. Phanpy dev site (*dev.phanpy.social*) uses [Rollbar](https://rollbar.com/) to log errors for debugging purposes. Read more about [Rollbar's privacy policy](https://rollbar.com/privacy/). The production site (*phanpy.social*) does not use error logging.

View file

@ -93,7 +93,7 @@ Everything is designed and engineered following my taste and vision. This is a p
- Content can be partially revealed by hovering over the post, with tooltip showing the post text. - Content can be partially revealed by hovering over the post, with tooltip showing the post text.
- Clicking it will open the Post page. - Clicking it will open the Post page.
- Long-pressing or right-clicking it will "peek" the post with a bottom sheet UI. - Long-pressing or right-clicking it will "peek" the post with a bottom sheet UI.
- On boosts carousel, they are not partially hidden, but sorted to the end of the carousel. - On boosts carousel, they are sorted to the end of the carousel.
## Development ## Development
@ -155,14 +155,14 @@ And here I am. Building a Mastodon web client.
## Alternative web clients ## Alternative web clients
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
- [Semaphore](https://semaphore.social/) - [Semaphore](https://semaphore.social/)
- [Enafore](https://pinafore.easrng.net/) - [Enafore](https://enafore.social/)
- [Cuckoo+](https://www.cuckoo.social/) - [Cuckoo+](https://www.cuckoo.social/)
- [Sengi](https://nicolasconstant.github.io/sengi/) - [Sengi](https://nicolasconstant.github.io/sengi/)
- [Soapbox](https://fe.soapbox.pub/) - [Soapbox](https://fe.soapbox.pub/)
- [Elk](https://elk.zone/) - [Elk](https://elk.zone/) - forks ↓
- Fork https://elk.fedified.com/ - [elk.fedified.com](https://elk.fedified.com/)
- [Mastodeck](https://mastodeck.com/) - [Mastodeck](https://mastodeck.com/)
- [Trunks](https://trunks.social/) - [Trunks](https://trunks.social/)
- [Tooty](https://github.com/n1k0/tooty) - [Tooty](https://github.com/n1k0/tooty)

Binary file not shown.

View file

@ -16,7 +16,7 @@
src = lib.cleanSource ./.; src = lib.cleanSource ./.;
npmDepsHash = "sha256-nd4RHxJIHJIHkoQUELURBeTc0qrThbbUwFnQhdyKKRI="; npmDepsHash = "sha256-tqR3YQ++nJmwDNKIm7uFLhJ5HlAqfeEmJVyynHx3Hzw=";
# npmDepsHash = lib.fakeHash; # npmDepsHash = lib.fakeHash;
# DTTH-specific env variables # DTTH-specific env variables

48
package-lock.json generated
View file

@ -8,7 +8,7 @@
"name": "phanpy", "name": "phanpy",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "~0.4.1", "@formatjs/intl-localematcher": "~0.4.2",
"@github/text-expander-element": "~2.5.0", "@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.7", "@iconify-icons/mingcute": "~1.2.7",
"@justinribeiro/lite-youtube": "~1.5.0", "@justinribeiro/lite-youtube": "~1.5.0",
@ -20,10 +20,10 @@
"fast-deep-equal": "~3.1.3", "fast-deep-equal": "~3.1.3",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"lz-string": "^1.5.0", "lz-string": "~1.5.0",
"masto": "~5.11.4", "masto": "~5.11.4",
"mem": "~9.0.2", "mem": "~9.0.2",
"p-retry": "~5.1.2", "p-retry": "~6.0.0",
"p-throttle": "~5.1.0", "p-throttle": "~5.1.0",
"preact": "~10.17.1", "preact": "~10.17.1",
"react-hotkeys-hook": "~4.4.1", "react-hotkeys-hook": "~4.4.1",
@ -2989,9 +2989,9 @@
} }
}, },
"node_modules/@formatjs/intl-localematcher": { "node_modules/@formatjs/intl-localematcher": {
"version": "0.4.1", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.2.tgz",
"integrity": "sha512-Fs4MhhHlLC0RrspX2u2KP7zlwL9eHrBZsOBxaPOeqrCZYLaOUK4cYXQ1ErpAB0HnGV/GUXNa5smzV/7jCuRzxg==", "integrity": "sha512-BGdtJFmaNJy5An/Zan4OId/yR9Ih1OojFjcduX/xOvq798OgWSyDtd6Qd5jqJXwJs1ipe4Fxu9+cshic5Ox2tA==",
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@ -3385,9 +3385,9 @@
} }
}, },
"node_modules/@types/retry": { "node_modules/@types/retry": {
"version": "0.12.1", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.1.tgz", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==" "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="
}, },
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.3", "version": "2.0.3",
@ -5573,15 +5573,15 @@
} }
}, },
"node_modules/p-retry": { "node_modules/p-retry": {
"version": "5.1.2", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-5.1.2.tgz", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.0.0.tgz",
"integrity": "sha512-couX95waDu98NfNZV+i/iLt+fdVxmI7CbrrdC2uDWfPdUAApyxT4wmDlyOtR5KtTDmkDO0zDScDjDou9YHhd9g==", "integrity": "sha512-6NuuXu8Upembd4sNdo4PRbs+M6aHgBTrFE6lkH0YKjVzne3cDW4gkncB98ty/bkMxLxLVNeD5bX9FyWjM7WZ+A==",
"dependencies": { "dependencies": {
"@types/retry": "0.12.1", "@types/retry": "0.12.2",
"retry": "^0.13.1" "retry": "^0.13.1"
}, },
"engines": { "engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "node": ">=16.17"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@ -9661,9 +9661,9 @@
"optional": true "optional": true
}, },
"@formatjs/intl-localematcher": { "@formatjs/intl-localematcher": {
"version": "0.4.1", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.2.tgz",
"integrity": "sha512-Fs4MhhHlLC0RrspX2u2KP7zlwL9eHrBZsOBxaPOeqrCZYLaOUK4cYXQ1ErpAB0HnGV/GUXNa5smzV/7jCuRzxg==", "integrity": "sha512-BGdtJFmaNJy5An/Zan4OId/yR9Ih1OojFjcduX/xOvq798OgWSyDtd6Qd5jqJXwJs1ipe4Fxu9+cshic5Ox2tA==",
"requires": { "requires": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@ -9995,9 +9995,9 @@
} }
}, },
"@types/retry": { "@types/retry": {
"version": "0.12.1", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.1.tgz", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
"integrity": "sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==" "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="
}, },
"@types/trusted-types": { "@types/trusted-types": {
"version": "2.0.3", "version": "2.0.3",
@ -11585,11 +11585,11 @@
"integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==" "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw=="
}, },
"p-retry": { "p-retry": {
"version": "5.1.2", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-5.1.2.tgz", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.0.0.tgz",
"integrity": "sha512-couX95waDu98NfNZV+i/iLt+fdVxmI7CbrrdC2uDWfPdUAApyxT4wmDlyOtR5KtTDmkDO0zDScDjDou9YHhd9g==", "integrity": "sha512-6NuuXu8Upembd4sNdo4PRbs+M6aHgBTrFE6lkH0YKjVzne3cDW4gkncB98ty/bkMxLxLVNeD5bX9FyWjM7WZ+A==",
"requires": { "requires": {
"@types/retry": "0.12.1", "@types/retry": "0.12.2",
"retry": "^0.13.1" "retry": "^0.13.1"
} }
}, },

View file

@ -10,7 +10,7 @@
"sourcemap": "npx source-map-explorer dist/assets/*.js" "sourcemap": "npx source-map-explorer dist/assets/*.js"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "~0.4.1", "@formatjs/intl-localematcher": "~0.4.2",
"@github/text-expander-element": "~2.5.0", "@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.7", "@iconify-icons/mingcute": "~1.2.7",
"@justinribeiro/lite-youtube": "~1.5.0", "@justinribeiro/lite-youtube": "~1.5.0",
@ -22,10 +22,10 @@
"fast-deep-equal": "~3.1.3", "fast-deep-equal": "~3.1.3",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"lz-string": "^1.5.0", "lz-string": "~1.5.0",
"masto": "~5.11.4", "masto": "~5.11.4",
"mem": "~9.0.2", "mem": "~9.0.2",
"p-retry": "~5.1.2", "p-retry": "~6.0.0",
"p-throttle": "~5.1.0", "p-throttle": "~5.1.0",
"preact": "~10.17.1", "preact": "~10.17.1",
"react-hotkeys-hook": "~4.4.1", "react-hotkeys-hook": "~4.4.1",

BIN
public/logo-badge-72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -9,6 +9,25 @@ import {
self.__WB_DISABLE_DEV_LOGS = true; self.__WB_DISABLE_DEV_LOGS = true;
const assetsRoute = new Route(
({ request, sameOrigin }) => {
const isAsset =
request.destination === 'style' || request.destination === 'script';
const hasHash = /-[0-9a-f]{4,}\./i.test(request.url);
return sameOrigin && isAsset && hasHash;
},
new NetworkFirst({
cacheName: 'assets',
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
);
registerRoute(assetsRoute);
const imageRoute = new Route( const imageRoute = new Route(
({ request, sameOrigin }) => { ({ request, sameOrigin }) => {
const isRemote = !sameOrigin; const isRemote = !sameOrigin;
@ -124,7 +143,7 @@ self.addEventListener('push', (event) => {
body, body,
icon, icon,
dir: 'auto', dir: 'auto',
badge: '/logo-192.png', badge: '/logo-badge-72.png',
lang: preferred_locale, lang: preferred_locale,
tag: notification_id, tag: notification_id,
timestamp: Date.now(), timestamp: Date.now(),

View file

@ -788,6 +788,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
gap: 16px; gap: 16px;
align-items: flex-start; align-items: flex-start;
counter-reset: index; counter-reset: index;
min-height: 160px;
} }
.status-carousel > ul > li { .status-carousel > ul > li {
scroll-snap-align: center; scroll-snap-align: center;
@ -1737,25 +1738,22 @@ meter.donut[hidden] {
:is(.shiny-pill, :root .toastify.shiny-pill) { :is(.shiny-pill, :root .toastify.shiny-pill) {
pointer-events: auto; pointer-events: auto;
color: var(--button-text-color); color: var(--link-text-color);
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color); font-weight: 500;
background-color: var(--button-bg-color); text-shadow: 0 1px var(--bg-color);
background-image: linear-gradient( background-color: var(--bg-color);
160deg, border: 1px solid var(--outline-color);
rgba(255, 255, 255, 0.5), box-shadow: 0 3px 16px var(--drop-shadow-color),
rgba(0, 0, 0, 0.1) 0 6px 16px -3px var(--drop-shadow-color);
);
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
0 10px 36px -4px var(--button-bg-blur-color),
inset var(--hairline-width) var(--hairline-width) rgba(255, 255, 255, 0.5);
transition: filter 0.3s;
} }
:is(.shiny-pill, :root .toastify.shiny-pill):hover { :is(.shiny-pill, :root .toastify.shiny-pill):hover:not(:active) {
filter: brightness(1.2); color: var(--text-color);
} border-color: var(--link-color);
:is(.shiny-pill, :root .toastify.shiny-pill):active { filter: none !important;
transition: none; box-shadow: 0 0 0 1px var(--link-text-color),
filter: brightness(0.9); 0 3px 16px var(--drop-shadow-color),
0 6px 16px -3px var(--drop-shadow-color),
0 6px 16px var(--drop-shadow-color);
} }
/* TOAST */ /* TOAST */
@ -1767,9 +1765,10 @@ meter.donut[hidden] {
pointer-events: none; pointer-events: none;
color: var(--button-text-color); color: var(--button-text-color);
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color); text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
background-color: var(--button-bg-blur-color); background-color: var(--button-bg-color);
background-image: none; background-image: none;
backdrop-filter: blur(16px); box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
0 10px 36px -4px var(--button-bg-blur-color);
} }
.toastify-bottom { .toastify-bottom {
margin-bottom: env(safe-area-inset-bottom); margin-bottom: env(safe-area-inset-bottom);
@ -2193,6 +2192,9 @@ ul.link-list li a .icon {
.timeline-deck > header[hidden] { .timeline-deck > header[hidden] {
transform: translate3d(0, calc((100% + var(--margin-top)) * -1), 0); transform: translate3d(0, calc((100% + var(--margin-top)) * -1), 0);
} }
.deck > header {
text-shadow: 0 1px var(--bg-color);
}
.deck > header h1 { .deck > header h1 {
font-size: 1.5em; font-size: 1.5em;
} }

View file

@ -7,33 +7,21 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import { import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
matchPath,
Route,
Routes,
useLocation,
useNavigate,
} from 'react-router-dom';
import 'swiped-events'; import 'swiped-events';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import AccountSheet from './components/account-sheet';
import BackgroundService from './components/background-service'; import BackgroundService from './components/background-service';
import Compose from './components/compose';
import ComposeButton from './components/compose-button'; import ComposeButton from './components/compose-button';
import Drafts from './components/drafts';
import { ICONS } from './components/icon'; import { ICONS } from './components/icon';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help'; import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader'; import Loader from './components/loader';
import MediaModal from './components/media-modal'; import Modals from './components/modals';
import Modal from './components/modal';
import NotificationService from './components/notification-service'; import NotificationService from './components/notification-service';
import SearchCommand from './components/search-command'; import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts'; import Shortcuts from './components/shortcuts';
import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404'; import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses'; import AccountStatuses from './pages/account-statuses';
import Accounts from './pages/accounts';
import Bookmarks from './pages/bookmarks'; import Bookmarks from './pages/bookmarks';
import Favourites from './pages/favourites'; import Favourites from './pages/favourites';
import FollowedHashtags from './pages/followed-hashtags'; import FollowedHashtags from './pages/followed-hashtags';
@ -48,7 +36,6 @@ import Mentions from './pages/mentions';
import Notifications from './pages/notifications'; import Notifications from './pages/notifications';
import Public from './pages/public'; import Public from './pages/public';
import Search from './pages/search'; import Search from './pages/search';
import Settings from './pages/settings';
import StatusRoute from './pages/status-route'; import StatusRoute from './pages/status-route';
import Trending from './pages/trending'; import Trending from './pages/trending';
import Welcome from './pages/welcome'; import Welcome from './pages/welcome';
@ -60,7 +47,7 @@ import {
initPreferences, initPreferences,
} from './utils/api'; } from './utils/api';
import { getAccessToken } from './utils/auth'; import { getAccessToken } from './utils/auth';
import showToast from './utils/show-toast'; import focusDeck from './utils/focus-deck';
import states, { initStates } from './utils/states'; import states, { initStates } from './utils/states';
import store from './utils/store'; import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils'; import { getCurrentAccount } from './utils/store-utils';
@ -85,7 +72,6 @@ function App() {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading'); const [uiState, setUIState] = useState('loading');
const navigate = useNavigate();
useLayoutEffect(() => { useLayoutEffect(() => {
const theme = store.local.get('theme'); const theme = store.local.get('theme');
@ -165,41 +151,9 @@ function App() {
let location = useLocation(); let location = useLocation();
states.currentLocation = location.pathname; states.currentLocation = location.pathname;
const focusDeck = () => {
let timer = setTimeout(() => {
const columns = document.getElementById('columns');
if (columns) {
// Focus first column
// columns.querySelector('.deck-container')?.focus?.();
} else {
const backDrop = document.querySelector('.deck-backdrop');
if (backDrop) return;
// Focus last deck
const pages = document.querySelectorAll('.deck-container');
const page = pages[pages.length - 1]; // last one
if (page && page.tabIndex === -1) {
console.log('FOCUS', page);
page.focus();
}
}
}, 100);
return () => clearTimeout(timer);
};
useEffect(focusDeck, [location, isLoggedIn]); useEffect(focusDeck, [location, isLoggedIn]);
const showModal =
snapStates.showCompose ||
snapStates.showSettings ||
snapStates.showAccounts ||
snapStates.showAccount ||
snapStates.showDrafts ||
snapStates.showMediaModal ||
snapStates.showShortcutsSettings ||
snapStates.showKeyboardShortcutsHelp;
useEffect(() => {
if (!showModal) focusDeck();
}, [showModal]);
const { prevLocation } = snapStates; const prevLocation = snapStates.prevLocation;
const backgroundLocation = useRef(prevLocation || null); const backgroundLocation = useRef(prevLocation || null);
const isModalPage = useMemo(() => { const isModalPage = useMemo(() => {
return ( return (
@ -230,9 +184,11 @@ function App() {
useEffect(() => { useEffect(() => {
const $app = document.getElementById('app'); const $app = document.getElementById('app');
if ($app) { if ($app) {
$app.dataset.shortcutsViewMode = snapStates.settings.shortcutsViewMode; $app.dataset.shortcutsViewMode = snapStates.shortcuts?.length
? snapStates.settings.shortcutsViewMode
: '';
} }
}, [snapStates.settings.shortcutsViewMode]); }, [snapStates.shortcuts, snapStates.settings.shortcutsViewMode]);
// Add/Remove cloak class to body // Add/Remove cloak class to body
useEffect(() => { useEffect(() => {
@ -294,147 +250,7 @@ function App() {
snapStates.settings.shortcutsViewMode !== 'multi-column' && ( snapStates.settings.shortcutsViewMode !== 'multi-column' && (
<Shortcuts /> <Shortcuts />
)} )}
{!!snapStates.showCompose && ( <Modals />
<Modal>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
const { newStatus, instance } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
text: 'Post published. Check it out.',
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showSettings = false;
}
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccounts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccounts = false;
}
}}
>
<Accounts
onClose={() => {
states.showAccounts = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccount = false;
}
}}
>
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination } = {}) => {
states.showAccount = false;
if (destination) {
states.showAccounts = false;
}
}}
/>
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showDrafts = false;
}
}}
>
<Drafts onClose={() => (states.showDrafts = false)} />
</Modal>
)}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showShortcutsSettings = false;
}
}}
>
<ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal>
)}
<NotificationService /> <NotificationService />
<BackgroundService isLoggedIn={isLoggedIn} /> <BackgroundService isLoggedIn={isLoggedIn} />
<SearchCommand onClose={focusDeck} /> <SearchCommand onClose={focusDeck} />

View file

@ -9,6 +9,46 @@
color: var(--outline-color); color: var(--outline-color);
} }
.account-container .account-moved {
animation: fade-in 0.3s both ease-in-out 0.3s;
padding: 16px;
background-color: var(--bg-color);
position: absolute;
top: 8px;
inset-inline: 8px;
z-index: 2;
border: 1px solid var(--outline-color);
box-shadow: 0 8px 16px var(--drop-shadow-color);
border-radius: calc(16px - 8px);
overflow: hidden;
p {
margin: 0 0 8px;
padding: 0;
}
.account-block {
background-color: var(--bg-faded-color);
padding: 8px;
border-radius: 8px;
border: 1px solid var(--link-faded-color);
&:hover {
background-color: var(--link-bg-hover-color);
border-color: var(--link-color);
}
b {
color: var(--link-color);
}
}
~ * {
/* pointer-events: none; */
filter: grayscale(0.75) brightness(0.75);
}
}
.account-container .header-banner { .account-container .header-banner {
/* pointer-events: none; */ /* pointer-events: none; */
aspect-ratio: 6 / 1; aspect-ratio: 6 / 1;
@ -139,15 +179,29 @@
/* flex-wrap: wrap; */ /* flex-wrap: wrap; */
column-gap: 24px; column-gap: 24px;
row-gap: 8px; row-gap: 8px;
opacity: 0.75; /* opacity: 0.75; */
font-size: 90%; font-size: 90%;
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
padding: 12px; padding: 12px;
border-radius: 16px; /* border-radius: 16px; */
line-height: 1.25; line-height: 1.25;
overflow-x: auto; overflow-x: auto !important;
justify-content: flex-start; justify-content: flex-start;
position: relative; position: relative;
[tabindex='0']:is(:hover, :focus) {
color: var(--text-color);
cursor: pointer;
text-decoration-color: var(--text-insignificant-color);
}
.stats-avatars-bunch {
animation: appear 1s both ease-in-out;
> *:not(:first-child) {
margin: 0 0 0 -4px;
}
}
} }
.timeline-start .account-container .stats { .timeline-start .account-container .stats {
flex-wrap: wrap; flex-wrap: wrap;
@ -158,6 +212,9 @@
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
} }
.account-container .stats a:not(.insignificant) {
color: inherit;
}
.account-container .stats a:hover { .account-container .stats a:hover {
color: inherit; color: inherit;
} }
@ -176,11 +233,35 @@
display: flex; display: flex;
} }
.account-container .account-metadata-box {
overflow: hidden;
border-radius: 16px;
display: block;
text-decoration: none;
& > * {
margin-bottom: 2px;
border-radius: 4px;
overflow: hidden;
}
&:has(+ .account-metadata-box) {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
+ .account-metadata-box {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
}
.account-container .profile-metadata { .account-container .profile-metadata {
display: flex; display: flex;
/* flex-wrap: wrap; */ /* flex-wrap: wrap; */
gap: 2px; gap: 2px;
border-radius: 16px;
overflow: hidden; overflow: hidden;
overflow-x: auto; overflow-x: auto;
} }
@ -226,12 +307,11 @@
margin: 0; margin: 0;
} }
.account-container .common-followers p { .account-container .common-followers {
font-size: 90%; font-size: 90%;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
border-top: 1px solid var(--outline-color); background-color: var(--bg-faded-color);
border-bottom: 1px solid var(--outline-color); padding: 8px 12px;
padding: 8px 0;
margin: 0; margin: 0;
} }
@ -252,6 +332,84 @@
opacity: 0.5; opacity: 0.5;
} }
@keyframes swoosh-bg-image {
0% {
background-position: -320px 0;
opacity: 0.25;
}
100% {
background-position: 0 0;
opacity: 1;
}
}
.account-container .posting-stats {
font-size: 90%;
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
padding: 8px 12px;
--size: 8px;
--original-color: var(--link-color);
&:is(:hover, :focus-within) {
background-color: var(--link-bg-hover-color);
}
.posting-stats-bar {
--gap: 0.5px;
--gap-color: var(--outline-color);
height: var(--size);
border-radius: var(--size);
overflow: hidden;
margin: 8px 0;
box-shadow: inset 0 0 0 1px var(--outline-color),
inset 0 0 0 1.5px var(--bg-blur-color);
background-color: var(--bg-color);
background-repeat: no-repeat;
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
background-image: linear-gradient(
to right,
var(--original-color) 0%,
var(--original-color) calc(var(--originals-percentage) - var(--gap)),
var(--gap-color) calc(var(--originals-percentage) - var(--gap)),
var(--gap-color) calc(var(--originals-percentage) + var(--gap)),
var(--reply-to-color) calc(var(--originals-percentage) + var(--gap)),
var(--reply-to-color) calc(var(--replies-percentage) - var(--gap)),
var(--gap-color) calc(var(--replies-percentage) - var(--gap)),
var(--gap-color) calc(var(--replies-percentage) + var(--gap)),
var(--reblog-color) calc(var(--replies-percentage) + var(--gap)),
var(--reblog-color) 100%
);
}
.posting-stats-legends {
font-size: 12px;
text-transform: uppercase;
}
.posting-stats-legend-item {
display: inline-block;
width: var(--size);
height: var(--size);
border-radius: var(--size);
background-color: var(--text-insignificant-color);
vertical-align: middle;
margin: 0 4px 2px;
/* border: 1px solid var(--outline-color); */
box-shadow: inset 0 0 0 1px var(--outline-color),
inset 0 0 0 1.5px var(--bg-blur-color);
&.posting-stats-legend-item-originals {
background-color: var(--original-color);
}
&.posting-stats-legend-item-replies {
background-color: var(--reply-to-color);
}
&.posting-stats-legend-item-boosts {
background-color: var(--reblog-color);
}
}
}
@keyframes shine { @keyframes shine {
0% { 0% {
left: -100%; left: -100%;

View file

@ -1,7 +1,8 @@
import './account-info.css'; import './account-info.css';
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu'; import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
import { proxy, useSnapshot } from 'valtio';
import { api } from '../utils/api'; import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
@ -46,6 +47,12 @@ const MUTE_DURATIONS_LABELS = {
604_800_000: '1 week', 604_800_000: '1 week',
}; };
const LIMIT = 80;
const accountInfoStates = proxy({
familiarFollowers: [],
});
function AccountInfo({ function AccountInfo({
account, account,
fetchAccount = () => {}, fetchAccount = () => {},
@ -53,9 +60,23 @@ function AccountInfo({
instance, instance,
authenticated, authenticated,
}) { }) {
const { masto } = api({
instance,
});
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string'; const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account); const [info, setInfo] = useState(isString ? null : account);
const snapAccountInfoStates = useSnapshot(accountInfoStates);
const isSelf = useMemo(
() => account.id === store.session.get('currentAccount'),
[account?.id],
);
const sameCurrentInstance = useMemo(
() => instance === api().instance,
[instance],
);
useEffect(() => { useEffect(() => {
if (!isString) { if (!isString) {
@ -99,6 +120,7 @@ function AccountInfo({
url, url,
username, username,
memorial, memorial,
moved,
} = info || {}; } = info || {};
let headerIsAvatar = false; let headerIsAvatar = false;
let { header, headerStatic } = info || {}; let { header, headerStatic } = info || {};
@ -114,6 +136,65 @@ function AccountInfo({
const [headerCornerColors, setHeaderCornerColors] = useState([]); const [headerCornerColors, setHeaderCornerColors] = useState([]);
const followersIterator = useRef();
const familiarFollowersCache = useRef([]);
async function fetchFollowers(firstLoad) {
if (firstLoad || !followersIterator.current) {
followersIterator.current = masto.v1.accounts.listFollowers(id, {
limit: LIMIT,
});
}
const results = await followersIterator.current.next();
if (isSelf) return results;
if (!sameCurrentInstance) return results;
const { value } = results;
let newValue = [];
// On first load, fetch familiar followers, merge to top of results' `value`
// Remove dups on every fetch
if (firstLoad) {
const familiarFollowers = await masto.v1.accounts.fetchFamiliarFollowers(
id,
);
familiarFollowersCache.current = familiarFollowers[0].accounts;
newValue = [
...familiarFollowersCache.current,
...value.filter(
(account) =>
!familiarFollowersCache.current.some(
(familiar) => familiar.id === account.id,
),
),
];
} else if (value?.length) {
newValue = value.filter(
(account) =>
!familiarFollowersCache.current.some(
(familiar) => familiar.id === account.id,
),
);
}
return {
...results,
value: newValue,
};
}
const followingIterator = useRef();
async function fetchFollowing(firstLoad) {
if (firstLoad || !followingIterator.current) {
followingIterator.current = masto.v1.accounts.listFollowing(id, {
limit: LIMIT,
});
}
const results = await followingIterator.current.next();
return results;
}
const LinkOrDiv = standalone ? 'div' : Link;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
return ( return (
<div <div
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`} class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
@ -128,7 +209,11 @@ function AccountInfo({
<div class="ui-state"> <div class="ui-state">
<p>Unable to load account.</p> <p>Unable to load account.</p>
<p> <p>
<a href={account} target="_blank"> <a
href={isString ? account : url}
target="_blank"
rel="noopener noreferrer"
>
Go to account page <Icon icon="external" /> Go to account page <Icon icon="external" />
</a> </a>
</p> </p>
@ -161,6 +246,22 @@ function AccountInfo({
) : ( ) : (
info && ( info && (
<> <>
{!!moved && (
<div class="account-moved">
<p>
<b>{displayName}</b> has indicated that their new account is
now:
</p>
<AccountBlock
account={moved}
instance={instance}
onClick={(e) => {
e.stopPropagation();
states.showAccount = moved;
}}
/>
</div>
)}
{header && !/missing\.png$/.test(header) && ( {header && !/missing\.png$/.test(header) && (
<img <img
src={header} src={header}
@ -289,6 +390,7 @@ function AccountInfo({
__html: enhanceContent(note, { emojis }), __html: enhanceContent(note, { emojis }),
}} }}
/> />
<div class="account-metadata-box">
{fields?.length > 0 && ( {fields?.length > 0 && (
<div class="profile-metadata"> <div class="profile-metadata">
{fields.map(({ name, value, verifiedAt }, i) => ( {fields.map(({ name, value, verifiedAt }, i) => (
@ -300,7 +402,9 @@ function AccountInfo({
> >
<b> <b>
<EmojiText text={name} emojis={emojis} />{' '} <EmojiText text={name} emojis={emojis} />{' '}
{!!verifiedAt && <Icon icon="check-circle" size="s" />} {!!verifiedAt && (
<Icon icon="check-circle" size="s" />
)}
</b> </b>
<p <p
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@ -311,41 +415,73 @@ function AccountInfo({
))} ))}
</div> </div>
)} )}
<p class="stats"> <div class="stats">
<div> <LinkOrDiv
tabIndex={0}
to={accountLink}
onClick={() => {
states.showAccount = false;
states.showGenericAccounts = {
heading: 'Followers',
fetchAccounts: fetchFollowers,
};
}}
>
{!!snapAccountInfoStates.familiarFollowers.length && (
<span class="shazam-container-horizontal">
<span class="shazam-container-inner stats-avatars-bunch">
{(snapAccountInfoStates.familiarFollowers || []).map(
(follower) => (
<Avatar
url={follower.avatarStatic}
size="s"
alt={`${follower.displayName} @${follower.acct}`}
squircle={follower?.bot}
/>
),
)}
</span>
</span>
)}
<span title={followersCount}> <span title={followersCount}>
{shortenNumber(followersCount)} {shortenNumber(followersCount)}
</span>{' '} </span>{' '}
Followers Followers
</div> </LinkOrDiv>
<div class="insignificant"> <LinkOrDiv
class="insignificant"
tabIndex={0}
to={accountLink}
onClick={() => {
states.showAccount = false;
states.showGenericAccounts = {
heading: 'Following',
fetchAccounts: fetchFollowing,
};
}}
>
<span title={followingCount}> <span title={followingCount}>
{shortenNumber(followingCount)} {shortenNumber(followingCount)}
</span>{' '} </span>{' '}
Following Following
<br /> <br />
</div> </LinkOrDiv>
{standalone ? ( <LinkOrDiv
<div class="insignificant">
<span title={statusesCount}>
{shortenNumber(statusesCount)}
</span>{' '}
Posts
</div>
) : (
<Link
class="insignificant" class="insignificant"
to={instance ? `/${instance}/a/${id}` : `/a/${id}`} to={accountLink}
onClick={() => { onClick={
standalone
? undefined
: () => {
hideAllModals(); hideAllModals();
}} }
}
> >
<span title={statusesCount}> <span title={statusesCount}>
{shortenNumber(statusesCount)} {shortenNumber(statusesCount)}
</span>{' '} </span>{' '}
Posts Posts
</Link> </LinkOrDiv>
)}
{!!createdAt && ( {!!createdAt && (
<div class="insignificant"> <div class="insignificant">
Joined{' '} Joined{' '}
@ -356,11 +492,13 @@ function AccountInfo({
</time> </time>
</div> </div>
)} )}
</p> </div>
</div>
<RelatedActions <RelatedActions
info={info} info={info}
instance={instance} instance={instance}
authenticated={authenticated} authenticated={authenticated}
standalone={standalone}
/> />
</main> </main>
</> </>
@ -370,7 +508,9 @@ function AccountInfo({
); );
} }
function RelatedActions({ info, instance, authenticated }) { const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({ info, instance, authenticated, standalone }) {
if (!info) return null; if (!info) return null;
const { const {
masto: currentMasto, masto: currentMasto,
@ -381,9 +521,10 @@ function RelatedActions({ info, instance, authenticated }) {
const [relationshipUIState, setRelationshipUIState] = useState('default'); const [relationshipUIState, setRelationshipUIState] = useState('default');
const [relationship, setRelationship] = useState(null); const [relationship, setRelationship] = useState(null);
const [familiarFollowers, setFamiliarFollowers] = useState([]); const [postingStats, setPostingStats] = useState();
const { id, acct, url, username, locked, lastStatusAt, note, fields } = info; const { id, acct, url, username, locked, lastStatusAt, note, fields, moved } =
info;
const accountID = useRef(id); const accountID = useRef(id);
const { const {
@ -440,33 +581,82 @@ function RelatedActions({ info, instance, authenticated }) {
accountID.current = currentID; accountID.current = currentID;
if (moved) return;
setRelationshipUIState('loading'); setRelationshipUIState('loading');
setFamiliarFollowers([]); accountInfoStates.familiarFollowers = [];
setPostingStats(null);
const fetchRelationships = currentMasto.v1.accounts.fetchRelationships([ const fetchRelationships = currentMasto.v1.accounts.fetchRelationships([
currentID, currentID,
]); ]);
const fetchFamiliarFollowers =
currentMasto.v1.accounts.fetchFamiliarFollowers(currentID);
try { try {
const relationships = await fetchRelationships; const relationships = await fetchRelationships;
console.log('fetched relationship', relationships); console.log('fetched relationship', relationships);
setRelationshipUIState('default');
if (relationships.length) { if (relationships.length) {
const relationship = relationships[0]; const relationship = relationships[0];
setRelationship(relationship); setRelationship(relationship);
if (!relationship.following) { if (!relationship.following) {
try { try {
const fetchFamiliarFollowers =
currentMasto.v1.accounts.fetchFamiliarFollowers(currentID);
const fetchStatuses = currentMasto.v1.accounts
.listStatuses(currentID, {
limit: 20,
})
.next();
const followers = await fetchFamiliarFollowers; const followers = await fetchFamiliarFollowers;
console.log('fetched familiar followers', followers); console.log('fetched familiar followers', followers);
setFamiliarFollowers(followers[0].accounts.slice(0, 10)); accountInfoStates.familiarFollowers =
followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT);
if (!standalone) {
const { value: statuses } = await fetchStatuses;
console.log('fetched statuses', statuses);
const stats = {
total: statuses.length,
originals: 0,
replies: 0,
boosts: 0,
};
// Categories statuses by type
// - Original posts (not replies to others)
// - Threads (self-replies + 1st original post)
// - Boosts (reblogs)
// - Replies (not-self replies)
statuses.forEach((status) => {
if (status.reblog) {
stats.boosts++;
} else if (
status.inReplyToAccountId !== currentID &&
!!status.inReplyToId
) {
stats.replies++;
} else {
stats.originals++;
}
});
// Count days since last post
stats.daysSinceLastPost = Math.ceil(
(Date.now() -
new Date(statuses[statuses.length - 1].createdAt)) /
86400000,
);
console.log('posting stats', stats);
setPostingStats(stats);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
} }
setRelationshipUIState('default');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setRelationshipUIState('error'); setRelationshipUIState('error');
@ -487,40 +677,74 @@ function RelatedActions({ info, instance, authenticated }) {
const [showTranslatedBio, setShowTranslatedBio] = useState(false); const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false); const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
const hasPostingStats = postingStats?.total >= 3;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
return ( return (
<> <>
<div {hasPostingStats && (
class="common-followers shazam-container no-animation" <Link
hidden={!familiarFollowers?.length} to={accountLink}
> class="account-metadata-box"
<div class="shazam-container-inner"> onClick={() => {
<p> states.showAccount = false;
Followed by{' '}
<span class="ib">
{familiarFollowers.map((follower) => (
<a
href={follower.url}
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = {
account: follower,
instance,
};
}} }}
> >
<Avatar <div class="shazam-container">
url={follower.avatarStatic} <div class="shazam-container-inner">
size="l" <div
alt={`${follower.displayName} @${follower.acct}`} class="posting-stats"
squircle={follower?.bot} title={`${Math.round(
(postingStats.originals / postingStats.total) * 100,
)}% original posts, ${Math.round(
(postingStats.replies / postingStats.total) * 100,
)}% replies, ${Math.round(
(postingStats.boosts / postingStats.total) * 100,
)}% boosts`}
>
<div>
{postingStats.daysSinceLastPost < 365
? `Last ${postingStats.total} posts in the past
${postingStats.daysSinceLastPost} day${
postingStats.daysSinceLastPost > 1 ? 's' : ''
}`
: `
Last ${postingStats.total} posts in the past year(s)
`}
</div>
<div
class="posting-stats-bar"
style={{
// [originals | replies | boosts]
'--originals-percentage': `${
(postingStats.originals / postingStats.total) * 100
}%`,
'--replies-percentage': `${
((postingStats.originals + postingStats.replies) /
postingStats.total) *
100
}%`,
}}
/> />
</a> <div class="posting-stats-legends">
))} <span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
Original
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
Replies
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
Boosts
</span> </span>
</p>
</div> </div>
</div> </div>
</div>
</div>
</Link>
)}
<p class="actions"> <p class="actions">
<span> <span>
{followedBy ? ( {followedBy ? (
@ -876,10 +1100,8 @@ function RelatedActions({ info, instance, authenticated }) {
{!!showTranslatedBio && ( {!!showTranslatedBio && (
<Modal <Modal
class="light" class="light"
onClick={(e) => { onClose={() => {
if (e.target === e.currentTarget) {
setShowTranslatedBio(false); setShowTranslatedBio(false);
}
}} }}
> >
<TranslatedBioSheet <TranslatedBioSheet
@ -892,10 +1114,8 @@ function RelatedActions({ info, instance, authenticated }) {
{!!showAddRemoveLists && ( {!!showAddRemoveLists && (
<Modal <Modal
class="light" class="light"
onClick={(e) => { onClose={() => {
if (e.target === e.currentTarget) {
setShowAddRemoveLists(false); setShowAddRemoveLists(false);
}
}} }}
> >
<AddRemoveListsSheet <AddRemoveListsSheet

View file

@ -1,5 +1,4 @@
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api'; import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
@ -11,8 +10,6 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
const { masto, instance, authenticated } = api({ instance: propInstance }); const { masto, instance, authenticated } = api({ instance: propInstance });
const isString = typeof account === 'string'; const isString = typeof account === 'string';
const escRef = useHotkeys('esc', onClose, [onClose]);
useEffect(() => { useEffect(() => {
if (!isString) { if (!isString) {
states.accounts[`${account.id}@${instance}`] = account; states.accounts[`${account.id}@${instance}`] = account;
@ -21,7 +18,6 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
return ( return (
<div <div
ref={escRef}
class="sheet" class="sheet"
onClick={(e) => { onClick={(e) => {
const accountBlock = e.target.closest('.account-block'); const accountBlock = e.target.closest('.account-block');

View file

@ -931,6 +931,14 @@ function Compose({
}} }}
maxCharacters={maxCharacters} maxCharacters={maxCharacters}
performSearch={(params) => { performSearch={(params) => {
const { type, q, limit } = params;
if (type === 'accounts') {
return masto.v1.accounts.search({
q,
limit,
resolve: false,
});
}
return masto.v2.search(params); return masto.v2.search(params);
}} }}
/> />
@ -1175,6 +1183,17 @@ function Compose({
); );
} }
function autoResizeTextarea(textarea) {
if (!textarea) return;
const { value, offsetHeight, scrollHeight, clientHeight } = textarea;
if (offsetHeight < window.innerHeight) {
// NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render
// No idea why it does that, will re-investigate in far future
const offset = offsetHeight - clientHeight;
textarea.style.height = value ? scrollHeight + offset + 'px' : null;
}
}
const Textarea = forwardRef((props, ref) => { const Textarea = forwardRef((props, ref) => {
const { masto } = api(); const { masto } = api();
const [text, setText] = useState(ref.current?.value || ''); const [text, setText] = useState(ref.current?.value || '');
@ -1258,7 +1277,7 @@ const Textarea = forwardRef((props, ref) => {
return; return;
} }
console.log({ value, type, v: value[type] }); console.log({ value, type, v: value[type] });
const results = value[type]; const results = value[type] || value;
console.log('RESULTS', value, results); console.log('RESULTS', value, results);
let html = ''; let html = '';
results.forEach((result) => { results.forEach((result) => {
@ -1367,15 +1386,46 @@ const Textarea = forwardRef((props, ref) => {
ref={ref} ref={ref}
name="status" name="status"
value={text} value={text}
onInput={(e) => { onKeyDown={(e) => {
const { scrollHeight, offsetHeight, clientHeight, value } = e.target; // Get line before cursor position after pressing 'Enter'
setText(value); const { key, target } = e;
if (offsetHeight < window.innerHeight) { if (key === 'Enter') {
// NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render try {
// No idea why it does that, will re-investigate in far future const { value, selectionStart } = target;
const offset = offsetHeight - clientHeight; const textBeforeCursor = value.slice(0, selectionStart);
e.target.style.height = value ? scrollHeight + offset + 'px' : null; const lastLine = textBeforeCursor.split('\n').slice(-1)[0];
if (lastLine) {
// If line starts with "- " or "12. "
if (/^\s*(-|\d+\.)\s/.test(lastLine)) {
// insert "- " at cursor position
const [_, preSpaces, bullet, postSpaces, anything] =
lastLine.match(/^(\s*)(-|\d+\.)(\s+)(.+)?/) || [];
if (anything) {
e.preventDefault();
const [number] = bullet.match(/\d+/) || [];
const newBullet = number ? `${+number + 1}.` : '-';
const text = `\n${preSpaces}${newBullet}${postSpaces}`;
target.setRangeText(text, selectionStart, selectionStart);
const pos = selectionStart + text.length;
target.setSelectionRange(pos, pos);
} else {
// trim the line before the cursor, then insert new line
const pos = selectionStart - lastLine.length;
target.setRangeText('', pos, selectionStart);
} }
autoResizeTextarea(target);
}
}
} catch (e) {
// silent fail
console.error(e);
}
}
}}
onInput={(e) => {
const { target } = e;
setText(target.value);
autoResizeTextarea(target);
props.onInput?.(e); props.onInput?.(e);
}} }}
style={{ style={{
@ -1851,7 +1901,15 @@ function CustomEmojisModal({
}} }}
title={`:${emoji.shortcode}:`} title={`:${emoji.shortcode}:`}
> >
<picture>
{!!emoji.staticUrl && (
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
/>
)}
<img <img
class="shortcode-emoji"
src={emoji.url || emoji.staticUrl} src={emoji.url || emoji.staticUrl}
alt={emoji.shortcode} alt={emoji.shortcode}
width="16" width="16"
@ -1859,6 +1917,7 @@ function CustomEmojisModal({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
</picture>
</button> </button>
))} ))}
</section> </section>

View file

@ -18,8 +18,8 @@ function EmojiText({ text, emojis }) {
src={url} src={url}
alt={word} alt={word}
class="shortcode-emoji emoji" class="shortcode-emoji emoji"
width="12" width="16"
height="12" height="16"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />

View file

@ -0,0 +1,42 @@
#generic-accounts-container {
.accounts-list {
list-style: none;
margin: 0;
padding: 8px 0;
display: flex;
flex-wrap: wrap;
flex-direction: row;
column-gap: 1.5em;
row-gap: 16px;
li {
display: flex;
flex-grow: 1;
flex-basis: 16em;
align-items: center;
margin: 0;
padding: 0;
gap: 8px;
}
.account-block-acct {
font-size: 80%;
color: var(--text-insignificant-color);
display: block;
}
}
.reactions-block {
display: flex;
flex-direction: column;
align-self: center;
.favourite-icon {
color: var(--favourite-color);
}
.reblog-icon {
color: var(--reblog-color);
}
}
}

View file

@ -0,0 +1,136 @@
import './generic-accounts.css';
import { useEffect, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio';
import states from '../utils/states';
import AccountBlock from './account-block';
import Icon from './icon';
import Loader from './loader';
export default function GenericAccounts({ onClose = () => {} }) {
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [accounts, setAccounts] = useState([]);
const [showMore, setShowMore] = useState(false);
if (!snapStates.showGenericAccounts) {
return null;
}
const {
heading,
fetchAccounts,
accounts: staticAccounts,
showReactions,
} = snapStates.showGenericAccounts;
const loadAccounts = (firstLoad) => {
if (!fetchAccounts) return;
if (firstLoad) setAccounts([]);
setUIState('loading');
(async () => {
try {
const { done, value } = await fetchAccounts(firstLoad);
if (Array.isArray(value)) {
if (firstLoad) {
setAccounts(value);
} else {
setAccounts((prev) => [...prev, ...value]);
}
setShowMore(!done);
} else {
setShowMore(false);
}
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
};
useEffect(() => {
if (staticAccounts?.length > 0) {
setAccounts(staticAccounts);
} else {
loadAccounts(true);
}
}, [staticAccounts, fetchAccounts]);
return (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<h2>{heading || 'Accounts'}</h2>
</header>
<main>
{accounts.length > 0 ? (
<>
<ul class="accounts-list">
{accounts.map((account) => (
<li key={account.id + (account._types || '')}>
{showReactions && account._types?.length > 0 && (
<div class="reactions-block">
{account._types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
</div>
)}
<AccountBlock account={account} />
</li>
))}
</ul>
{uiState === 'default' ? (
showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadAccounts();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => loadAccounts()}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
)
) : (
uiState === 'loading' && (
<p class="ui-state">
<Loader abrupt />
</p>
)
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Error loading accounts</p>
) : (
<p class="ui-state insignificant">Nothing to show</p>
)}
</main>
</div>
);
}

View file

@ -45,6 +45,7 @@ export const ICONS = {
plus: () => import('@iconify-icons/mingcute/add-circle-line'), plus: () => import('@iconify-icons/mingcute/add-circle-line'),
'chevron-left': () => import('@iconify-icons/mingcute/left-line'), 'chevron-left': () => import('@iconify-icons/mingcute/left-line'),
'chevron-right': () => import('@iconify-icons/mingcute/right-line'), 'chevron-right': () => import('@iconify-icons/mingcute/right-line'),
'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
reply: [ reply: [
() => import('@iconify-icons/mingcute/share-forward-line'), () => import('@iconify-icons/mingcute/share-forward-line'),
'180deg', '180deg',

View file

@ -39,6 +39,10 @@ const Link = forwardRef((props, ref) => {
{...restProps} {...restProps}
class={`${props.class || ''} ${isActive ? 'is-active' : ''}`} class={`${props.class || ''} ${isActive ? 'is-active' : ''}`}
onClick={(e) => { onClick={(e) => {
if (e.currentTarget?.parentNode?.closest('a')) {
// If this <a> is nested inside another <a>
e.stopPropagation();
}
if (routerLocation) states.prevLocation = routerLocation; if (routerLocation) states.prevLocation = routerLocation;
props.onClick?.(e); props.onClick?.(e);
}} }}

View file

@ -1,4 +1,5 @@
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact';
import { import {
useCallback, useCallback,
useLayoutEffect, useLayoutEffect,
@ -103,7 +104,11 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
[to], [to],
); );
const isImage = type === 'image' || (type === 'unknown' && previewUrl); const isVideoMaybe =
type === 'unknown' &&
/\.(mp4|m4a|m4p|m4b|m4r|m4v|mov|webm)$/i.test(remoteMediaURL);
const isImage =
type === 'image' || (type === 'unknown' && previewUrl && !isVideoMaybe);
const parentRef = useRef(); const parentRef = useRef();
const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false); const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false);
@ -221,7 +226,7 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
)} )}
</Parent> </Parent>
); );
} else if (type === 'gifv' || type === 'video') { } else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
const shortDuration = original.duration < 31; const shortDuration = original.duration < 31;
const isGIF = type === 'gifv' && shortDuration; const isGIF = type === 'gifv' && shortDuration;
// If GIF is too long, treat it as a video // If GIF is too long, treat it as a video
@ -247,7 +252,11 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
></video> ></video>
`; `;
const showInlineDesc = !showOriginal && !isGIF && !!description;
const Container = showInlineDesc ? 'figure' : Fragment;
return ( return (
<Container>
<Parent <Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${ class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : '' autoGIFAnimate ? 'media-contain' : ''
@ -319,7 +328,7 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
<> <>
<img <img
src={previewUrl} src={previewUrl}
alt={description} alt={showInlineDesc ? '' : description}
width={width} width={width}
height={height} height={height}
data-orientation={orientation} data-orientation={orientation}
@ -331,6 +340,16 @@ function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
</> </>
)} )}
</Parent> </Parent>
{showInlineDesc && (
<figcaption
onClick={() => {
location.hash = to;
}}
>
{description}
</figcaption>
)}
</Container>
); );
} else if (type === 'audio') { } else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration); const formattedDuration = formatDuration(original.duration);

View file

@ -2,10 +2,11 @@ import './modal.css';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
const $modalContainer = document.getElementById('modal-container'); const $modalContainer = document.getElementById('modal-container');
function Modal({ children, onClick, class: className }) { function Modal({ children, onClose, onClick, class: className }) {
if (!children) return null; if (!children) return null;
const modalRef = useRef(); const modalRef = useRef();
@ -19,8 +20,30 @@ function Modal({ children, onClick, class: className }) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
const escRef = useHotkeys('esc', onClose, [onClose], {
enabled: !!onClose,
});
const Modal = ( const Modal = (
<div ref={modalRef} className={className} onClick={onClick}> <div
ref={(node) => {
modalRef.current = node;
escRef.current = node?.querySelector?.('[tabindex="-1"]') || node;
}}
className={className}
onClick={(e) => {
onClick?.(e);
if (e.target === e.currentTarget) {
onClose?.(e);
}
}}
tabIndex="-1"
onFocus={(e) => {
if (e.target === e.currentTarget) {
modalRef.current?.querySelector?.('[tabindex="-1"]')?.focus?.();
}
}}
>
{children} {children}
</div> </div>
); );

179
src/components/modals.jsx Normal file
View file

@ -0,0 +1,179 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio';
import Accounts from '../pages/accounts';
import Settings from '../pages/settings';
import focusDeck from '../utils/focus-deck';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import AccountSheet from './account-sheet';
import Compose from './compose';
import Drafts from './drafts';
import GenericAccounts from './generic-accounts';
import MediaModal from './media-modal';
import Modal from './modal';
import ShortcutsSettings from './shortcuts-settings';
subscribe(states, (changes) => {
for (const [action, path, value, prevValue] of changes) {
// When closing modal, focus on deck
if (/^show/i.test(path) && !value) {
focusDeck();
}
}
});
export default function Modals() {
const snapStates = useSnapshot(states);
const navigate = useNavigate();
const location = useLocation();
return (
<>
{!!snapStates.showCompose && (
<Modal>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
onClose={(results) => {
const { newStatus, instance } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
text: 'Post published. Check it out.',
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClose={() => {
states.showSettings = false;
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccounts && (
<Modal
onClose={() => {
states.showAccounts = false;
}}
>
<Accounts
onClose={() => {
states.showAccounts = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClose={() => {
states.showAccount = false;
}}
>
<AccountSheet
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={({ destination } = {}) => {
states.showAccount = false;
if (destination) {
states.showAccounts = false;
}
}}
/>
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClose={() => {
states.showDrafts = false;
}}
>
<Drafts onClose={() => (states.showDrafts = false)} />
</Modal>
)}
{!!snapStates.showMediaModal && (
<Modal
onClick={(e) => {
if (
e.target === e.currentTarget ||
e.target.classList.contains('media')
) {
states.showMediaModal = false;
}
}}
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
states.showMediaModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClose={() => {
states.showShortcutsSettings = false;
}}
>
<ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal>
)}
{!!snapStates.showGenericAccounts && (
<Modal
class="light"
onClose={() => {
states.showGenericAccounts = false;
}}
>
<GenericAccounts
onClose={() => (states.showGenericAccounts = false)}
/>
</Modal>
)}
</>
);
}

View file

@ -72,9 +72,9 @@ function NameText({
<i class="instance">{acct2}</i> <i class="instance">{acct2}</i>
</> </>
) : short ? ( ) : short ? (
<i>@{username}</i> <i>{username}</i>
) : ( ) : (
<b>@{username}</b> <b>{username}</b>
)} )}
{showAcct && ( {showAcct && (
<> <>

View file

@ -239,6 +239,13 @@ function NavMenu(props) {
<MenuLink to="/login"> <MenuLink to="/login">
<Icon icon="user" size="l" /> <span>Log in</span> <Icon icon="user" size="l" /> <span>Log in</span>
</MenuLink> </MenuLink>
<MenuItem
onClick={() => {
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" /> <span>Settings&hellip;</span>
</MenuItem>
</> </>
)} )}
</section> </section>

View file

@ -106,6 +106,11 @@ export default memo(function NotificationService() {
}; };
}, []); }, []);
useLayoutEffect(() => {
if (navigator?.clearAppBadge) {
navigator.clearAppBadge();
}
}, []);
usePageVisibility((visible) => { usePageVisibility((visible) => {
if (visible && navigator?.clearAppBadge) { if (visible && navigator?.clearAppBadge) {
console.log('🔰 Clear app badge'); console.log('🔰 Clear app badge');

View file

@ -1,5 +1,7 @@
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import useTruncated from '../utils/useTruncated';
import Avatar from './avatar'; import Avatar from './avatar';
import FollowRequestButtons from './follow-request-buttons'; import FollowRequestButtons from './follow-request-buttons';
@ -60,6 +62,8 @@ const contentText = {
'admin.report': 'reported a post.', 'admin.report': 'reported a post.',
}; };
const AVATARS_LIMIT = 50;
function Notification({ notification, instance, reload, isStatic }) { function Notification({ notification, instance, reload, isStatic }) {
const { id, status, account, _accounts, _statuses } = notification; const { id, status, account, _accounts, _statuses } = notification;
let { type } = notification; let { type } = notification;
@ -126,6 +130,21 @@ function Notification({ notification, instance, reload, isStatic }) {
const formattedCreatedAt = const formattedCreatedAt =
notification.createdAt && new Date(notification.createdAt).toLocaleString(); notification.createdAt && new Date(notification.createdAt).toLocaleString();
const genericAccountsHeading =
{
'favourite+reblog': 'Boosted/Favourited by…',
favourite: 'Favourited by…',
reblog: 'Boosted by…',
follow: 'Followed by…',
}[type] || 'Accounts';
const handleOpenGenericAccounts = () => {
states.showGenericAccounts = {
heading: genericAccountsHeading,
accounts: _accounts,
showReactions: type === 'favourite+reblog',
};
};
return ( return (
<div class={`notification notification-${type}`} tabIndex="0"> <div class={`notification notification-${type}`} tabIndex="0">
<div <div
@ -153,7 +172,12 @@ function Notification({ notification, instance, reload, isStatic }) {
<> <>
{_accounts?.length > 1 ? ( {_accounts?.length > 1 ? (
<> <>
<b>{_accounts.length} people</b>{' '} <b tabIndex="0" onClick={handleOpenGenericAccounts}>
<span title={_accounts.length}>
{shortenNumber(_accounts.length)}
</span>{' '}
people
</b>{' '}
</> </>
) : ( ) : (
<> <>
@ -186,7 +210,7 @@ function Notification({ notification, instance, reload, isStatic }) {
)} )}
{_accounts?.length > 1 && ( {_accounts?.length > 1 && (
<p class="avatars-stack"> <p class="avatars-stack">
{_accounts.map((account, i) => ( {_accounts.slice(0, AVATARS_LIMIT).map((account, i) => (
<> <>
<a <a
href={account.url} href={account.url}
@ -202,11 +226,11 @@ function Notification({ notification, instance, reload, isStatic }) {
size={ size={
_accounts.length <= 10 _accounts.length <= 10
? 'xxl' ? 'xxl'
: _accounts.length < 100 : _accounts.length < 20
? 'xl' ? 'xl'
: _accounts.length < 1000 : _accounts.length < 30
? 'l' ? 'l'
: _accounts.length < 2000 : _accounts.length < 40
? 'm' ? 'm'
: 's' // My god, this person is popular! : 's' // My god, this person is popular!
} }
@ -228,43 +252,71 @@ function Notification({ notification, instance, reload, isStatic }) {
</a>{' '} </a>{' '}
</> </>
))} ))}
<button
type="button"
class="small plain"
onClick={handleOpenGenericAccounts}
>
{_accounts.length > AVATARS_LIMIT &&
`+${_accounts.length - AVATARS_LIMIT}`}
<Icon icon="chevron-down" />
</button>
</p> </p>
)} )}
{_statuses?.length > 1 && ( {_statuses?.length > 1 && (
<ul class="notification-group-statuses"> <ul class="notification-group-statuses">
{_statuses.map((status) => ( {_statuses.map((status) => (
<li key={status.id}> <li key={status.id}>
<Link <TruncatedLink
class={`status-link status-type-${type}`} class={`status-link status-type-${type}`}
to={ to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}` instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
} }
> >
<Status status={status} size="s" /> <Status status={status} size="s" />
</Link> </TruncatedLink>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{status && (!_statuses?.length || _statuses?.length <= 1) && ( {status && (!_statuses?.length || _statuses?.length <= 1) && (
<Link <TruncatedLink
class={`status-link status-type-${type}`} class={`status-link status-type-${type}`}
to={ to={
instance instance
? `/${instance}/s/${actualStatusID}` ? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}` : `/s/${actualStatusID}`
} }
onContextMenu={(e) => {
const post = e.target.querySelector('.status');
if (post) {
// Fire a custom event to open the context menu
if (e.metaKey) return;
e.preventDefault();
post.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: e.clientX,
clientY: e.clientY,
}),
);
}
}}
> >
{isStatic ? ( {isStatic ? (
<Status status={actualStatus} size="s" /> <Status status={actualStatus} size="s" />
) : ( ) : (
<Status statusID={actualStatusID} size="s" /> <Status statusID={actualStatusID} size="s" />
)} )}
</Link> </TruncatedLink>
)} )}
</div> </div>
</div> </div>
); );
} }
function TruncatedLink(props) {
const ref = useTruncated();
return <Link {...props} data-read-more="Read more →" ref={ref} />;
}
export default Notification; export default Notification;

View file

@ -16,6 +16,7 @@ export default memo(function SearchCommand({ onClose = () => {} }) {
setShowSearch(true); setShowSearch(true);
setTimeout(() => { setTimeout(() => {
searchFormRef.current?.focus?.(); searchFormRef.current?.focus?.();
searchFormRef.current?.select?.();
}, 0); }, 0);
}, },
{ {

View file

@ -23,6 +23,9 @@ const SearchForm = forwardRef((props, ref) => {
focus: () => { focus: () => {
searchFieldRef.current.focus(); searchFieldRef.current.focus();
}, },
select: () => {
searchFieldRef.current.select();
},
blur: () => { blur: () => {
searchFieldRef.current.blur(); searchFieldRef.current.blur();
}, },
@ -67,6 +70,9 @@ const SearchForm = forwardRef((props, ref) => {
// autofocus // autofocus
placeholder="Search" placeholder="Search"
dir="auto" dir="auto"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
onSearch={(e) => { onSearch={(e) => {
if (!e.target.value) { if (!e.target.value) {
setSearchParams({}); setSearchParams({});

View file

@ -93,6 +93,38 @@
text-decoration: none; text-decoration: none;
color: var(--text-color); color: var(--text-color);
} }
.status-card-link:not(
.truncated .status-card-link, /* parent status already truncated */
.status-card-link .status-card-link /* nested status cards */
):has(.truncated) {
display: block;
position: relative;
&:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
position: absolute;
--inset-offset: 16px;
inset-block-end: var(--inset-offset);
inset-inline-end: var(--inset-offset);
color: var(--text-color);
background-color: var(--bg-faded-color);
border: 1px dashed var(--link-color);
box-shadow: 0 0 0 1px var(--bg-color), 0 -5px 10px var(--bg-color),
0 -5px 15px var(--bg-color), 0 -5px 20px var(--bg-color);
padding: 0.5em 0.75em;
border-radius: 10em;
font-size: 90%;
white-space: nowrap;
transition: transform 0.2s ease-out;
}
&:is(:hover, :focus):after {
color: var(--link-color);
transform: translate(2px, 0);
}
}
.status-card-link:is(:hover, :focus) .status-card { .status-card-link:is(:hover, :focus) .status-card {
border-color: var(--outline-hover-color); border-color: var(--outline-hover-color);
box-shadow: inset 0 0 0 4px var(--bg-faded-blur-color); box-shadow: inset 0 0 0 4px var(--bg-faded-blur-color);
@ -131,11 +163,16 @@
:is(.content, .poll, .media-container) { :is(.content, .poll, .media-container) {
max-height: 80px !important; max-height: 80px !important;
} }
.status-card :is(.content, .poll) { .status.large .status-card :is(.content, .poll, .media-container) {
max-height: 80vh !important;
}
.status-card :is(.content.truncated, .poll, .media-container.truncated) {
font-size: inherit !important; font-size: inherit !important;
mask-image: linear-gradient(to bottom, #000 80px, transparent); mask-image: linear-gradient(to bottom, #000 80px, transparent);
} }
.status.small .status-card :is(.content, .poll) { .status.small
.status-card
:is(.content.truncated, .poll, .media-container.truncated) {
mask-image: linear-gradient(to bottom, #000 40px, transparent); mask-image: linear-gradient(to bottom, #000 40px, transparent);
} }
.status-card .card { .status-card .card {
@ -174,6 +211,10 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
.status-carousel & {
padding: 16px 16px 16px 24px;
}
} }
.status.filtered .status-filtered-info { .status.filtered .status-filtered-info {
pointer-events: none; pointer-events: none;
@ -246,8 +287,8 @@
.status > .container > .meta { .status > .container > .meta {
display: flex; display: flex;
gap: 8px; gap: 4px;
justify-content: space-between; /* justify-content: space-between; */
white-space: nowrap; white-space: nowrap;
} }
.status.small > .container > .meta { .status.small > .container > .meta {
@ -256,7 +297,11 @@
.status > .container > .meta > * { .status > .container > .meta > * {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; /* text-overflow: ellipsis; */
}
.status > .container > .meta .name-text {
mask-image: linear-gradient(to left, transparent, black 16px);
flex-grow: 1;
} }
.status.large > .container > .meta { .status.large > .container > .meta {
min-height: 50px; min-height: 50px;
@ -417,7 +462,12 @@
.content-container.has-spoiler:not(.show-spoiler) .content-container.has-spoiler:not(.show-spoiler)
.spoiler .spoiler
~ .card ~ .card
.meta-container { .meta-container,
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ .media-container
figcaption {
filter: blur(5px) invert(0.5); filter: blur(5px) invert(0.5);
image-rendering: crisp-edges; image-rendering: crisp-edges;
image-rendering: pixelated; image-rendering: pixelated;
@ -560,8 +610,20 @@
.status .content blockquote { .status .content blockquote {
margin-block: min(0.75em, 12px); margin-block: min(0.75em, 12px);
margin-inline: 0; margin-inline: 0;
padding: 0 0 0 8px; padding-block: 0;
border-left: 4px solid var(--link-faded-color); padding-inline: 12px 0;
/* border-left: 4px solid var(--link-faded-color); */
position: relative;
&:before {
position: absolute;
content: '';
width: 3px;
background-color: var(--link-faded-color);
inset-block: 0;
inset-inline-start: 0;
border-radius: 9999px;
}
} }
.status .content > :is(ul, ol), .status .content > :is(ul, ol),
.status .content > div > :is(ul, ol) { .status .content > div > :is(ul, ol) {
@ -642,6 +704,31 @@
width: auto !important; width: auto !important;
max-width: 100%; max-width: 100%;
display: block; display: block;
figure {
margin: 0;
padding: 0;
figcaption {
margin: -2px 0 0;
padding: 0 4px;
font-size: 90%;
color: var(--text-insignificant-color);
overflow: hidden;
white-space: normal;
display: -webkit-box;
display: box;
-webkit-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.2;
}
}
&:hover figure figcaption {
color: var(--text-color);
}
} }
.status .media-container.media-eq1 .media { .status .media-container.media-eq1 .media {
display: inline-block; display: inline-block;
@ -779,7 +866,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: relative; position: relative;
background-clip: padding-box; background-clip: padding-box;
} }
.status :is(.media-video, .media-audio)[data-formatted-duration] .media-play { .status :is(.media-video, .media-audio) .media-play {
pointer-events: none; pointer-events: none;
width: 44px; width: 44px;
height: 44px; height: 44px;
@ -787,23 +874,17 @@ body:has(#modal-container .carousel) .status .media img:hover {
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: var(--text-color); color: var(--video-fg-color);
background-color: var(--bg-blur-color); background-color: var(--video-bg-color);
/* backdrop-filter: blur(6px) saturate(3) invert(0.2); */ box-shadow: inset 0 0 0 2px var(--video-outline-color);
box-shadow: 0 0 16px var(--drop-shadow-color);
display: flex; display: flex;
place-content: center; place-content: center;
place-items: center; place-items: center;
border-radius: 70px; border-radius: 70px;
transition: all 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
.status .status :is(.media-video, .media-audio):hover:not(:active) .media-play {
:is(.media-video, .media-audio)[data-formatted-duration]:hover:not(:active)
.media-play {
transform: translate(-50%, -50%) scale(1.1); transform: translate(-50%, -50%) scale(1.1);
background-color: var(--bg-color);
box-shadow: 0 0 16px var(--drop-shadow-color),
0 0 8px var(--drop-shadow-color);
} }
.status :is(.media-video, .media-audio)[data-formatted-duration]:after { .status :is(.media-video, .media-audio)[data-formatted-duration]:after {
font-size: 12px; font-size: 12px;
@ -812,9 +893,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
right: 8px; right: 8px;
color: var(--bg-color); color: var(--video-fg-color);
background-color: var(--text-color); background-color: var(--video-bg-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2); border: var(--hairline-width) solid var(--video-outline-color);
border-radius: 4px; border-radius: 4px;
padding: 0 4px; padding: 0 4px;
} }
@ -881,6 +962,15 @@ body:has(#modal-container .carousel) .status .media img:hover {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 100%; max-width: 100%;
/* Convert breaks to spaces */
br {
display: none;
+ * {
margin-left: 1ex;
}
}
} }
.status:not(.large) .hashtag-stuffing:first-child { .status:not(.large) .hashtag-stuffing:first-child {
display: -webkit-box; display: -webkit-box;
@ -1057,6 +1147,9 @@ a:focus-visible .card img {
overflow: hidden; overflow: hidden;
display: block; display: block;
} }
.card:visited .meta.domain {
color: var(--link-visited-color);
}
.card .meta.domain * { .card .meta.domain * {
vertical-align: middle; vertical-align: middle;
} }
@ -1068,6 +1161,9 @@ a.card:is(:hover, :focus) {
border: 1px solid var(--link-color); border: 1px solid var(--link-color);
box-shadow: 0 0 0 2px var(--link-faded-color); box-shadow: 0 0 0 2px var(--link-faded-color);
} }
a.card:is(:hover, :focus):visited {
border-color: var(--link-visited-color);
}
.card.video { .card.video {
max-width: 320px; max-width: 320px;
max-height: 320px; max-height: 320px;
@ -1394,10 +1490,13 @@ a.card:is(:hover, :focus) {
} }
.shortcode-emoji { .shortcode-emoji {
width: 1.2em; width: auto;
min-width: 1.2em;
max-width: 100%;
height: 1.2em; height: 1.2em;
vertical-align: text-bottom; vertical-align: text-bottom;
object-fit: scale-down; object-fit: cover;
object-position: left;
} }
/* EDIT HISTORY */ /* EDIT HISTORY */
@ -1454,26 +1553,52 @@ a.card:is(:hover, :focus) {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
#filtered-status-peek .status-link { #filtered-status-peek {
.status-link {
margin: 8px 0 0;
border-radius: 16px; border-radius: 16px;
border: var(--hairline-width) dashed var(--text-insignificant-color); border: var(--hairline-width) solid var(--divider-color);
position: relative;
max-height: 33vh; max-height: 33vh;
max-height: 33dvh; max-height: 33dvh;
overflow: hidden; overflow: hidden;
}
#filtered-status-peek .status-link .status { &.truncated {
.status {
mask-image: linear-gradient(to bottom, #000 80px, transparent);
}
&:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
position: absolute;
--inset-offset: 16px;
inset-block-end: var(--inset-offset);
inset-inline-end: var(--inset-offset);
color: var(--text-color);
background-color: var(--bg-faded-color);
border: 1px dashed var(--link-color);
box-shadow: 0 0 0 1px var(--bg-color), 0 -5px 10px var(--bg-color),
0 -5px 15px var(--bg-color), 0 -5px 20px var(--bg-color);
padding: 0.5em 0.75em;
border-radius: 10em;
font-size: 90%;
white-space: nowrap;
transition: transform 0.2s ease-out;
}
&:is(:hover, :focus):after {
color: var(--link-color);
transform: translate(2px, 0);
}
}
.status {
pointer-events: none; pointer-events: none;
font-size: 90%; font-size: 90%;
max-height: 33vh; }
max-height: 33dvh; }
overflow: hidden;
mask-image: linear-gradient(black 80%, transparent 95%);
}
#filtered-status-peek .status-post-link {
float: right;
position: sticky;
bottom: 8px;
right: 8px;
} }
/* REACTIONS */ /* REACTIONS */

View file

@ -34,6 +34,7 @@ import Modal from '../components/modal';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import Poll from '../components/poll'; import Poll from '../components/poll';
import { api } from '../utils/api'; import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import getHTMLText from '../utils/getHTMLText'; import getHTMLText from '../utils/getHTMLText';
@ -48,6 +49,7 @@ import showToast from '../utils/show-toast';
import states, { getStatus, saveStatus, statusKey } from '../utils/states'; import states, { getStatus, saveStatus, statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek'; import statusPeek from '../utils/status-peek';
import store from '../utils/store'; import store from '../utils/store';
import useTruncated from '../utils/useTruncated';
import visibilityIconsMap from '../utils/visibility-icons-map'; import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar'; import Avatar from './avatar';
@ -228,7 +230,12 @@ function Status({
inReplyToAccountId === currentAccount || inReplyToAccountId === currentAccount ||
mentions?.find((mention) => mention.id === currentAccount); mentions?.find((mention) => mention.id === currentAccount);
const showSpoiler = previewMode || !!snapStates.spoilers[id] || false; const readingExpandSpoilers = useMemo(() => {
const prefs = store.account.get('preferences') || {};
return !!prefs['reading:expand:spoilers'];
}, []);
const showSpoiler =
previewMode || readingExpandSpoilers || !!snapStates.spoilers[id] || false;
if (reblog) { if (reblog) {
// If has statusID, means useItemID (cached in states) // If has statusID, means useItemID (cached in states)
@ -313,40 +320,9 @@ function Status({
const [showEdited, setShowEdited] = useState(false); const [showEdited, setShowEdited] = useState(false);
const [showReactions, setShowReactions] = useState(false); const [showReactions, setShowReactions] = useState(false);
const spoilerContentRef = useRef(null); const spoilerContentRef = useTruncated();
useResizeObserver({ const contentRef = useTruncated();
ref: spoilerContentRef, const mediaContainerRef = useTruncated();
onResize: () => {
if (spoilerContentRef.current) {
const { scrollHeight, clientHeight } = spoilerContentRef.current;
if (scrollHeight < window.innerHeight * 0.4) {
spoilerContentRef.current.classList.remove('truncated');
} else {
spoilerContentRef.current.classList.toggle(
'truncated',
scrollHeight > clientHeight,
);
}
}
},
});
const contentRef = useRef(null);
useResizeObserver({
ref: contentRef,
onResize: () => {
if (contentRef.current) {
const { scrollHeight, clientHeight } = contentRef.current;
if (scrollHeight < window.innerHeight * 0.4) {
contentRef.current.classList.remove('truncated');
} else {
contentRef.current.classList.toggle(
'truncated',
scrollHeight > clientHeight,
);
}
}
},
});
const readMoreText = 'Read more →'; const readMoreText = 'Read more →';
const statusRef = useRef(null); const statusRef = useRef(null);
@ -1128,6 +1104,7 @@ function Status({
<button <button
class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`} class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
type="button" type="button"
disabled={readingExpandSpoilers}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -1139,7 +1116,11 @@ function Status({
}} }}
> >
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '} <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show more'} {readingExpandSpoilers
? 'Content warning'
: showSpoiler
? 'Show less'
: 'Show more'}
</button> </button>
</> </>
)} )}
@ -1148,7 +1129,12 @@ function Status({
lang={language} lang={language}
dir="auto" dir="auto"
class="inner-content" class="inner-content"
onClick={handleContentLinks({ mentions, instance, previewMode })} onClick={handleContentLinks({
mentions,
instance,
previewMode,
statusURL: url,
})}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: enhanceContent(content, { __html: enhanceContent(content, {
emojis, emojis,
@ -1225,6 +1211,7 @@ function Status({
)} )}
{(((enableTranslate || inlineTranslate) && {(((enableTranslate || inlineTranslate) &&
!!content.trim() && !!content.trim() &&
!!getHTMLText(emojifyText(content, emojis)) &&
differentLanguage) || differentLanguage) ||
forceTranslate) && ( forceTranslate) && (
<TranslationBlock <TranslationBlock
@ -1269,6 +1256,7 @@ function Status({
)} )}
{!!mediaAttachments.length && ( {!!mediaAttachments.length && (
<div <div
ref={mediaContainerRef}
class={`media-container media-eq${mediaAttachments.length} ${ class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : '' mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
@ -1317,7 +1305,7 @@ function Status({
icon={visibilityIconsMap[visibility]} icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]} alt={visibilityText[visibility]}
/>{' '} />{' '}
<a href={url} target="_blank"> <a href={url} target="_blank" rel="noopener noreferrer">
<time <time
class="created" class="created"
datetime={createdAtDate.toISOString()} datetime={createdAtDate.toISOString()}
@ -1569,6 +1557,7 @@ function Card({ card, instance }) {
rel="nofollow noopener noreferrer" rel="nofollow noopener noreferrer"
class={`card link ${blurhashImage ? '' : size}`} class={`card link ${blurhashImage ? '' : size}`}
lang={language} lang={language}
dir="auto"
> >
<div class="card-image"> <div class="card-image">
<img <img
@ -1585,9 +1574,15 @@ function Card({ card, instance }) {
/> />
</div> </div>
<div class="meta-container"> <div class="meta-container">
<p class="meta domain">{domain}</p> <p class="meta domain" dir="auto">
<p class="title">{title}</p> {domain}
<p class="meta">{description || providerName || authorName}</p> </p>
<p class="title" dir="auto">
{title}
</p>
<p class="meta" dir="auto">
{description || providerName || authorName}
</p>
</div> </div>
</a> </a>
); );
@ -2096,6 +2091,8 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
}, },
); );
const statusPeekRef = useTruncated();
return ( return (
<div <div
class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''} class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''}
@ -2169,16 +2166,15 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
</header> </header>
<main tabIndex="-1"> <main tabIndex="-1">
<Link <Link
ref={statusPeekRef}
class="status-link" class="status-link"
to={`/${instance}/s/${status.id}`} to={`/${instance}/s/${status.id}`}
onClick={() => { onClick={() => {
setShowPeek(false); setShowPeek(false);
}} }}
data-read-more="Read more →"
> >
<Status status={status} instance={instance} size="s" readOnly /> <Status status={status} instance={instance} size="s" readOnly />
<button type="button" class="status-post-link plain3">
See post &raquo;
</button>
</Link> </Link>
</main> </main>
</div> </div>
@ -2206,6 +2202,7 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
key={q.instance + q.id} key={q.instance + q.id}
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`} to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
class="status-card-link" class="status-card-link"
data-read-more="Read more →"
> >
<Status <Status
statusID={q.id} statusID={q.id}

View file

@ -206,7 +206,6 @@ function Timeline({
} }
}, [nearReachEnd, showMore]); }, [nearReachEnd, showMore]);
const isHovering = useRef(false);
const idle = useIdle(5000); const idle = useIdle(5000);
console.debug('🧘‍♀️ IDLE', idle); console.debug('🧘‍♀️ IDLE', idle);
const loadOrCheckUpdates = useCallback( const loadOrCheckUpdates = useCallback(
@ -275,12 +274,6 @@ 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
@ -392,6 +385,7 @@ function Timeline({
instance={instance} instance={instance}
size="s" size="s"
contentTextWeight contentTextWeight
allowFilters={allowFilters}
/> />
) : ( ) : (
<Status <Status
@ -399,6 +393,7 @@ function Timeline({
instance={instance} instance={instance}
size="s" size="s"
contentTextWeight contentTextWeight
allowFilters={allowFilters}
/> />
)} )}
</Link> </Link>
@ -419,7 +414,13 @@ function Timeline({
const isSpoiler = item.sensitive && !!item.spoilerText; const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact = const showCompact =
(isSpoiler && i > 0) || (isSpoiler && i > 0) ||
(manyItems && isMiddle && type === 'thread'); (manyItems &&
isMiddle &&
(type === 'thread' ||
(type === 'conversation' &&
!_differentAuthor &&
!items[i - 1]._differentAuthor &&
!items[i + 1]._differentAuthor)));
return ( return (
<li <li
key={`timeline-${statusID}`} key={`timeline-${statusID}`}

View file

@ -1,5 +1,6 @@
import './translation-block.css'; import './translation-block.css';
import pRetry from 'p-retry';
import pThrottle from 'p-throttle'; import pThrottle from 'p-throttle';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
@ -15,12 +16,21 @@ const throttle = pThrottle({
interval: 2000, interval: 2000,
}); });
// Using other API instances instead of lingva.ml because of this bug (slashes don't work):
// https://github.com/thedaviddelta/lingva-translate/issues/68
const LINGVA_INSTANCES = [
'lingva.garudalinux.org',
'lingva.lunar.icu',
'translate.plausibility.cloud',
];
let currentLingvaInstance = 0;
function lingvaTranslate(text, source, target) { function lingvaTranslate(text, source, target) {
console.log('TRANSLATE', text, source, target); console.log('TRANSLATE', text, source, target);
// Using another API instance instead of lingva.ml because of this bug (slashes don't work): const fetchCall = () => {
// https://github.com/thedaviddelta/lingva-translate/issues/68 let instance = LINGVA_INSTANCES[currentLingvaInstance];
return fetch( return fetch(
`https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent( `https://${instance}/api/v1/${source}/${target}/${encodeURIComponent(
text, text,
)}`, )}`,
) )
@ -33,6 +43,18 @@ function lingvaTranslate(text, source, target) {
info: res.info, info: res.info,
}; };
}); });
};
return pRetry(fetchCall, {
retries: 3,
onFailedAttempt: (e) => {
currentLingvaInstance =
(currentLingvaInstance + 1) % LINGVA_INSTANCES.length;
console.log(
'Retrying translation with another instance',
currentLingvaInstance,
);
},
});
// return masto.v1.statuses.translate(id, { // return masto.v1.statuses.translate(id, {
// lang: DEFAULT_LANG, // lang: DEFAULT_LANG,
// }); // });
@ -66,7 +88,7 @@ function TranslationBlock({
const translate = async () => { const translate = async () => {
setUIState('loading'); setUIState('loading');
try { try {
const { content, detectedSourceLanguage, provider, ...props } = const { content, detectedSourceLanguage, provider, error, ...props } =
await onTranslate(text, apiSourceLang.current, targetLang); await onTranslate(text, apiSourceLang.current, targetLang);
if (content) { if (content) {
if (detectedSourceLanguage) { if (detectedSourceLanguage) {
@ -89,7 +111,7 @@ function TranslationBlock({
}); });
} }
} else { } else {
console.error(result); if (error) console.error(error);
setUIState('error'); setUIState('error');
} }
} catch (e) { } catch (e) {

View file

@ -63,6 +63,11 @@
--close-button-color: rgba(0, 0, 0, 0.5); --close-button-color: rgba(0, 0, 0, 0.5);
--close-button-hover-color: rgba(0, 0, 0, 1); --close-button-hover-color: rgba(0, 0, 0, 1);
/* Video colors won't change based on color scheme */
--video-fg-color: #f0f2f5;
--video-bg-color: #242526;
--video-outline-color: color-mix(in lch, var(--video-fg-color), transparent);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1); --timing-function: cubic-bezier(0.3, 0.5, 0, 1);
} }
@ -438,3 +443,24 @@ kbd {
.shazam-container-inner { .shazam-container-inner {
overflow: hidden; overflow: hidden;
} }
@keyframes shazam-horizontal {
0% {
grid-template-columns: 0fr;
}
100% {
grid-template-columns: 1fr;
}
}
.shazam-container-horizontal {
display: grid;
grid-template-columns: 1fr;
transition: grid-template-columns 0.5s ease-in-out;
white-space: nowrap;
}
.shazam-container-horizontal:not(.no-animation) {
animation: shazam-horizontal 0.5s ease-in-out both !important;
}
.shazam-container-horizontal[hidden] {
grid-template-columns: 0fr;
}

View file

@ -30,7 +30,7 @@ export default function HttpRoute() {
<> <>
<h2>Unable to process URL</h2> <h2>Unable to process URL</h2>
<p> <p>
<a href={url} target="_blank"> <a href={url} target="_blank" rel="noopener noreferrer">
{url} {url}
</a> </a>
</p> </p>

View file

@ -4,6 +4,11 @@
gap: 12px; gap: 12px;
animation: appear 0.2s ease-out; animation: appear 0.2s ease-out;
clear: both; clear: both;
b[tabindex='0']:is(:hover, :focus) {
text-decoration: underline;
cursor: pointer;
}
} }
.notification.notification-mention { .notification.notification-mention {
margin-top: 16px; margin-top: 16px;
@ -93,8 +98,8 @@
} }
.notification .status-link:not(.status-type-mention) > .status { .notification .status-link:not(.status-type-mention) > .status {
font-size: calc(var(--text-size) * 0.9); font-size: calc(var(--text-size) * 0.9);
max-height: 160px; }
overflow: hidden; .notification .status-link.truncated:not(.status-type-mention) > .status {
/* fade out mask gradient bottom */ /* fade out mask gradient bottom */
mask-image: linear-gradient( mask-image: linear-gradient(
rgba(0, 0, 0, 1) 130px, rgba(0, 0, 0, 1) 130px,
@ -102,6 +107,33 @@
transparent 159px transparent 159px
); );
} }
.notification .status-link.truncated {
position: relative;
}
.notification .status-link.truncated:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
position: absolute;
--inset-offset: 16px;
inset-block-end: var(--inset-offset);
inset-inline-end: var(--inset-offset);
color: var(--text-color);
background-color: var(--bg-faded-color);
border: 1px dashed var(--link-color);
box-shadow: 0 0 0 1px var(--bg-color), 0 -5px 10px var(--bg-color),
0 -5px 15px var(--bg-color), 0 -5px 20px var(--bg-color);
padding: 0.5em 0.75em;
border-radius: 10em;
font-size: 90%;
white-space: nowrap;
text-shadow: 0 -1px var(--bg-color);
transition: transform 0.2s ease-out;
}
.notification .status-link:is(:hover, :focus).truncated:after {
color: var(--link-color);
transform: translate(2px, 0);
}
.notification .status-link.status-type-mention { .notification .status-link.status-type-mention {
max-height: 320px; max-height: 320px;
filter: none; filter: none;

View file

@ -162,14 +162,12 @@ function Notifications({ columnMode }) {
} }
}, [nearReachEnd, showMore]); }, [nearReachEnd, showMore]);
const isHovering = useRef(false);
const idle = useIdle(5000); const idle = useIdle(5000);
console.debug('🧘‍♀️ IDLE', idle); console.debug('🧘‍♀️ IDLE', idle);
const loadUpdates = useCallback(() => { const loadUpdates = useCallback(() => {
console.log('✨ Load updates', { console.log('✨ Load updates', {
autoRefresh: snapStates.settings.autoRefresh, autoRefresh: snapStates.settings.autoRefresh,
scrollTop: scrollableRef.current?.scrollTop === 0, scrollTop: scrollableRef.current?.scrollTop === 0,
isHovering: isHovering.current,
inBackground: inBackground(), inBackground: inBackground(),
notificationsShowNew: snapStates.notificationsShowNew, notificationsShowNew: snapStates.notificationsShowNew,
uiState, uiState,
@ -177,7 +175,7 @@ function Notifications({ columnMode }) {
if ( if (
snapStates.settings.autoRefresh && snapStates.settings.autoRefresh &&
scrollableRef.current?.scrollTop === 0 && scrollableRef.current?.scrollTop === 0 &&
(!isHovering.current || idle) && idle &&
!inBackground() && !inBackground() &&
snapStates.notificationsShowNew && snapStates.notificationsShowNew &&
uiState !== 'loading' uiState !== 'loading'
@ -236,14 +234,6 @@ function Notifications({ columnMode }) {
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

View file

@ -87,7 +87,7 @@ function Search(props) {
if (type) { if (type) {
params.limit = LIMIT; params.limit = LIMIT;
params.type = type; params.type = type;
params.offset = offsetRef.current; if (authenticated) params.offset = offsetRef.current;
} }
try { try {
const results = await masto.v2.search(params); const results = await masto.v2.search(params);

View file

@ -34,6 +34,7 @@ function Settings({ onClose }) {
const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE; const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE;
const [prefs, setPrefs] = useState(store.account.get('preferences') || {}); const [prefs, setPrefs] = useState(store.account.get('preferences') || {});
const { masto, authenticated } = api();
// Get preferences every time Settings is opened // Get preferences every time Settings is opened
// NOTE: Disabled for now because I don't expect this to change often. Also for some reason, the /api/v1/preferences endpoint is cached for a while and return old prefs if refresh immediately after changing them. // NOTE: Disabled for now because I don't expect this to change often. Also for some reason, the /api/v1/preferences endpoint is cached for a while and return old prefs if refresh immediately after changing them.
// useEffect(() => { // useEffect(() => {
@ -169,12 +170,16 @@ function Settings({ onClose }) {
</li> </li>
</ul> </ul>
</section> </section>
{authenticated && (
<>
<h3>Posting</h3> <h3>Posting</h3>
<section> <section>
<ul> <ul>
<li> <li>
<div> <div>
<label for="posting-privacy-field">Default visibility</label> <label for="posting-privacy-field">
Default visibility
</label>
</div> </div>
<div> <div>
<select <select
@ -182,7 +187,6 @@ function Settings({ onClose }) {
value={prefs['posting:default:visibility'] || 'public'} value={prefs['posting:default:visibility'] || 'public'}
onChange={(e) => { onChange={(e) => {
const { value } = e.target; const { value } = e.target;
const { masto } = api();
(async () => { (async () => {
try { try {
await masto.v1.accounts.updateCredentials({ await masto.v1.accounts.updateCredentials({
@ -213,6 +217,8 @@ function Settings({ onClose }) {
</li> </li>
</ul> </ul>
</section> </section>
</>
)}
<h3>Experiments</h3> <h3>Experiments</h3>
<section> <section>
<ul> <ul>
@ -384,6 +390,7 @@ function Settings({ onClose }) {
</small> </small>
</div> </div>
</li> </li>
{authenticated && (
<li> <li>
<button <button
type="button" type="button"
@ -396,9 +403,10 @@ function Settings({ onClose }) {
Unsent drafts Unsent drafts
</button> </button>
</li> </li>
)}
</ul> </ul>
</section> </section>
<PushNotificationsSection onClose={onClose} /> {authenticated && <PushNotificationsSection onClose={onClose} />}
<h3>About</h3> <h3>About</h3>
<section> <section>
<div <div
@ -439,7 +447,11 @@ function Settings({ onClose }) {
</a> </a>
) )
<br /> <br />
<a href="https://github.com/cheeaun/phanpy" target="_blank"> <a
href="https://github.com/cheeaun/phanpy"
target="_blank"
rel="noopener noreferrer"
>
Original Original
</a>{' '} </a>{' '}
by{' '} by{' '}

View file

@ -31,6 +31,9 @@
.ancestors-indicator { .ancestors-indicator {
font-size: 70% !important; font-size: 70% !important;
} }
.ancestors-indicator:not([hidden]) {
animation: slide-up 0.3s both ease-out 0.3s;
}
.ancestors-indicator[hidden] { .ancestors-indicator[hidden] {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;

View file

@ -8,7 +8,7 @@ function emojifyText(text, emojis = []) {
const { shortcode, staticUrl, url } = emoji; const { shortcode, staticUrl, url } = emoji;
text = text.replace( text = text.replace(
new RegExp(`:${shortcode}:`, 'g'), new RegExp(`:${shortcode}:`, 'g'),
`<picture><source srcset="${staticUrl}" media="(prefers-reduced-motion: reduce)"></source><img class="shortcode-emoji emoji" src="${url}" alt=":${shortcode}:" width="12" height="12" loading="lazy" decoding="async" /></picture>`, `<picture><source srcset="${staticUrl}" media="(prefers-reduced-motion: reduce)"></source><img class="shortcode-emoji emoji" src="${url}" alt=":${shortcode}:" width="16" height="16" loading="lazy" decoding="async" /></picture>`,
); );
}); });
// console.log(text, emojis); // console.log(text, emojis);

View file

@ -1,6 +1,7 @@
import emojifyText from './emojify-text'; import emojifyText from './emojify-text';
const fauxDiv = document.createElement('div'); const fauxDiv = document.createElement('div');
const whitelistLinkClasses = ['u-url', 'mention', 'hashtag'];
function enhanceContent(content, opts = {}) { function enhanceContent(content, opts = {}) {
const { emojis, postEnhanceDOM = () => {} } = opts; const { emojis, postEnhanceDOM = () => {} } = opts;
@ -10,15 +11,25 @@ function enhanceContent(content, opts = {}) {
const hasLink = /<a/i.test(enhancedContent); const hasLink = /<a/i.test(enhancedContent);
const hasCodeBlock = enhancedContent.indexOf('```') !== -1; const hasCodeBlock = enhancedContent.indexOf('```') !== -1;
if (hasLink) {
// Add target="_blank" to all links with no target="_blank" // Add target="_blank" to all links with no target="_blank"
// E.g. `note` in `account` // E.g. `note` in `account`
if (hasLink) {
const noTargetBlankLinks = Array.from( const noTargetBlankLinks = Array.from(
dom.querySelectorAll('a:not([target="_blank"])'), dom.querySelectorAll('a:not([target="_blank"])'),
); );
noTargetBlankLinks.forEach((link) => { noTargetBlankLinks.forEach((link) => {
link.setAttribute('target', '_blank'); link.setAttribute('target', '_blank');
}); });
// Remove all classes except `u-url`, `mention`, `hashtag`
const links = Array.from(dom.querySelectorAll('a[class]'));
links.forEach((link) => {
Array.from(link.classList).forEach((c) => {
if (!whitelistLinkClasses.includes(c)) {
link.classList.remove(c);
}
});
});
} }
// Add 'has-url-text' to all links that contains a url // Add 'has-url-text' to all links that contains a url

33
src/utils/focus-deck.jsx Normal file
View file

@ -0,0 +1,33 @@
const focusDeck = () => {
let timer = setTimeout(() => {
const columns = document.getElementById('columns');
if (columns) {
// Focus first column
// columns.querySelector('.deck-container')?.focus?.();
} else {
const modals = document.querySelectorAll('#modal-container > *');
if (modals?.length) {
// Focus last modal
const modal = modals[modals.length - 1]; // last one
const modalFocusElement =
modal.querySelector('[tabindex="-1"]') || modal;
if (modalFocusElement) {
modalFocusElement.focus();
return;
}
}
const backDrop = document.querySelector('.deck-backdrop');
if (backDrop) return;
// Focus last deck
const pages = document.querySelectorAll('.deck-container');
const page = pages[pages.length - 1]; // last one
if (page && page.tabIndex === -1) {
console.log('FOCUS', page);
page.focus();
}
}
}, 100);
return () => clearTimeout(timer);
};
export default focusDeck;

View file

@ -1,11 +1,17 @@
import states from './states'; import states from './states';
function handleContentLinks(opts) { function handleContentLinks(opts) {
const { mentions = [], instance, previewMode } = opts || {}; const { mentions = [], instance, previewMode, statusURL } = opts || {};
return (e) => { return (e) => {
let { target } = e; let { target } = e;
target = target.closest('a'); target = target.closest('a');
if (!target) return; if (!target) return;
// If cmd/ctrl/shift/alt key is pressed or middle-click, let the browser handle it
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.which === 2) {
return;
}
const prevText = target.previousSibling?.textContent; const prevText = target.previousSibling?.textContent;
const textBeforeLinkIsAt = prevText?.endsWith('@'); const textBeforeLinkIsAt = prevText?.endsWith('@');
const textStartsWithAt = target.innerText.startsWith('@'); const textStartsWithAt = target.innerText.startsWith('@');
@ -50,7 +56,11 @@ function handleContentLinks(opts) {
const hashURL = instance ? `#/${instance}/t/${tag}` : `#/t/${tag}`; const hashURL = instance ? `#/${instance}/t/${tag}` : `#/t/${tag}`;
console.log({ hashURL }); console.log({ hashURL });
location.hash = hashURL; location.hash = hashURL;
} else if (states.unfurledLinks[target.href]?.url) { } else if (
states.unfurledLinks[target.href]?.url &&
statusURL !== target.href
) {
// If unfurled AND not self-referential
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
states.prevLocation = { states.prevLocation = {

View file

@ -1,5 +1,7 @@
import Toastify from 'toastify-js'; import Toastify from 'toastify-js';
window._showToast = showToast;
function showToast(props) { function showToast(props) {
if (typeof props === 'string') { if (typeof props === 'string') {
props = { text: props }; props = { text: props };

View file

@ -139,6 +139,7 @@ export function hideAllModals() {
states.showMediaModal = false; states.showMediaModal = false;
states.showShortcutsSettings = false; states.showShortcutsSettings = false;
states.showKeyboardShortcutsHelp = false; states.showKeyboardShortcutsHelp = false;
states.showGenericAccounts = false;
} }
export function statusKey(id, instance) { export function statusKey(id, instance) {

17
src/utils/useTruncated.js Normal file
View file

@ -0,0 +1,17 @@
import { useRef } from 'preact/hooks';
import useResizeObserver from 'use-resize-observer';
export default function useTruncated({ className = 'truncated' } = {}) {
const ref = useRef();
useResizeObserver({
ref,
box: 'border-box',
onResize: ({ height }) => {
if (ref.current) {
const { scrollHeight } = ref.current;
ref.current.classList.toggle(className, scrollHeight > height);
}
},
});
return ref;
}