Compare commits

...

332 commits

Author SHA1 Message Date
Natsu Kagami c72bd47bbd
Don't statically pin the size of the carousel 2024-08-12 15:42:44 +02:00
Natsu Kagami bc0f856d72
Link to GtS settings when we know we are on GtS 2024-08-12 15:42:44 +02:00
Natsu Kagami 0e4dd6ee39
Make main column bigger 2024-08-12 15:42:44 +02:00
Natsu Kagami 0fa57fc0aa
Add a bit more touch 2024-08-12 15:42:43 +02:00
Natsu Kagami bb440c5d28
Force display instance
Because I don't like this decision from Phanpy
2024-08-12 15:42:43 +02:00
Natsu Kagami 66078a1867
Automatically put people into DTTHDon login 2024-08-12 15:42:43 +02:00
Natsu Kagami 3ff14d942e
Add some DTTH notice 2024-08-12 15:42:43 +02:00
Natsu Kagami 109b919c6c
Incorporate commit hash 2024-08-12 15:42:43 +02:00
Natsu Kagami 28fb3e4102
Get flakes to work 2024-08-12 15:42:28 +02:00
Chee Aun 9bf50615cb
Merge pull request #615 from verymilan/phanpy.social.tchncs.de
add phanpy.social.tchncs.de deployment to readme
2024-08-07 09:51:17 +08:00
Chee Aun dfa1123ac3
Merge pull request #616 from Fastidious/patch-2
Update README.md
2024-08-07 09:50:39 +08:00
Fastidious a0f2eb7305
Update README.md
Moved servers. This updates to the new one.
2024-08-06 10:47:13 -04:00
Milan 1bd9ceb4fc
add phanpy.social.tchncs.de deployment to readme 2024-08-06 12:39:12 +02:00
Chee Aun 082409a09f
Merge pull request #613 from illfygli/main
Filter out languages that aren't RFC5646-shaped
2024-08-06 08:58:16 +08:00
owl 225eaf4a2d
Pass undefined to Intl.DisplayNames, so '*' doesn't break it 2024-08-05 14:14:03 +02:00
Lim Chee Aun 60289cdb29 Upgrade dependencies 2024-08-04 19:09:46 +08:00
Lim Chee Aun a1c419b675 Try fix select field bug on Windows again
Previously: b47c043699
2024-08-04 19:01:21 +08:00
Lim Chee Aun 89e8bdf77b Use pinned instead of _pinned 2024-08-04 18:06:26 +08:00
Lim Chee Aun b3681a93ee Workbox expiration plugin not working as expected 2024-08-04 18:05:03 +08:00
Lim Chee Aun ad7193d067 Fix notifications popover not close-able on iPad 2024-08-04 13:53:06 +08:00
Lim Chee Aun f05e3012e3 Preliminary step for RTL 2024-08-04 13:32:46 +08:00
Lim Chee Aun 2aff1dc1fd Try switch to 20s interval 2024-08-04 13:32:46 +08:00
Lim Chee Aun 99ee6c3979 Don't reuse var for both timeout and interval 2024-08-04 13:32:46 +08:00
Lim Chee Aun 4ebfb544aa This caching seems still buggy
Revert to SWR with 1-min expiry
2024-08-04 13:32:46 +08:00
Lim Chee Aun cf2461add5 Better checks 2024-08-04 13:32:46 +08:00
Chee Aun 4937c5f77e
Merge pull request #610 from fhemberger/patch-1
fix(shortcuts-settings): `settingsJSON` must be defined if note doesn't exist
2024-08-04 10:11:42 +08:00
Frederic Hemberger 0febcacb93
fix(shortcuts-settings): settingsJSON must be defined if note doesn't exist 2024-08-03 13:30:22 +02:00
Lim Chee Aun 818f58b460 Fix profile URLs not working for http route 2024-08-01 20:18:44 +08:00
Lim Chee Aun 57db8778a4 Adapt to new changes in group notifications API
Reference: https://github.com/mastodon/mastodon/pull/31214
2024-08-01 20:18:10 +08:00
Chee Aun 9806d8ae9d
Merge pull request #607 from kizu/fix-overflow
Fix overflow for the columns wrapper
2024-08-01 09:56:05 +08:00
Roman Komarov 522a324b0d
Fix overflow for the columns wrapper 2024-07-31 13:59:35 +02:00
Lim Chee Aun 5be30e0c80 Upgrade dependencies 2024-07-29 20:05:03 +08:00
Lim Chee Aun 379ef7cc11 Random unused IntersectionView
Keeping this for future use
2024-07-28 16:09:44 +08:00
Lim Chee Aun 2d23b15c8d Assume title is the author for .card-post 2024-07-28 16:09:03 +08:00
Lim Chee Aun fa3a0e23cc Unhide some text for posts inside Edit History
Every char matters when looking at post edit history
2024-07-28 16:08:18 +08:00
Lim Chee Aun 631730f2f2 Replace SWR with CacheFirst
This SWR strategy is sometimes too stale, possibly a bug with Workbox
2024-07-28 16:07:22 +08:00
Lim Chee Aun f1822d54af Fix poll radio button position on Safari
Plus a color
2024-07-25 18:39:14 +08:00
Lim Chee Aun 4c0bc62ad0 Group filtered carousel items 2024-07-22 14:31:52 +08:00
Lim Chee Aun 84b3106f50 Undo font size inherit for card posts 2024-07-22 14:19:25 +08:00
Lim Chee Aun a2b88f1cdd Distinct both implementation of grouped notifications 2024-07-21 20:31:10 +08:00
Lim Chee Aun b88376569e Test this out for bridgy fed links 2024-07-21 19:06:38 +08:00
Lim Chee Aun 00e2ba0b34 Fix notification markers not working
Also the ids are getting confusing, so need to clean this up.
2024-07-21 18:59:38 +08:00
Lim Chee Aun a0d75e7e83 Upgrade dependencies 2024-07-20 17:45:43 +08:00
Lim Chee Aun 4b2ec14dcd Try set default sort and group when choosing Boosts 2024-07-19 20:00:10 +08:00
Chee Aun 808c6262d8
Merge pull request #597 from graue/graue/copy-handle-with-instance
Include domain when copying local user's handle
2024-07-18 17:08:51 +08:00
Scott Feeney 44d440649f Include domain when copying local user's handle
Fixes #596
2024-07-13 01:15:01 -07:00
Lim Chee Aun a2f7638257 Experimental opt-in server-side grouped notifications 2024-07-12 18:57:48 +08:00
Lim Chee Aun 57d6889826 Test memoize Media 2024-07-12 13:35:43 +08:00
Lim Chee Aun 2a91c005a1 Test fix self-recursive quote posts 2024-07-12 13:34:57 +08:00
Lim Chee Aun 418895e1c3 Another attempt: upgrade dependencies 2024-07-08 17:40:16 +08:00
Lim Chee Aun 180a23f116 Fix wrong exceeded chars highlighting 2024-07-07 22:56:24 +08:00
Lim Chee Aun 9ea7a1f4db Use onClose for this 2024-07-06 09:47:42 +08:00
Lim Chee Aun f26dbeb79a Fix more cloaking business 2024-07-06 09:47:28 +08:00
Lim Chee Aun f0872e79fb Revert "Upgrade dependencies"
This reverts commit cb9848fe8c.
2024-07-05 18:56:52 +08:00
Lim Chee Aun a72400febf Test support Hollo 2024-07-05 16:19:04 +08:00
Lim Chee Aun cb9848fe8c Upgrade dependencies 2024-07-03 20:02:47 +08:00
Lim Chee Aun c950a6552c Experiment: unhide header when clicking on timeline items 2024-07-03 20:01:11 +08:00
Lim Chee Aun 95bf9e183e Replace trivago/ with ianvs/prettier-plugin-sort-imports 2024-07-01 17:41:21 +08:00
Lim Chee Aun e6e884f1cb Refactor + make card post work for no-image cards 2024-06-28 07:49:30 +08:00
Lim Chee Aun b6a25f5939 MVP-ish add/remove featured tags 2024-06-27 22:05:16 +08:00
Lim Chee Aun 71823fbad2 Fix typo 2024-06-27 22:05:16 +08:00
Lim Chee Aun 046d3d323a Enable unfurling when fetching reply hints 2024-06-27 22:05:16 +08:00
Lim Chee Aun f7024f7723 Only allow trending link posts for current instance, not remote instance
For this to work on remote instance, will need to fetch its version and check first
2024-06-27 22:05:16 +08:00
Lim Chee Aun 1b3938f3d2 Add bundle-visualizer 2024-06-27 22:05:16 +08:00
Lim Chee Aun 5ab0ea1b59 Remove usehooks dep
In the end, only used one hook out of so many hooks
2024-06-27 22:05:16 +08:00
Lim Chee Aun 09745e3078 Don't show account if notification = severed_relationships 2024-06-27 22:05:16 +08:00
Chee Aun 87be0cad16
Merge pull request #584 from coxde/patch-1
fix: enable/disable boosts button logic
2024-06-27 22:00:01 +08:00
COxDE 04588874c7
fix: enable/disable boosts button logic 2024-06-27 13:38:55 +01:00
Lim Chee Aun 5d6a43e5d2 Bump up to 600 2024-06-23 15:41:00 +08:00
Lim Chee Aun 7f5f01b118 Further extend quote post dimensions 2024-06-22 17:48:14 +08:00
Lim Chee Aun f4a4913889 Don't animate for small-dimension images 2024-06-22 17:47:56 +08:00
Lim Chee Aun 7fb4aad089 Upgrade vite 2024-06-22 12:42:43 +08:00
Lim Chee Aun f8e72d1808 Prevent miscalculated width in large media container 2024-06-22 12:42:23 +08:00
Lim Chee Aun 527a1551cf The math here is quite forgiving 2024-06-19 23:34:19 +08:00
Lim Chee Aun a6e6a7d741 Simplify natural aspect ratio math 2024-06-19 18:29:20 +08:00
Lim Chee Aun 21bdb6afc1 Posts timeline for trending links
Timeline logic changed slightly, so might be buggy.
2024-06-19 12:22:17 +08:00
Lim Chee Aun 4be88da1d6 Test slight fade out 2024-06-19 12:19:48 +08:00
Lim Chee Aun 93bb1da7c9 Fix undefined in account link when ctrl-clicking 2024-06-18 20:14:56 +08:00
Lim Chee Aun 497ede1a3d Use state to set natural aspect ratio instead
And fix all other data attributes
2024-06-15 10:25:10 +08:00
Lim Chee Aun 8a1fda5a85 Prevent flash of post page 2024-06-15 10:24:33 +08:00
Lim Chee Aun 83164c321f Apply anim-duration for card image too
And also media images in Catch-up
2024-06-15 08:36:12 +08:00
Lim Chee Aun 15ebf628f8 Give status cards same treatment as media attachments 2024-06-14 18:13:59 +08:00
Lim Chee Aun fbe540ca7f Upgrade dependencies 2024-06-14 08:36:23 +08:00
Lim Chee Aun 1f8a8f8928 Use URL.parse with polyfill 2024-06-14 08:34:50 +08:00
Lim Chee Aun febd04dd54 Try use dangerouslySetInnerHTML again
And… fix the loop attribute value
2024-06-11 23:43:55 +08:00
Lim Chee Aun 983dd6623f Try autoPlay instead of autoplay
Fixing Mobile Safari bug
2024-06-11 18:17:19 +08:00
Lim Chee Aun a79d0613ec One more experimental magic 2024-06-11 14:53:12 +08:00
Lim Chee Aun c0c7fdd6e1 Handle tiny images & fix layout
Honestly there's just too many possibilities
2024-06-11 14:46:29 +08:00
Lim Chee Aun 17a3939061 Use data attr instead
The JSX className modification classes with this DOM-based modification
2024-06-10 20:50:21 +08:00
Lim Chee Aun 8a10a81fec Experiment immersive media render on large-size post 2024-06-10 20:42:38 +08:00
Lim Chee Aun 17230fc690 Experiment reduce radius for uncropped images 2024-06-10 20:41:43 +08:00
Lim Chee Aun 88e36183c6 Experiment different card preview style 2024-06-10 20:40:35 +08:00
Lim Chee Aun d0bb0c04db Small style adjustments to composer 2024-06-10 20:39:03 +08:00
Lim Chee Aun 42d761e747 Chunk tinyld out 2024-06-10 20:38:41 +08:00
Lim Chee Aun 901725793b Try resolve threads' links if they work one day 2024-06-08 21:36:09 +08:00
Lim Chee Aun 3fbecb2f0d Fix NameText not showing username when short 2024-06-08 21:35:14 +08:00
Lim Chee Aun ef1abbc25c Wait I need a slash here? 2024-06-08 21:34:50 +08:00
Lim Chee Aun 2f75dfd9e4 Prefs need to be awaited 2024-06-07 18:41:04 +08:00
Lim Chee Aun 8d91bfb0a3 Throttle account fetches 2024-06-07 18:38:26 +08:00
Lim Chee Aun 04e1d60e54 Check vapidKey 2024-06-06 17:47:44 +08:00
Lim Chee Aun 1c01e1b0f4 Fix federated feed only showing remote posts
There's a mismatch parameter between Mastodon's and Pixelfed's APIs
2024-06-06 17:47:44 +08:00
Chee Aun dea3507053
Merge pull request #561 from zkreml/add-my-instance
Added phanpy.cz to self-hosted instances
2024-06-05 09:02:29 +08:00
archos 9b35119f99 Added phanpy.cz to self-hosted instances 2024-06-04 20:04:04 +02:00
Chee Aun 6d7eddc568
Merge pull request #557 from kevquirk/patch-1
Added social.qrk.one to self-hosted instances
2024-06-03 22:37:31 +08:00
Kev Quirk dac2af4334
Added social.qrk.one to self-hosted instances 2024-06-03 14:54:09 +01:00
Lim Chee Aun 2099953b68 Remove spaces between buttons 2024-06-03 18:01:49 +08:00
Lim Chee Aun 5931ebb8fc Reduce visual clutter for grouped notification
30 instead of 50 as limit. No more tiny avatars as they don't help much.
2024-06-02 22:52:47 +08:00
Lim Chee Aun adcb87679b Upgrade dependencies
Try bump text-expander too as it might have fixed its bugs
2024-06-01 16:33:13 +08:00
Lim Chee Aun 5ead17a093 Disable GIF button if exceed max media limit or has poll 2024-06-01 11:51:58 +08:00
Lim Chee Aun 224cad4d7f Utilise the new batch fetch on Mastodon v4.3 2024-05-31 17:11:40 +08:00
Lim Chee Aun e08817d611 Attempt to rewrite this part 2024-05-31 16:56:13 +08:00
Lim Chee Aun 1ffc1c257a Use setTimeout instead 2024-05-29 18:46:42 +08:00
Lim Chee Aun 098014a109 Fix possible error 2024-05-29 18:46:14 +08:00
Lim Chee Aun 7546b42c7c Further improve lang detection perf 2024-05-29 15:26:58 +08:00
Lim Chee Aun f9a73777e7 Perf over function 2024-05-29 10:23:46 +08:00
Lim Chee Aun d5584f8dd4 Delay preload 2024-05-29 08:58:17 +08:00
Lim Chee Aun 563b06e680 Break the tasks 2024-05-28 22:22:14 +08:00
Lim Chee Aun b6a64b66c7 Fix wrong logic for highlighting Languages select 2024-05-28 21:03:05 +08:00
Lim Chee Aun 0a4aae51b7 It's time for MVP-ish language auto-detection 2024-05-28 17:59:17 +08:00
Lim Chee Aun d16221e296 Test fix Pixelfed home timeline not showing reblogs 2024-05-28 13:44:24 +08:00
Lim Chee Aun ed712d15f1 Test fix notification toast appearing after loaded 2024-05-28 13:44:02 +08:00
Lim Chee Aun bd8817e61b Show warning if exceed file size or matrix limit 2024-05-27 19:19:34 +08:00
Lim Chee Aun ef712c62a9 Add one more username ≈ display name logic 2024-05-27 19:02:19 +08:00
Lim Chee Aun 9aa2bac685 Try fix toast width again 2024-05-27 19:01:41 +08:00
Lim Chee Aun 34077e8467 Don't show 'More…' for hashtag autosuggest 2024-05-26 18:15:37 +08:00
Lim Chee Aun b473061845 Show compose button above post modal when minimized 2024-05-26 00:13:20 +08:00
Lim Chee Aun 64c7b5b4f0 Rewrite polyfill suspense for Composer with preload
Hopefully this works
2024-05-25 20:43:15 +08:00
Lim Chee Aun c11bbbb2b3 Handle modifiers when clicking on account links 2024-05-25 13:52:25 +08:00
Lim Chee Aun 2c1a6c8cb5 Restyle the composer controls UI 2024-05-25 13:39:11 +08:00
Lim Chee Aun 67a85e1eef Forgot Mobile Safari always need 16px for input fields 2024-05-25 13:16:22 +08:00
Lim Chee Aun 2e0ef6494b Extend at-mentions with dedicated UI 2024-05-25 11:06:58 +08:00
Lim Chee Aun 012b86d7ce Try not hide compose button if loading 2024-05-25 11:06:03 +08:00
Lim Chee Aun 0c45f515f0 Don't add space if empty string 2024-05-25 09:16:03 +08:00
Lim Chee Aun 9cc590be1b Extra check if the composer is publishing 2024-05-25 09:15:43 +08:00
Lim Chee Aun 7589ec8803 Downgrade text-expander
Possibly might fix autosuggest position bug on Mobile Safari
2024-05-25 09:15:13 +08:00
Lim Chee Aun cd17ca0b42 Experiment: allow minimize composer 2024-05-24 12:30:20 +08:00
Lim Chee Aun 8aab997900 Upgrade dependencies 2024-05-23 20:25:14 +08:00
Lim Chee Aun 96c44ed485 Fix composer not overwritten by restored composer window 2024-05-23 14:14:23 +08:00
Lim Chee Aun 7053fcc96a Experimental 'More…' for custom emojis suggestions
Also includes small fixes and improvements
2024-05-22 19:12:13 +08:00
Lim Chee Aun ad7cb46547 Experiment auto-expand spoiler in hero status 2024-05-19 18:46:27 +08:00
Lim Chee Aun 1b1af67064 Experiment non-English description generation 2024-05-19 16:27:59 +08:00
Lim Chee Aun bdd238de0e Test using inert to control text searchability 2024-05-19 16:26:15 +08:00
Lim Chee Aun ced4dc86aa Forgot passing blankCopy 2024-05-19 16:24:29 +08:00
Lim Chee Aun 7be1e589ab Support Pleroma's /notice unfurl 2024-05-19 16:23:12 +08:00
Lim Chee Aun 7da1745cca Respect expand spoiler setting in Catchup 2024-05-19 16:22:18 +08:00
Lim Chee Aun 025a5429cc Set limit to 80 for notifications 2024-05-17 18:32:12 +08:00
Lim Chee Aun 62f843b4dc Fix crash when media url doesn't have http prefix 2024-05-17 17:10:54 +08:00
Lim Chee Aun b0a53b7fa1 Handle hideCollections 2024-05-16 21:11:51 +08:00
Lim Chee Aun 9934daeb4d Handle filtered quote posts 2024-05-16 13:00:23 +08:00
Lim Chee Aun d4a0a080b5 Bump up max entries for icons 2024-05-15 19:38:28 +08:00
Lim Chee Aun bc4e3b0f72 Fix red too faint in dark mode 2024-05-14 23:39:48 +08:00
Lim Chee Aun ac760265da Fix post preview internals becoming clickable 2024-05-11 13:09:08 +08:00
Lim Chee Aun 98b0ccf032 Default to floor rounding mode 2024-05-10 12:11:57 +08:00
Lim Chee Aun 90f06c511a Test allow linking to post from generic accounts modal 2024-05-08 10:29:00 +08:00
Lim Chee Aun e7aad03279 Preliminary implementation of moderation_warning notifications 2024-05-08 10:28:34 +08:00
Lim Chee Aun 1c6b0aa0d7 Upgrade dependencies 2024-05-06 12:48:55 +08:00
Lim Chee Aun 3e1b9ff53d Apply filter context in compact status too 2024-05-02 23:29:01 +08:00
Lim Chee Aun 5c9a47c31e Might as well re-use it for instances search 2024-05-02 00:14:48 +08:00
Lim Chee Aun 65a4c3441c Add search for custom emojis 2024-05-02 00:14:25 +08:00
Lim Chee Aun 77bc06545c Handle inline images 2024-05-01 15:05:29 +08:00
Lim Chee Aun 11e64a2cc4 Fix filter expiry wrongly set if there's no expiry 2024-04-28 08:30:52 +08:00
Lim Chee Aun 5433e4e119 initStates needed for standalone compose page 2024-04-28 08:30:52 +08:00
Lim Chee Aun c8dc32b884 Test caching shazam states 2024-04-28 08:30:52 +08:00
Lim Chee Aun 1f29aee26e Upgrade dependencies 2024-04-28 08:30:52 +08:00
Lim Chee Aun daae055f4d List out forks 2024-04-28 08:30:52 +08:00
Chee Aun 044f754d7e
Merge pull request #522 from mickaobrien/timeline-enter-keyboard-shortcut-fix
Fix `enter` keyboard shortcut on timeline
2024-04-28 08:29:47 +08:00
Mick O'Brien 5ae2058c07 Fix enter keyboard shortcut on timeline
Currently pressing `enter` opens the active status if the status or any
focusable child of the status is focused e.g. the avatar or a link.

I think it should only open the post details when the post itself is
focused.
2024-04-26 12:23:53 +01:00
Lim Chee Aun 7376cb1e99 Fix muted="false" means still muted 🤦‍♂️🤦‍♂️🤦‍♂️ 2024-04-19 08:46:10 +08:00
Lim Chee Aun ffbae70178 Remove newline from regex for shortcode 2024-04-19 08:41:16 +08:00
Lim Chee Aun 9235d2c800 Hide poll button if maxOptions <= 1
It's not a poll if there's only 1 option
2024-04-18 23:12:29 +08:00
Lim Chee Aun 6ccefaebe1 Handle invalid date
Ugly solution for now, but it's already ugly
2024-04-18 23:11:18 +08:00
Lim Chee Aun 5a448c8049 Fix infinite reloading
Comment these out because this used to fix an old bug with instances not loaded properly
2024-04-18 23:10:26 +08:00
Lim Chee Aun 9bf77fa97a Mentions also need fixNotifications
It's also from notifications API
2024-04-18 17:15:51 +08:00
Lim Chee Aun b9058c6e3d Debounced auto-submit for GIF search field 2024-04-17 08:26:35 +08:00
Lim Chee Aun 55ad6500bc Fix margins 2024-04-16 23:21:46 +08:00
Lim Chee Aun f4b95d254c Maybe this helps? 2024-04-16 20:18:18 +08:00
Lim Chee Aun effbe189e1 Revert "Test upgrade react-hotkeys-hook for the keys fix"
This reverts commit 9285a0ba9a.
2024-04-16 00:09:53 +08:00
Lim Chee Aun 44e910b8c9 Fix wrong carousel math 2024-04-15 23:34:58 +08:00
Lim Chee Aun a68dccd7cf Fix rerender bug with followed hashtag parent
And… somehow memoize it?
2024-04-15 21:37:03 +08:00
Lim Chee Aun 9a6364a674 Obviously got to flex my scroll-driven animation CSSkillz 2024-04-15 19:59:57 +08:00
Lim Chee Aun e2f39596f0 Might as well add more supports 2024-04-15 19:58:59 +08:00
Lim Chee Aun 701b9e99b3 More media-first styling changes 2024-04-15 17:07:34 +08:00
Lim Chee Aun 294ab2bf00 Just put in this commented test notification
Good for reference in the future
2024-04-15 17:07:20 +08:00
Lim Chee Aun 304ce5a3e8 Experiment dynamic change of parent
This might prevent double renders
2024-04-15 17:06:44 +08:00
Lim Chee Aun 57390a291b No need background if there's pre-meta before it 2024-04-15 10:10:49 +08:00
Lim Chee Aun cd5920114f Undo back to -45deg, not everything need 135deg 2024-04-15 07:26:45 +08:00
Lim Chee Aun 06c6360cae More support for Pixelfed 2024-04-14 17:20:18 +08:00
Lim Chee Aun afdfdb86da Media-first style adjustments 2024-04-14 17:18:52 +08:00
Lim Chee Aun 6f8f3e4fd0 Change -35deg to 145deg prevents stripes animation
When dynamically changing dimension (height), repeating linear gradient seems to animate. This prevents it.
https://stackoverflow.com/a/76285775/20838
2024-04-14 14:08:50 +08:00
Lim Chee Aun 342ff20986 Document PHANPY_IMG_ALT_API_URL 2024-04-14 08:14:34 +08:00
Lim Chee Aun 94996d098e Fix width issue 2024-04-13 23:08:25 +08:00
Lim Chee Aun c286562ee8 Media-first style adjustments 2024-04-13 19:21:48 +08:00
Lim Chee Aun 5babdc9d63 Fix width/height not set 2024-04-13 19:21:20 +08:00
Lim Chee Aun 260bb8746d More media-first adjustments 2024-04-13 17:10:13 +08:00
Lim Chee Aun 7be620808f Fix notifications for Pixelfed 2024-04-13 17:09:56 +08:00
Lim Chee Aun df3aca70fa Open media + post view for wider viewports 2024-04-13 17:09:00 +08:00
Lim Chee Aun ec65163c89 More breathing space 2024-04-13 17:08:39 +08:00
Lim Chee Aun 6f22ec3842 Fix missing idempotency key 2024-04-13 17:07:28 +08:00
Lim Chee Aun 2faf9b4c20 Pixelfed needs remote which is opposite of local lol 2024-04-13 00:11:00 +08:00
Lim Chee Aun 501e43207b Don't set onlyMedia if not set
This defaults to false for Mastodon, but true for Pixelfed
2024-04-13 00:11:00 +08:00
Lim Chee Aun e782cc0dde Refactor set/get current account ID
And add fallback for standalone mode where session storage is not enough
2024-04-13 00:11:00 +08:00
Lim Chee Aun aefda31c2a Temporary quick fix, remove dash from hashtag regex 2024-04-13 00:11:00 +08:00
Lim Chee Aun 9285a0ba9a Test upgrade react-hotkeys-hook for the keys fix 2024-04-13 00:11:00 +08:00
Chee Aun 7fb56d9f6c
Merge pull request #493 from ultramookie/ultramookie-patch-1
Adding new self-hosted instance of Phanpy
2024-04-12 17:41:55 +08:00
steve mookie kong f7c69e56e9
Adding new self-hosted instance of Phanpy
Added new self-hosted instance of Phanpy, halo.mookiesplace.com
2024-04-11 21:28:38 -07:00
Lim Chee Aun c3bcf3d595 Try make Safari show video preview 2024-04-11 18:24:51 +08:00
Lim Chee Aun 0efa39b825 Sometimes it returns a preview image without dimenions 2024-04-11 17:45:19 +08:00
Lim Chee Aun a0d2037007 Early implementation of media-first UI experience 2024-04-11 17:18:17 +08:00
Lim Chee Aun 6e73728e2b Only show data-read-more when it's available 2024-04-11 17:16:04 +08:00
Lim Chee Aun 60920966d6 Special fallback handling when media object doesn't have enough info 2024-04-11 17:15:16 +08:00
Lim Chee Aun 5083463942 Show empty copy when no notifications at all 2024-04-11 17:13:34 +08:00
Lim Chee Aun 8b5fee3dfd Just sub it once 2024-04-10 17:31:26 +08:00
Lim Chee Aun c9124bf150 Change double-tap zoom to match mobile expectations 2024-04-10 15:03:02 +08:00
Lim Chee Aun b85174155c Make notifications settings icon less significant 2024-04-10 14:21:05 +08:00
Lim Chee Aun 5c9f6bae3c Fix followers list failing if familiar followers fail 2024-04-10 14:19:35 +08:00
Lim Chee Aun 4e5940900e Pixelfed-related fixes 2024-04-09 23:35:17 +08:00
Lim Chee Aun 7fa0b4f076 Upgrade dependencies 2024-04-05 17:13:29 +08:00
Lim Chee Aun ecfcc68f15 Add TheDesk 2024-04-05 17:13:13 +08:00
Lim Chee Aun 015ed5e7eb Further expand usage of SubMenu2 2024-04-04 17:03:30 +08:00
Lim Chee Aun 2ad9706304 Further utilize lazy shazam 2024-04-04 14:34:28 +08:00
Lim Chee Aun 30382d088b Possible fix for menus again 2024-04-04 14:34:04 +08:00
Lim Chee Aun 80196f83ca Revert "Test if this fixes submenu not opening"
This reverts commit 49fa48bd28.
2024-04-04 14:29:46 +08:00
Lim Chee Aun 419ad34250 Revert "Test another fix for submenus not opening"
This reverts commit a7cc0785f9.
2024-04-04 14:29:35 +08:00
Lim Chee Aun ed0d714cf2 Just a little spacing fix 2024-04-03 22:51:29 +08:00
Lim Chee Aun 708976a9e9 Anything Intl always need to extract out
and memoized
2024-04-03 19:48:18 +08:00
Lim Chee Aun d77ba19308 Handle another kind of emojiReaction response
Can't everyone just standardize the responses?
2024-04-03 17:58:37 +08:00
Lim Chee Aun b10e22a9a2 Better fallbacks 2024-04-03 17:57:15 +08:00
Lim Chee Aun 36d8b62e1e Height adjustments when switching between poll form and results 2024-04-03 16:14:59 +08:00
Lim Chee Aun 989e788d8e Slight delay is needed 2024-04-03 16:06:37 +08:00
Lim Chee Aun ebd9f05f69 Preload IntlSegmenter polyfill if needed 2024-04-03 14:33:53 +08:00
Lim Chee Aun 5246af4ae9 Undo lazy component experiment
Doesn't make much difference
2024-04-03 14:33:19 +08:00
Lim Chee Aun e6ba72f4c8 'Remove follower' menu item 2024-04-03 11:54:46 +08:00
Lim Chee Aun 960dff8b9e Make lazy shazam ignore top sticky header 2024-04-03 11:53:03 +08:00
Lim Chee Aun e3c25d25ee Add menus to view profile image and header 2024-04-03 09:29:23 +08:00
Lim Chee Aun 090320150a Select text too when pressing / 2024-04-03 09:28:59 +08:00
Lim Chee Aun 7100937e79 Higher gif picker sheet 2024-04-02 19:44:22 +08:00
Lim Chee Aun c18efef7b6 GIF picker 2024-04-02 17:51:48 +08:00
Lim Chee Aun ff336628f8 Fix media description not recognized if programmatically entered 2024-04-02 17:45:14 +08:00
Lim Chee Aun 28882d98d9 Add different UI state than default for start 2024-04-02 17:42:51 +08:00
Lim Chee Aun f6ad22e58f Fix bug: media attachments not updated when edited 2024-04-02 13:12:52 +08:00
Lim Chee Aun aa664e15f6 Convert all the punycodes
Surprising that this is still not built into browsers
2024-04-02 09:03:13 +08:00
Chee Aun f2f203c9d8
Merge pull request #478 from snail-coupe/doc/fix_build_example
Update README.md
2024-04-02 07:49:11 +08:00
snail-coupe ae0e4a0792
Update README.md
Build examples: PHANPY_APP_TITLE -> PHANPY_CLIENT_NAME
2024-04-01 23:26:15 +01:00
Lim Chee Aun 4def6eef5a Refactor this out for no particular reason 2024-03-31 20:53:08 +08:00
Lim Chee Aun 1004a5f176 Revert back to 88px 2024-03-31 20:47:43 +08:00
Lim Chee Aun 2b6beee875 More logic to prevent recursive/wrong quote posts 2024-03-31 20:35:24 +08:00
Lim Chee Aun e35e02593a If beyond 12 hours, allow last catch up's end timing 2024-03-31 20:34:01 +08:00
Lim Chee Aun 5e56ba9fb9 Bring back auto-updating relative time
This time, more optimized re-render
2024-03-30 17:21:31 +08:00
Lim Chee Aun a7cc0785f9 Test another fix for submenus not opening 2024-03-30 14:44:48 +08:00
Lim Chee Aun bb5d34c94c Still need to request relationship for moved accounts
Instead hide specific elements if moved.
2024-03-29 21:27:46 +08:00
Lim Chee Aun 671d2c9bb1 Less wider submenu 2024-03-28 18:22:29 +08:00
Lim Chee Aun 49fa48bd28 Test if this fixes submenu not opening 2024-03-28 18:22:03 +08:00
Lim Chee Aun 32fb406629 Better shift, but not dynamic 2024-03-28 12:18:25 +08:00
Lim Chee Aun 6950698935 Color space works differently in different browsers 2024-03-28 12:13:38 +08:00
Lim Chee Aun fd9d8059bc Handle info with menu dropdown for profile page 2024-03-28 00:25:10 +08:00
Lim Chee Aun 3b975e899b Try use smaller dimension for fine pointers 2024-03-28 00:23:31 +08:00
Lim Chee Aun b1950046d4 Better alignment for poll radios/checkboxes 2024-03-27 22:08:56 +08:00
Lim Chee Aun d2af509eaf Hacky way to show on-screen keyboard
Doesn't work some of the time.
2024-03-27 21:22:47 +08:00
Lim Chee Aun 311160983f Experiment hide some visibility icons 2024-03-27 19:09:01 +08:00
Lim Chee Aun 9d7d5df7f2 Fix sudden Chrome CSS bug with text-shadow affecting underlines 2024-03-27 16:17:09 +08:00
Lim Chee Aun 927430853a Fix CW-ed images from QPs not cloaked 2024-03-27 16:03:15 +08:00
Lim Chee Aun 1692637e22 Possibly fix weird race conditions
No idea how this happen at all
2024-03-27 14:58:32 +08:00
Lim Chee Aun 2bc24cc495 Pass in postID for Boosted/Liked sheet here too 2024-03-27 10:19:01 +08:00
Lim Chee Aun 66e58c74ef Shazam the filtered notifications 2024-03-27 10:18:34 +08:00
Lim Chee Aun e3591514a1 Use acct instead of username 2024-03-27 10:18:12 +08:00
Lim Chee Aun 4abb1aeaed Fix poll got false value 2024-03-27 09:46:37 +08:00
Lim Chee Aun 7cac17a043 Need Loader fallbacks 2024-03-27 08:09:24 +08:00
Lim Chee Aun 7049166b40 Finally facing the consequences of hacky code
By fixing it with more hacky code
2024-03-26 23:45:22 +08:00
Lim Chee Aun 0a695410d9 Cloak the buttons in filtered notifications 2024-03-26 23:44:18 +08:00
Lim Chee Aun d671178c02 Update copies for severed relationships
Ref: https://github.com/mastodon/mastodon/pull/29731
2024-03-26 19:47:03 +08:00
Lim Chee Aun 67a05450cf Test this lazy shazam 2024-03-26 16:35:02 +08:00
Lim Chee Aun 438b520970 Fix sudden weird underline bug 2024-03-26 13:49:14 +08:00
Lim Chee Aun c8c96f08ac Another attempt to conditional load Intl.Segmenter polyfill 2024-03-25 19:31:25 +08:00
Lim Chee Aun c9bbca9e11 Might as well go further into custom emoji reactions
But still MVP-ish. Misskey emoji shortcodes ain't going to work tho'
2024-03-25 17:58:56 +08:00
Lim Chee Aun 39800e771c Add Mangane 2024-03-25 12:06:03 +08:00
Lim Chee Aun b1c81f7d71 Preliminary support for emoji reaction notifications
Note: pleroma:emoji_reaction is not tested.
2024-03-25 12:05:49 +08:00
Lim Chee Aun 53e9aac14f Show chevron to hint dropdown 2024-03-25 10:26:37 +08:00
Lim Chee Aun cc268019a0 Upgrade dependencies 2024-03-25 10:13:42 +08:00
Lim Chee Aun 9d16c6c12a Fix policy change not working for push notifications
1. Turns out `policy` needs to be inside `data` hash
2. namedItem(policy) → namedItem('policy')

Super embarrassed that these bugs exist for 7 months since push notifications release.
2024-03-25 09:20:51 +08:00
Lim Chee Aun 27a7bc7627 Edit profile now includes extra fields 2024-03-24 23:39:45 +08:00
Lim Chee Aun 1a2914362f Very, very simple Edit Profile sheet. 2024-03-24 20:49:02 +08:00
Lim Chee Aun 9c8aff6d32 Show post preview inside Boosted/Liked by modal
And show the menu in more places
2024-03-24 17:24:47 +08:00
Lim Chee Aun 6816a4b64a Port the tooltip stuff to other link cards 2024-03-24 16:53:33 +08:00
Lim Chee Aun 13f5621488 Fix char counter not showing properly on Firefox 2024-03-24 16:37:58 +08:00
Lim Chee Aun fd59a39021 Preliminary support for severed relationships notifications
Reference: https://github.com/mastodon/mastodon/pull/27511

This is done purely based on the above codebase without real testing.
2024-03-24 14:13:58 +08:00
Lim Chee Aun c19096ab1b Try no split CSS 2024-03-24 10:13:51 +08:00
Lim Chee Aun 0fbc566454 Fix this somehow-partially implemented dot shortcut 2024-03-24 00:21:41 +08:00
Lim Chee Aun f6a9f7807e Allow Lists to be in Shortcuts (except columns)
…and all various Lists-related improvements
2024-03-23 23:52:05 +08:00
Lim Chee Aun 8378d6fc1d Upgrade dependencies 2024-03-23 15:00:13 +08:00
Lim Chee Aun 5ccf8b6842 Show published dates for cards 2024-03-23 12:26:50 +08:00
Lim Chee Aun d6b65d0413 Better red color for danger menus 2024-03-23 12:26:22 +08:00
Lim Chee Aun 8eb67f469c Add Enable/Disable notifications/boosts for accounts 2024-03-23 12:26:01 +08:00
Lim Chee Aun 717633e422 Filters, finally. 2024-03-23 01:07:24 +08:00
Lim Chee Aun f6c2097a89 Fix beyond to date range formatting 2024-03-22 09:33:32 +08:00
Lim Chee Aun 5695b3ca1e Fix alignment issues with the checkboxes 2024-03-21 08:59:07 +08:00
Lim Chee Aun 15c113ecb1 Reduce brightness
iOS seems to HDR-ify it and it's so annoyingly brighter
2024-03-20 14:30:07 +08:00
Lim Chee Aun 4a75d6f172 Fix flex issues 2024-03-20 11:18:56 +08:00
Lim Chee Aun 8f43099840 More conditional menu dividers
Srsly need better way to render these dividers
2024-03-20 11:04:38 +08:00
Lim Chee Aun a2743f9940 This got prettier-ed 2024-03-20 11:04:38 +08:00
Lim Chee Aun 4c2210c68b MVP-ish filtered notifications UI 2024-03-20 11:04:38 +08:00
Lim Chee Aun da909e4084 Fix wrong filtered counts due to grouped boosts 2024-03-20 11:04:38 +08:00
Lim Chee Aun 552ad249e5 Clean up the usernames 2024-03-20 11:04:38 +08:00
Chee Aun 9a5704ee95
Merge pull request #464 from snail-coupe/phanpy-crmbl-uk
Update README.md - adding another instance
2024-03-18 09:02:03 +08:00
snail-coupe c7f68c8971
Update README.md - adding another instance 2024-03-17 21:31:26 +00:00
Lim Chee Aun e8219e458d Try this font settings out.
Depends on system font's capabilities, so may not work.
2024-03-16 20:02:20 +08:00
Lim Chee Aun 6157ee105c Fix "hide"-filtered post bug again 2024-03-16 18:45:59 +08:00
Lim Chee Aun 4718ef36b0 Need one more detail: site version 2024-03-16 17:49:41 +08:00
Lim Chee Aun 2723ef4593 Attempt to fix wrong boosts count 2024-03-16 13:36:23 +08:00
Chee Aun d1965a84b5
Merge pull request #461 from Vinnl/ellipsis-tooltip
Add tooltip for truncated preview text
2024-03-16 13:33:28 +08:00
Lim Chee Aun c7762cc56f Upgrade dependencies 2024-03-16 10:12:34 +08:00
Vincent cf05568e0c
Add tooltip for truncated preview text
Expose the full content of preview text that might get truncated in
their tooltips.
2024-03-15 18:06:56 +01:00
Lim Chee Aun 69c47489e3 Fix some at-mentions not handled 2024-03-15 18:20:45 +08:00
Lim Chee Aun 861ad83423 More keyboard shortcuts for Catch-up 2024-03-15 18:06:52 +08:00
Lim Chee Aun cd3ed64e48 Show relative time if boosting/quoting old post 2024-03-15 16:02:33 +08:00
Lim Chee Aun 2e28c147b9 Scope the keyboard shortcuts in Catch-up 2024-03-15 09:05:05 +08:00
Lim Chee Aun fef033b282 Show relative time if replying to old post
Ref: https://blog.joinmastodon.org/2023/11/improving-the-quality-of-conversations-on-mastodon/
2024-03-13 13:30:58 +08:00
Lim Chee Aun 3dbbba0be2 Fix captioning turned on even when showCaption = false 2024-03-12 08:14:07 +08:00
Lim Chee Aun 0b8cbbef51 Consider the safe areas 2024-03-11 19:04:08 +08:00
Lim Chee Aun f72ec0aba5 Scroll up too if changing author 2024-03-11 12:21:15 +08:00
Lim Chee Aun d63e6c87c4 Potential perf improvements for canvas 2024-03-10 23:25:07 +08:00
Lim Chee Aun f5ea96a093 Merge dup boosts in Catch-up 2024-03-10 23:24:17 +08:00
Lim Chee Aun 0e1be5dbdc MVP-ish initial implementation of Quote
The menuExtras is hacky, I know.
2024-03-09 21:29:44 +08:00
Lim Chee Aun 4843970e1b Custom context menu if link has hash 2024-03-09 17:01:50 +08:00
Lim Chee Aun a0367f4860 Basic j/k/o/enter shortcuts for Notifications page 2024-03-08 16:25:23 +08:00
Lim Chee Aun 687a08b2a4 Forgot to add 'k' lol
Might as well add 'h' and 'l', & fix the selected author focusing issue
2024-03-08 14:53:38 +08:00
Lim Chee Aun ac07479edd Fix wrong account shown for multiple same-username links 2024-03-08 14:52:31 +08:00
Lim Chee Aun 306a96eec3 Need uppercase C,else it'll be true instead of false
🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️🤦‍♂️
2024-03-07 16:33:56 +08:00
Lim Chee Aun 061d769901 Test fix race-condition for new notifications 2024-03-07 16:06:08 +08:00
Lim Chee Aun cf1c10b338 Show text from poll too 2024-03-07 12:34:38 +08:00
Lim Chee Aun 7f6ef4ff96 Better copy for embed post 2024-03-07 09:05:52 +08:00
Lim Chee Aun ce190cbc50 Lock icon for locked profiles 2024-03-07 09:05:40 +08:00
Lim Chee Aun e7e4f15234 Need extra check on domain 2024-03-06 22:01:13 +08:00
129 changed files with 11978 additions and 4398 deletions

View file

@ -10,6 +10,7 @@ assignees: ''
**Describe the bug**
A clear and concise description of what the bug is.
- Which site: [e.g. dev.phanpy.social OR phanpy.social]
- Which site version: [On Phanpy, go to Settings -> About]
- Which instance: [e.g. mastodon.social]
**To Reproduce**

4
.gitignore vendored
View file

@ -27,3 +27,7 @@ dist-ssr
.env.dev
phanpy-dist.zip
phanpy-dist.tar.gz
# Nix
.direnv
result

View file

@ -3,17 +3,20 @@
"useTabs": false,
"singleQuote": true,
"trailingComma": "all",
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": [
"^[^.].*.css$",
"index.css$",
".css$",
"",
"./polyfills",
"",
"<THIRD_PARTY_MODULES>",
"",
"/assets/",
"",
"^../",
"",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderGroupNamespaceSpecifiers": true,
"importOrderCaseInsensitive": true
]
}

View file

@ -43,7 +43,7 @@ Everything is designed and engineered following my taste and vision. This is a p
- **Status actions (reply, boost, favourite, bookmark, etc) are hidden by default**.<br>They only appear in individual status page. This is to reduce clutter and distraction. It may result in lower engagement, but we're not chasing numbers here.
- **Boost is represented with the rocket icon**.<br>The green double arrow icon (retweet for Twitter) doesn't look right for the term "boost". Green rocket looks weird, so I use purple.
- **Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.
- ~~**Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.<br>Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet.~~ DTTHDon fork displays the full username by default.
- **No autoplay for video/GIF/whatever in timeline**.<br>The timeline is already a huge mess with lots of people, brands, news and media trying to grab your attention. Let's not make it worse. (Current exception now would be animated emojis.)
- **Hash-based URLs**.<br>This web app is not meant to be a full-fledged replacement to Mastodon's existing front-end. There's no SEO, database, serverless or any long-running servers. I could be wrong one day.
@ -138,7 +138,7 @@ Download or `git clone` this repository. Use `production` branch for *stable* re
Customization can be done by passing environment variables to the build command. Examples:
```bash
PHANPY_APP_TITLE="Phanpy Dev" \
PHANPY_CLIENT_NAME="Phanpy Dev" \
PHANPY_WEBSITE="https://dev.phanpy.social" \
npm run build
```
@ -179,6 +179,13 @@ Available variables:
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)
- List of fallback instances hard-coded in `/.env`
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances)
- `PHANPY_IMG_ALT_API_URL` (optional, no defaults):
- API endpoint for self-hosted instance of [img-alt-api](https://github.com/cheeaun/img-alt-api).
- If provided, a setting will appear for users to enable the image description generator in the composer. Disabled by default.
- `PHANPY_GIPHY_API_KEY` (optional, no defaults):
- API key for [GIPHY](https://developers.giphy.com/). See [API docs](https://developers.giphy.com/docs/api/).
- If provided, a setting will appear for users to enable the GIF picker in the composer. Disabled by default.
- This is not self-hosted.
### Static site hosting
@ -192,7 +199,7 @@ See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva
These are self-hosted by other wonderful folks.
- [ferengi.one](https://m.ferengi.one/) by [@david@collantes.social](https://collantes.social/@david)
- [ferengi.one](https://m.ferengi.one/) by [@david@weaknotes.com](https://weaknotes.com/@david)
- [phanpy.blaede.family](https://phanpy.blaede.family/) by [@cassidy@blaede.family](https://mastodon.blaede.family/@cassidy)
- [phanpy.mstdn.mx](https://phanpy.mstdn.mx/) by [@maop@mstdn.mx](https://mstdn.mx/@maop)
- [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan)
@ -200,6 +207,11 @@ These are self-hosted by other wonderful folks.
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
- [phanpy.fulda.social](https://phanpy.fulda.social) by [@Ganneff@fulda.social](https://fulda.social/@Ganneff)
- [phanpy.crmbl.uk](https://phanpy.crmbl.uk) by [@snail@crmbl.uk](https://mstdn.crmbl.uk/@snail)
- [halo.mookiesplace.com](https://halo.mookiesplace.com) by [@mookie@mookiesplace.com](https://mookiesplace.com/@mookie)
- [social.qrk.one](https://social.qrk.one) by [@kev@fosstodon.org](https://fosstodon.org/@kev)
- [phanpy.cz](https://phanpy.cz) by [@zdendys@mamutovo.cz](https://mamutovo.cz/@zdendys)
- [phanpy.social.tchncs.de](https://phanpy.social.tchncs.de) by [@milan@social.tchncs.de](https://social.tchncs.de/@milan)
> Note: Add yours by creating a pull request.
@ -235,6 +247,8 @@ And here I am. Building a Mastodon web client.
## Alternative web clients
- Phanpy forks ↓
- [Agora](https://agorasocial.app/)
- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) - forks ↓
- [Semaphore](https://semaphore.social/)
- [Enafore](https://enafore.social/)
@ -250,6 +264,8 @@ And here I am. Building a Mastodon web client.
- [Statuzer](https://statuzer.com/)
- [Tusked](https://tusked.app/)
- [Mastodon Glitch Edition (standalone frontend)](https://iceshrimp.dev/iceshrimp/masto-fe-standalone)
- [Mangane](https://github.com/BDX-town/Mangane)
- [TheDesk](https://github.com/cutls/TheDesk)
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
## 💁‍♂️ Notice to all other social media client developers

61
flake.lock Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1723175592,
"narHash": "sha256-M0xJ3FbDUc4fRZ84dPGx5VvgFsOzds77KiBMW/mMTnI=",
"owner": "nixOS",
"repo": "nixpkgs",
"rev": "5e0ca22929f3342b19569b21b2f3462f053e497b",
"type": "github"
},
"original": {
"owner": "nixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

60
flake.nix Normal file
View file

@ -0,0 +1,60 @@
{
inputs.nixpkgs.url = github:nixOS/nixpkgs/nixos-unstable;
inputs.flake-utils.url = github:numtide/flake-utils;
outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
esbuild = pkgs.buildGoModule rec {
pname = "esbuild";
version = "0.21.5";
src = pkgs.fetchFromGitHub {
owner = "evanw";
repo = "esbuild";
rev = "v${version}";
hash = "sha256-FpvXWIlt67G8w3pBKZo/mcp57LunxDmRUaCU/Ne89B8=";
};
vendorHash = "sha256-+BfxCyg0KkDQpHt/wycy/8CTG6YBA/VJvJFhhzUnSiQ=";
subPackages = [ "cmd/esbuild" ];
ldflags = [ "-s" "-w" ];
meta.mainProgram = "esbuild";
};
in
rec {
packages.default = pkgs.buildNpmPackage {
pname = "dtth-phanpy";
version = "0.1.0";
nativeBuildInputs = with pkgs; [ git ];
ESBUILD_BINARY_PATH = lib.getExe esbuild;
src = lib.cleanSource ./.;
npmFlags = [ "--legacy-peer-deps" ];
npmDepsHash = "sha256-VROK9Emxi+jFqwidA/CUxQwxitKf7Y6mx0yuOCUwrzI=";
# npmDepsHash = lib.fakeHash;
# DTTH-specific env variables
PHANPY_CLIENT_NAME = "DTTH Phanpy";
PHANPY_CLIENT_ID = "ch.dtth.phanpy";
PHANPY_WEBSITE = "https://social.dtth.ch";
installPhase = ''
runHook preInstall
mkdir -p $out/lib
cp -r dist $out/lib/phanpy
runHook postInstall
'';
};
devShells.default = pkgs.mkShell {
inputsFrom = [ packages.default ];
buildInputs = with pkgs; [ nodejs ];
};
});
}

4699
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,60 +7,63 @@
"build": "vite build",
"preview": "vite preview",
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
"sourcemap": "npx source-map-explorer dist/assets/*.js"
"sourcemap": "npx source-map-explorer dist/assets/*.js",
"bundle-visualizer": "npx vite-bundle-visualizer"
},
"dependencies": {
"@formatjs/intl-localematcher": "~0.5.4",
"@formatjs/intl-segmenter": "~11.5.5",
"@formkit/auto-animate": "~0.8.1",
"@github/text-expander-element": "~2.6.1",
"@formatjs/intl-segmenter": "~11.5.7",
"@formkit/auto-animate": "~0.8.2",
"@github/text-expander-element": "~2.7.1",
"@iconify-icons/mingcute": "~1.2.9",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.1.0",
"@uidotdev/usehooks": "~2.4.1",
"compare-versions": "~6.1.0",
"dayjs": "~1.11.10",
"@szhsin/react-menu": "~4.2.1",
"compare-versions": "~6.1.1",
"dayjs": "~1.11.12",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-blurhash": "~1.1.4",
"fast-equals": "~5.0.1",
"html-prettify": "^1.0.7",
"fuse.js": "~7.0.0",
"html-prettify": "~1.0.7",
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.6.4",
"masto": "~6.8.0",
"moize": "~6.1.6",
"p-retry": "~6.2.0",
"p-throttle": "~6.1.0",
"preact": "~10.19.6",
"preact": "~10.23.1",
"punycode": "~2.3.1",
"react-hotkeys-hook": "~4.5.0",
"react-intersection-observer": "~9.8.1",
"react-intersection-observer": "~9.13.0",
"react-quick-pinch-zoom": "~5.1.0",
"react-router-dom": "6.6.2",
"string-length": "6.0.0",
"swiped-events": "~1.1.9",
"swiped-events": "~1.2.0",
"tinyld": "~1.3.4",
"toastify-js": "~1.12.0",
"uid": "~2.0.2",
"use-debounce": "~10.0.0",
"use-debounce": "~10.0.2",
"use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0",
"valtio": "1.13.2"
},
"devDependencies": {
"@preact/preset-vite": "~2.8.1",
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
"postcss": "~8.4.35",
"postcss-dark-theme-class": "~1.2.1",
"postcss-preset-env": "~9.4.0",
"@ianvs/prettier-plugin-sort-imports": "~4.3.1",
"@preact/preset-vite": "~2.9.0",
"postcss": "~8.4.40",
"postcss-dark-theme-class": "~1.3.0",
"postcss-preset-env": "~10.0.0",
"twitter-text": "~3.1.0",
"vite": "~5.1.5",
"vite-plugin-generate-file": "~0.1.1",
"vite": "~5.3.5",
"vite-plugin-generate-file": "~0.2.0",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.19.2",
"vite-plugin-pwa": "~0.20.1",
"vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",
"workbox-routing": "~7.0.0",
"workbox-strategies": "~7.0.0"
"workbox-cacheable-response": "~7.1.0",
"workbox-expiration": "~7.1.0",
"workbox-routing": "~7.1.0",
"workbox-strategies": "~7.1.0"
},
"postcss": {
"plugins": {

View file

@ -62,7 +62,7 @@ const iconsRoute = new Route(
cacheName: 'icons',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxEntries: 300,
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
purgeOnQuotaError: true,
}),
@ -96,6 +96,28 @@ const apiExtendedRoute = new RegExpRoute(
);
registerRoute(apiExtendedRoute);
// Note: expiration is not working as expected
// https://github.com/GoogleChrome/workbox/issues/3316
//
// const apiIntermediateRoute = new RegExpRoute(
// // Matches:
// // - trends/*
// // - timelines/link
// /^https?:\/\/[^\/]+\/api\/v\d+\/(trends|timelines\/link)/,
// new StaleWhileRevalidate({
// cacheName: 'api-intermediate',
// plugins: [
// new ExpirationPlugin({
// maxAgeSeconds: 1 * 60, // 1min
// }),
// new CacheableResponsePlugin({
// statuses: [0, 200],
// }),
// ],
// }),
// );
// registerRoute(apiIntermediateRoute);
const apiRoute = new RegExpRoute(
// Matches:
// - statuses/:id/context - some contexts are really huge

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
import './app.css';
import debounce from 'just-debounce-it';
import { lazy, Suspense } from 'preact/compat';
import {
useEffect,
useLayoutEffect,
@ -10,7 +9,9 @@ import {
useState,
} from 'preact/hooks';
import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
import 'swiped-events';
import { subscribe } from 'valtio';
import BackgroundService from './components/background-service';
@ -18,15 +19,16 @@ import ComposeButton from './components/compose-button';
import { ICONS } from './components/ICONS';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader';
// import Modals from './components/modals';
import Modals from './components/modals';
import NotificationService from './components/notification-service';
import SearchCommand from './components/search-command';
import Shortcuts from './components/shortcuts';
import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses';
import Bookmarks from './pages/bookmarks';
// import Catchup from './pages/catchup';
import Catchup from './pages/catchup';
import Favourites from './pages/favourites';
import Filters from './pages/filters';
import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following';
import Hashtag from './pages/hashtag';
@ -53,11 +55,9 @@ import { getAccessToken } from './utils/auth';
import focusDeck from './utils/focus-deck';
import states, { initStates, statusKey } from './utils/states';
import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils';
import './utils/toast-alert';
import { getCurrentAccount, setCurrentAccountID } from './utils/store-utils';
const Catchup = lazy(() => import('./pages/catchup'));
const Modals = lazy(() => import('./components/modals'));
import './utils/toast-alert';
window.__STATES__ = states;
window.__STATES_STATS__ = () => {
@ -129,13 +129,15 @@ setInterval(() => {
// Related: https://github.com/vitejs/vite/issues/10600
setTimeout(() => {
for (const icon in ICONS) {
queueMicrotask(() => {
setTimeout(() => {
if (Array.isArray(ICONS[icon])) {
ICONS[icon][0]?.();
} else if (typeof ICONS[icon] === 'object') {
ICONS[icon].module?.();
} else {
ICONS[icon]?.();
}
});
}, 1);
}
}, 5000);
@ -328,11 +330,11 @@ function App() {
const client = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([
initPreferences(client),
initInstance(client, instanceURL),
initAccount(client, instanceURL, accessToken, vapidKey),
]);
initStates();
initPreferences(client);
setIsLoggedIn(true);
setUIState('default');
@ -341,15 +343,15 @@ function App() {
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
const account = getCurrentAccount();
if (account) {
store.session.set('currentAccount', account.info.id);
setCurrentAccountID(account.info.id);
const { client } = api({ account });
const { instance } = client;
// console.log('masto', masto);
initStates();
initPreferences(client);
setUIState('loading');
(async () => {
try {
await initPreferences(client);
await initInstance(client, instance);
} catch (e) {
} finally {
@ -386,9 +388,7 @@ function App() {
)}
{isLoggedIn && <ComposeButton />}
{isLoggedIn && <Shortcuts />}
<Suspense>
<Modals />
</Suspense>
<Modals />
{isLoggedIn && <NotificationService />}
<BackgroundService isLoggedIn={isLoggedIn} />
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
@ -463,15 +463,9 @@ function SecondaryRoutes({ isLoggedIn }) {
<Route index element={<Lists />} />
<Route path=":id" element={<List />} />
</Route>
<Route path="/ft" element={<FollowedHashtags />} />
<Route
path="/catchup"
element={
<Suspense>
<Catchup />
</Suspense>
}
/>
<Route path="/fh" element={<FollowedHashtags />} />
<Route path="/ft" element={<Filters />} />
<Route path="/catchup" element={<Catchup />} />
</>
)}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 641 223">
<path fill="#aaa" d="M86 214c-9-1-17-4-24-8l-6-3-5-5-5-4-4-6-4-6-3-8-2-8v-27l2-9 3-9 4-6 4-6 5-5 5-5 7-3 6-4 7-2 7-2 12-1h12l7 1 8 2 7 4 7 3 5 5 5 4-10 10-10 9-4-3-10-5-5-1H88l-5 2-6 3-3 4-4 4-2 5-2 6v6l-1 7 1 7 2 7 3 5 2 4 4 3 4 3 5 2 6 2h9l10-1 5-2 6-3v-16H91v-27h59v54l-1 3-2 3-5 4-4 4-5 3-5 2-8 2-8 2-10 1H92l-6-1zm266-62V91h34v46h44V91h34v121h-34v-46h-44v46h-34v-61zm-182-1V90h34v121h-34v-60zm59-1V90h35l36 1 5 2c3 0 8 2 10 4l5 2 4 5 5 4 3 7 3 7 1 13v13l-4 6-3 7-4 4-5 5-5 2-5 3-6 2-5 1-18 1h-18v32h-34v-61zm67-2 3-2 2-4 2-5v-5l-2-4-2-4-3-2-3-3h-30v31h30l3-2zm226 39v-24l-8-12-18-28a1751 1751 0 0 0-20-31v-2h39l7 12 12 21 6 9 13-21 13-21h38v2l-41 61-7 10v48h-34v-24zM109 66l-4-1-5-5-5-4-1-5-3-9v-5l1-5c2-7 3-10 8-15l4-4 7-2 7-2h7l6 1 5 2 5 2 3 4 4 3 2 6 2 5v13l-2 5-2 6-4 4-3 3-5 2-4 2-9 1h-9l-5-2zm22-11 4-2 3-4 2-5V34l-2-4-2-4-3-2-4-3-5-1h-6l-4 2-5 2-2 4-3 5-1 3v4l1 5 2 5 2 2 5 3 4 2h10l4-2zM37 39V11h33l3 1 3 2 4 3 3 3 1 5 1 4v5l-1 4-3 4-3 5-4 1-3 2-11 1H49v16H37V39zm31 0 3-2 1-2 1-2v-4l-1-3-3-2-2-2H49v18h15l4-1zm107 25a512 512 0 0 0-19-53h14l4 14 6 19 1 4 1-1 7-19 5-17h9l6 19 7 18v-1l2-6 5-17 4-13h14v1l-4 12-16 41v2h-5l-5-1-6-15-6-15-1 1-3 7-6 15-2 8h-11l-1-3zm74-25V11h42v11h-29v2l-1 5v4h29v11h-28v11h2l15 1h13v11h-43V39zm55 0V11h33l5 3 5 2 2 4 2 5v10l-2 3-1 4-5 3-5 3 5 5 8 10 3 4h-14l-7-9-8-10h-9v19h-12V39zm33-3 2-3v-6l-3-3-2-3h-18v16h1v1h17l2-2zm26 3V11h42v11h-29l-1 6v5h29v11h-28v5l-1 5 1 1v1h30v11h-43V39zm54 0V11h17l18 1 4 2 5 3 2 4 3 4 2 6 1 6v5c-1 6-3 12-6 15l-3 4-5 3-5 2-17 1h-16V39zm33 14 5-5 2-3v-6l-1-6-1-3-1-3-4-3-3-2h-5l-6-1-3 1h-3v34h9l8-1 3-2zm50-14V11h34l5 2 4 2 2 3 2 3v9l-2 2-3 4-1 1 3 3 3 4 1 3 1 4-1 4-1 4-3 3-3 3-5 1-5 1h-31V39zm34 15 2-1v-6l-2-2-2-2h-20v13h20l2-2zm-3-22 4-2v-6l-2-1-2-2h-19v12h16l4-1zm42 24V45l-6-9-11-17-5-8h15l4 8 7 11 2 3 7-11 7-11h14l-11 16-11 17v23h-12V56z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -9,13 +9,17 @@ body.cloak,
.status .content-container,
.status .content-container *,
.status .content-compact > *,
.account-container .actions small,
.account-container :is(header, main > *:not(.actions)),
.account-container :is(header, main > *:not(.actions)) *,
.header-double-lines,
.header-double-lines *,
.account-block,
.catchup-filters .filter-author *,
.post-peek-html *,
.post-peek-content > * {
.post-peek-content > *,
.request-notifications-account *,
.status.compact-thread *,
.status .content-compact {
text-decoration-thickness: 1.1em;
text-decoration-line: line-through;
/* text-rendering: optimizeSpeed; */
@ -49,9 +53,19 @@ body.cloak,
body.cloak,
.cloak {
.header-double-lines *,
.account-container .profile-metadata b,
.account-container .actions small,
.account-container .stats *,
.media-container figcaption,
.media-container figcaption > *,
.catchup-filters .filter-author * {
.catchup-filters .filter-author *,
.request-notifications-account * {
color: var(--text-color) !important;
}
.account-container .actions small,
.status .content-compact {
background-color: currentColor !important;
}
}

View file

@ -6,8 +6,14 @@ export const ICONS = {
'x-circle': () => import('@iconify-icons/mingcute/close-circle-line'),
transfer: () => import('@iconify-icons/mingcute/transfer-4-line'),
rocket: () => import('@iconify-icons/mingcute/rocket-line'),
'arrow-left': () => import('@iconify-icons/mingcute/arrow-left-line'),
'arrow-right': () => import('@iconify-icons/mingcute/arrow-right-line'),
'arrow-left': {
module: () => import('@iconify-icons/mingcute/arrow-left-line'),
rtl: true,
},
'arrow-right': {
module: () => import('@iconify-icons/mingcute/arrow-right-line'),
rtl: true,
},
'arrow-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
earth: () => import('@iconify-icons/mingcute/earth-line'),
@ -16,8 +22,14 @@ export const ICONS = {
'eye-close': () => import('@iconify-icons/mingcute/eye-close-line'),
'eye-open': () => import('@iconify-icons/mingcute/eye-2-line'),
message: () => import('@iconify-icons/mingcute/mail-line'),
comment: () => import('@iconify-icons/mingcute/chat-3-line'),
comment2: () => import('@iconify-icons/mingcute/comment-2-line'),
comment: {
module: () => import('@iconify-icons/mingcute/chat-3-line'),
rtl: true,
},
comment2: {
module: () => import('@iconify-icons/mingcute/comment-2-line'),
rtl: true,
},
home: () => import('@iconify-icons/mingcute/home-3-line'),
notification: () => import('@iconify-icons/mingcute/notification-line'),
follow: () => import('@iconify-icons/mingcute/user-follow-line'),
@ -31,23 +43,46 @@ export const ICONS = {
gear: () => import('@iconify-icons/mingcute/settings-3-line'),
more: () => import('@iconify-icons/mingcute/more-3-line'),
more2: () => import('@iconify-icons/mingcute/more-1-fill'),
external: () => import('@iconify-icons/mingcute/external-link-line'),
popout: () => import('@iconify-icons/mingcute/external-link-line'),
popin: [() => import('@iconify-icons/mingcute/external-link-line'), '180deg'],
external: {
module: () => import('@iconify-icons/mingcute/external-link-line'),
rtl: true,
},
popout: {
module: () => import('@iconify-icons/mingcute/external-link-line'),
rtl: true,
},
popin: {
module: () => import('@iconify-icons/mingcute/external-link-line'),
rotate: '180deg',
rtl: true,
},
plus: () => import('@iconify-icons/mingcute/add-circle-line'),
'chevron-left': () => import('@iconify-icons/mingcute/left-line'),
'chevron-right': () => import('@iconify-icons/mingcute/right-line'),
'chevron-left': {
module: () => import('@iconify-icons/mingcute/left-line'),
rtl: true,
},
'chevron-right': {
module: () => import('@iconify-icons/mingcute/right-line'),
rtl: true,
},
'chevron-down': () => import('@iconify-icons/mingcute/down-line'),
reply: [
() => import('@iconify-icons/mingcute/share-forward-line'),
'180deg',
'horizontal',
],
reply: {
module: () => import('@iconify-icons/mingcute/share-forward-line'),
rotate: '180deg',
flip: 'horizontal',
rtl: true,
},
thread: () => import('@iconify-icons/mingcute/route-line'),
group: () => import('@iconify-icons/mingcute/group-line'),
group: {
module: () => import('@iconify-icons/mingcute/group-line'),
rtl: true,
},
bot: () => import('@iconify-icons/mingcute/android-2-line'),
menu: () => import('@iconify-icons/mingcute/rows-4-line'),
list: () => import('@iconify-icons/mingcute/list-check-line'),
list: {
module: () => import('@iconify-icons/mingcute/list-check-line'),
rtl: true,
},
search: () => import('@iconify-icons/mingcute/search-2-line'),
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
info: () => import('@iconify-icons/mingcute/information-line'),
@ -62,12 +97,21 @@ export const ICONS = {
share: () => import('@iconify-icons/mingcute/share-2-line'),
sparkles: () => import('@iconify-icons/mingcute/sparkles-line'),
sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'),
exit: () => import('@iconify-icons/mingcute/exit-line'),
exit: {
module: () => import('@iconify-icons/mingcute/exit-line'),
rtl: true,
},
translate: () => import('@iconify-icons/mingcute/translate-line'),
play: () => import('@iconify-icons/mingcute/play-fill'),
trash: () => import('@iconify-icons/mingcute/delete-2-line'),
mute: () => import('@iconify-icons/mingcute/volume-mute-line'),
unmute: () => import('@iconify-icons/mingcute/volume-line'),
mute: {
module: () => import('@iconify-icons/mingcute/volume-mute-line'),
rtl: true,
},
unmute: {
module: () => import('@iconify-icons/mingcute/volume-line'),
rtl: true,
},
block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
unblock: [
() => import('@iconify-icons/mingcute/forbid-circle-line'),
@ -78,29 +122,57 @@ export const ICONS = {
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
filters: () => import('@iconify-icons/mingcute/filter-line'),
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
react: () => import('@iconify-icons/mingcute/react-line'),
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
layout4: {
module: () => import('@iconify-icons/mingcute/layout-4-line'),
rtl: true,
},
layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
announce: () => import('@iconify-icons/mingcute/announcement-line'),
announce: {
module: () => import('@iconify-icons/mingcute/announcement-line'),
rtl: true,
},
alert: () => import('@iconify-icons/mingcute/alert-line'),
round: () => import('@iconify-icons/mingcute/round-fill'),
'arrow-up-circle': () =>
import('@iconify-icons/mingcute/arrow-up-circle-line'),
'arrow-down-circle': () =>
import('@iconify-icons/mingcute/arrow-down-circle-line'),
clipboard: () => import('@iconify-icons/mingcute/clipboard-line'),
clipboard: {
module: () => import('@iconify-icons/mingcute/clipboard-line'),
rtl: true,
},
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
month: {
module: () => import('@iconify-icons/mingcute/calendar-month-line'),
rtl: true,
},
media: () => import('@iconify-icons/mingcute/photo-album-line'),
speak: () => import('@iconify-icons/mingcute/radar-line'),
building: () => import('@iconify-icons/mingcute/building-5-line'),
history2: () => import('@iconify-icons/mingcute/history-2-line'),
history2: {
module: () => import('@iconify-icons/mingcute/history-2-line'),
rtl: true,
},
document: () => import('@iconify-icons/mingcute/document-line'),
'arrows-right': () => import('@iconify-icons/mingcute/arrows-right-line'),
'arrows-right': {
module: () => import('@iconify-icons/mingcute/arrows-right-line'),
rtl: true,
},
code: () => import('@iconify-icons/mingcute/code-line'),
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
quote: {
module: () => import('@iconify-icons/mingcute/quote-left-line'),
rtl: true,
},
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
'user-x': () => import('@iconify-icons/mingcute/user-x-line'),
'user-setting': () => import('@iconify-icons/mingcute/user-setting-line'),
minimize: () => import('@iconify-icons/mingcute/arrows-down-line'),
};

View file

@ -29,6 +29,8 @@
line-clamp: 1;
text-overflow: ellipsis;
overflow: hidden;
unicode-bidi: isolate;
direction: initial;
}
a {

View file

@ -33,7 +33,7 @@ function AccountBlock({
<span>
<b></b>
<br />
<span class="account-block-acct">@</span>
<span class="account-block-acct"></span>
</span>
</div>
);
@ -62,6 +62,7 @@ function AccountBlock({
group,
followersCount,
createdAt,
locked,
} = account;
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (accountInstance) {
@ -86,7 +87,7 @@ function AccountBlock({
class="account-block"
href={url}
target={external ? '_blank' : null}
title={`@${acct}`}
title={acct2 ? acct : `@${acct}`}
onClick={(e) => {
if (external) return;
e.preventDefault();
@ -119,27 +120,31 @@ function AccountBlock({
)}
</>
)}{' '}
<span class="account-block-acct">
@{acct1}
<span class="account-block-acct bidi-isolate">
{acct2 ? '' : '@'}
{acct1}
<wbr />
{acct2}
{locked && (
<>
{' '}
<Icon icon="lock" size="s" alt="Locked" />
</>
)}
</span>
{showActivity && (
<>
<br />
<small class="last-status-at insignificant">
Posts: {statusesCount}
{!!lastStatusAt && (
<>
{' '}
&middot; Last posted:{' '}
{niceDateTime(lastStatusAt, {
hideTime: true,
})}
</>
)}
</small>
</>
<div class="account-block-stats">
Posts: {shortenNumber(statusesCount)}
{!!lastStatusAt && (
<>
{' '}
&middot; Last posted:{' '}
{niceDateTime(lastStatusAt, {
hideTime: true,
})}
</>
)}
</div>
)}
{showStats && (
<div class="account-block-stats">

View file

@ -57,7 +57,7 @@
background-repeat: no-repeat;
animation: swoosh-bg-image 0.3s ease-in-out 0.3s both;
background-image: linear-gradient(
to right,
var(--to-forward),
var(--original-color) 0%,
var(--original-color) calc(var(--originals-percentage) - var(--gap)),
var(--gap-color) calc(var(--originals-percentage) - var(--gap)),
@ -181,8 +181,8 @@
opacity: 1;
}
.sheet .account-container .header-banner {
border-top-left-radius: 16px;
border-top-right-radius: 16px;
border-start-start-radius: 16px;
border-start-end-radius: 16px;
}
.account-container .header-banner.header-is-avatar {
mask-image: linear-gradient(
@ -288,10 +288,17 @@
align-self: center !important;
/* clip a dog ear on top right */
clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%);
&:dir(rtl) {
/* top left */
clip-path: polygon(4px 0, 100% 0, 100% 100%, 0 100%, 0 4px);
}
/* 4x4px square on top right */
background-size: 4px 4px;
background-repeat: no-repeat;
background-position: top right;
&:dir(rtl) {
background-position: top left;
}
background-image: linear-gradient(
to bottom,
var(--private-note-border-color),
@ -311,7 +318,7 @@
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
text-align: left;
text-align: start;
}
&:hover:not(:active) {
@ -370,7 +377,8 @@
animation: appear 1s both ease-in-out;
> *:not(:first-child) {
margin: 0 0 0 -4px;
margin: 0;
margin-inline-start: -4px;
}
}
}
@ -422,15 +430,15 @@
}
&:has(+ .account-metadata-box) {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-end-start-radius: 4px;
border-end-end-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;
border-start-start-radius: 4px;
border-start-end-radius: 4px;
border-end-start-radius: 16px;
border-end-end-radius: 16px;
}
}
@ -781,3 +789,108 @@
}
}
}
#edit-profile-container {
p {
margin-block: 8px;
}
label {
input,
textarea {
display: block;
width: 100%;
}
textarea {
resize: vertical;
min-height: 5em;
max-height: 50vh;
}
}
table {
width: 100%;
th {
text-align: start;
color: var(--text-insignificant-color);
font-weight: normal;
font-size: 0.8em;
text-transform: uppercase;
}
tbody tr td:first-child {
width: 40%;
}
input {
width: 100%;
}
}
footer {
display: flex;
justify-content: space-between;
padding: 8px 0;
* {
vertical-align: middle;
}
}
}
.handle-info {
.handle-handle {
display: inline-block;
margin-block: 5px;
b {
font-weight: 600;
padding: 2px 4px;
border-radius: 4px;
display: inline-block;
box-shadow: 0 0 0 5px var(--bg-blur-color);
&.handle-username {
color: var(--orange-fg-color);
background-color: var(--orange-bg-color);
}
&.handle-server {
color: var(--purple-fg-color);
background-color: var(--purple-bg-color);
}
}
}
.handle-at {
display: inline-block;
margin-inline: -3px;
position: relative;
z-index: 1;
}
.handle-legend {
margin-top: 0.25em;
}
.handle-legend-icon {
overflow: hidden;
display: inline-block;
width: 14px;
height: 14px;
border: 4px solid transparent;
border-radius: 8px;
background-clip: padding-box;
&.username {
background-color: var(--orange-fg-color);
border-color: var(--orange-bg-color);
}
&.server {
background-color: var(--purple-fg-color);
border-color: var(--purple-bg-color);
}
}
}

View file

@ -1,6 +1,6 @@
import './account-info.css';
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
import {
useCallback,
useEffect,
@ -9,18 +9,22 @@ import {
useRef,
useState,
} from 'preact/hooks';
import punycode from 'punycode/';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import { getLists } from '../utils/lists';
import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number';
import showCompose from '../utils/show-compose';
import showToast from '../utils/show-toast';
import states, { hideAllModals } from '../utils/states';
import store from '../utils/store';
import { updateAccount } from '../utils/store-utils';
import { getCurrentAccountID, updateAccount } from '../utils/store-utils';
import supports from '../utils/supports';
import AccountBlock from './account-block';
import Avatar from './avatar';
@ -29,9 +33,11 @@ import Icon from './icon';
import Link from './link';
import ListAddEdit from './list-add-edit';
import Loader from './loader';
import Menu2 from './menu2';
import MenuConfirm from './menu-confirm';
import MenuLink from './menu-link';
import Menu2 from './menu2';
import Modal from './modal';
import SubMenu2 from './submenu2';
import TranslationBlock from './translation-block';
const MUTE_DURATIONS = [
@ -181,6 +187,7 @@ function AccountInfo({
memorial,
moved,
roles,
hideCollections,
} = info || {};
let headerIsAvatar = false;
let { header, headerStatic } = info || {};
@ -194,10 +201,7 @@ function AccountInfo({
}
}
const isSelf = useMemo(
() => id === store.session.get('currentAccount'),
[id],
);
const isSelf = useMemo(() => id === getCurrentAccountID(), [id]);
useEffect(() => {
const infoHasEssentials = !!(
@ -227,7 +231,7 @@ function AccountInfo({
const accountInstance = useMemo(() => {
if (!url) return null;
const domain = new URL(url).hostname;
const domain = punycode.toUnicode(URL.parse(url).hostname);
return domain;
}, [url]);
@ -250,12 +254,13 @@ function AccountInfo({
// 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.familiarFollowers.fetch(
{
let familiarFollowers = [];
try {
familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch({
id: [id],
},
);
familiarFollowersCache.current = familiarFollowers[0].accounts;
});
} catch (e) {}
familiarFollowersCache.current = familiarFollowers?.[0]?.accounts || [];
newValue = [
...familiarFollowersCache.current,
...value.filter(
@ -340,6 +345,17 @@ function AccountInfo({
[standalone, id, statusesCount],
);
const onProfileUpdate = useCallback(
(newAccount) => {
if (newAccount.id === id) {
console.log('Updated account info', newAccount);
setInfo(newAccount);
states.accounts[`${newAccount.id}@${instance}`] = newAccount;
}
},
[id, instance],
);
return (
<div
tabIndex="-1"
@ -453,12 +469,15 @@ function AccountInfo({
e.target.classList.add('loaded');
try {
// Get color from four corners of image
const canvas = document.createElement('canvas');
const canvas = window.OffscreenCanvas
? new OffscreenCanvas(1, 1)
: document.createElement('canvas');
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
});
canvas.width = e.target.width;
canvas.height = e.target.height;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(e.target, 0, 0);
// const colors = [
// ctx.getImageData(0, 0, 1, 1).data,
@ -526,13 +545,66 @@ function AccountInfo({
/>
)}
<header>
<AccountBlock
account={info}
instance={instance}
avatarSize="xxxl"
external={standalone}
internal={!standalone}
/>
{standalone ? (
<Menu2
shift={
window.matchMedia('(min-width: calc(40em))').matches
? 114
: 64
}
menuButton={
<div>
<AccountBlock
account={info}
instance={instance}
avatarSize="xxxl"
onClick={() => {}}
/>
</div>
}
>
<div class="szh-menu__header">
<AccountHandleInfo acct={acct} instance={instance} />
</div>
<MenuItem
onClick={() => {
const handleWithInstance = acct.includes('@')
? `@${acct}`
: `@${acct}@${instance}`;
try {
navigator.clipboard.writeText(handleWithInstance);
showToast('Handle copied');
} catch (e) {
console.error(e);
showToast('Unable to copy handle');
}
}}
>
<Icon icon="link" />
<span>Copy handle</span>
</MenuItem>
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<span>Go to original profile page</span>
</MenuItem>
<MenuDivider />
<MenuLink href={info.avatar} target="_blank">
<Icon icon="user" />
<span>View profile image</span>
</MenuLink>
<MenuLink href={info.header} target="_blank">
<Icon icon="media" />
<span>View profile header</span>
</MenuLink>
</Menu2>
) : (
<AccountBlock
account={info}
instance={instance}
avatarSize="xxxl"
internal
/>
)}
</header>
<div class="faux-header-bg" aria-hidden="true" />
<main>
@ -602,12 +674,16 @@ function AccountInfo({
// states.showAccount = false;
setTimeout(() => {
states.showGenericAccounts = {
id: 'followers',
heading: 'Followers',
fetchAccounts: fetchFollowers,
instance,
excludeRelationshipAttrs: isSelf
? ['followedBy']
: [],
blankCopy: hideCollections
? 'This user has chosen to not make this information available.'
: undefined,
};
}, 0);
}}
@ -643,6 +719,9 @@ function AccountInfo({
fetchAccounts: fetchFollowing,
instance,
excludeRelationshipAttrs: isSelf ? ['following'] : [],
blankCopy: hideCollections
? 'This user has chosen to not make this information available.'
: undefined,
};
}, 0);
}}
@ -752,45 +831,49 @@ function AccountInfo({
</div>
</LinkOrDiv>
)}
<div class="account-metadata-box">
<div
class="shazam-container no-animation"
hidden={!!postingStats}
>
<div class="shazam-container-inner">
<button
type="button"
class="posting-stats-button"
disabled={postingStatsUIState === 'loading'}
onClick={() => {
renderPostingStats();
}}
>
<div
class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
{!moved && (
<div class="account-metadata-box">
<div
class="shazam-container no-animation"
hidden={!!postingStats}
>
<div class="shazam-container-inner">
<button
type="button"
class="posting-stats-button"
disabled={postingStatsUIState === 'loading'}
onClick={() => {
renderPostingStats();
}}
/>
View post stats{' '}
{/* <Loader
>
<div
class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
}}
/>
View post stats{' '}
{/* <Loader
abrupt
hidden={postingStatsUIState !== 'loading'}
/> */}
</button>
</button>
</div>
</div>
</div>
</div>
)}
</main>
<footer>
<RelatedActions
info={info}
instance={instance}
standalone={standalone}
authenticated={authenticated}
onRelationshipChange={onRelationshipChange}
onProfileUpdate={onProfileUpdate}
/>
</footer>
</>
@ -805,8 +888,10 @@ const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({
info,
instance,
standalone,
authenticated,
onRelationshipChange = () => {},
onProfileUpdate = () => {},
}) {
if (!info) return null;
const {
@ -841,9 +926,11 @@ function RelatedActions({
const [currentInfo, setCurrentInfo] = useState(null);
const [isSelf, setIsSelf] = useState(false);
const acctWithInstance = acct.includes('@') ? acct : `${acct}@${instance}`;
useEffect(() => {
if (info) {
const currentAccount = store.session.get('currentAccount');
const currentAccount = getCurrentAccountID();
let currentID;
(async () => {
if (sameInstance && authenticated) {
@ -878,7 +965,7 @@ function RelatedActions({
accountID.current = currentID;
if (moved) return;
// if (moved) return;
setRelationshipUIState('loading');
@ -917,6 +1004,7 @@ function RelatedActions({
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
const [showEditProfile, setShowEditProfile] = useState(false);
const [lists, setLists] = useState([]);
return (
@ -998,11 +1086,11 @@ function RelatedActions({
<>
<MenuItem
onClick={() => {
states.showCompose = {
showCompose({
draftStatus: {
status: `@${currentInfo?.acct || acct} `,
},
};
});
}}
>
<Icon icon="at" />
@ -1016,16 +1104,82 @@ function RelatedActions({
<Icon icon="translate" />
<span>Translate bio</span>
</MenuItem>
<MenuItem
onClick={() => {
setShowPrivateNoteModal(true);
}}
>
<Icon icon="pencil" />
<span>
{privateNote ? 'Edit private note' : 'Add private note'}
</span>
</MenuItem>
{supports('@mastodon/profile-private-note') && (
<MenuItem
onClick={() => {
setShowPrivateNoteModal(true);
}}
>
<Icon icon="pencil" />
<span>
{privateNote ? 'Edit private note' : 'Add private note'}
</span>
</MenuItem>
)}
{following && !!relationship && (
<>
<MenuItem
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const rel = await currentMasto.v1.accounts
.$select(accountID.current)
.follow({
notify: !notifying,
});
if (rel) setRelationship(rel);
setRelationshipUIState('default');
showToast(
rel.notifying
? `Notifications enabled for @${username}'s posts.`
: ` Notifications disabled for @${username}'s posts.`,
);
} catch (e) {
alert(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="notification" />
<span>
{notifying
? 'Disable notifications'
: 'Enable notifications'}
</span>
</MenuItem>
<MenuItem
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const rel = await currentMasto.v1.accounts
.$select(accountID.current)
.follow({
reblogs: !showingReblogs,
});
if (rel) setRelationship(rel);
setRelationshipUIState('default');
showToast(
rel.showingReblogs
? `Boosts from @${username} enabled.`
: `Boosts from @${username} disabled.`,
);
} catch (e) {
alert(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="rocket" />
<span>
{showingReblogs ? 'Disable boosts' : 'Enable boosts'}
</span>
</MenuItem>
</>
)}
{/* Add/remove from lists is only possible if following the account */}
{following && (
<MenuItem
@ -1055,7 +1209,7 @@ function RelatedActions({
)}
<MenuItem
onClick={() => {
const handle = `@${currentInfo?.acct || acct}`;
const handle = `@${currentInfo?.acct || acctWithInstance}`;
try {
navigator.clipboard.writeText(handle);
showToast('Handle copied');
@ -1069,8 +1223,8 @@ function RelatedActions({
<small>
Copy handle
<br />
<span class="more-insignificant">
@{currentInfo?.acct || acct}
<span class="more-insignificant bidi-isolate">
@{currentInfo?.acct || acctWithInstance}
</span>
</small>
</MenuItem>
@ -1144,7 +1298,7 @@ function RelatedActions({
<span>Unmute @{username}</span>
</MenuItem>
) : (
<SubMenu
<SubMenu2
menuClassName="menu-blur"
openTrigger="clickOnly"
direction="bottom"
@ -1198,7 +1352,44 @@ function RelatedActions({
</MenuItem>
))}
</div>
</SubMenu>
</SubMenu2>
)}
{followedBy && (
<MenuConfirm
subMenu
menuItemClassName="danger"
confirmLabel={
<>
<Icon icon="user-x" />
<span>Remove @{username} from followers?</span>
</>
}
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.removeFromFollowers();
console.log(
'removing from followers',
newRelationship,
);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`@${username} removed from followers`);
states.reloadGenericAccounts.id = 'followers';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
}
})();
}}
>
<Icon icon="user-x" />
<span>Remove follower</span>
</MenuConfirm>
)}
<MenuConfirm
subMenu
@ -1273,6 +1464,22 @@ function RelatedActions({
</MenuItem>
</>
)}
{currentAuthenticated &&
isSelf &&
standalone &&
supports('@mastodon/profile-edit') && (
<>
<MenuDivider />
<MenuItem
onClick={() => {
setShowEditProfile(true);
}}
>
<Icon icon="pencil" />
<span>Edit profile</span>
</MenuItem>
</>
)}
{import.meta.env.DEV && currentAuthenticated && isSelf && (
<>
<MenuDivider />
@ -1298,7 +1505,7 @@ function RelatedActions({
{!relationship && relationshipUIState === 'loading' && (
<Loader abrupt />
)}
{!!relationship && (
{!!relationship && !moved && (
<MenuConfirm
confirm={following || requested}
confirmLabel={
@ -1414,6 +1621,22 @@ function RelatedActions({
/>
</Modal>
)}
{!!showEditProfile && (
<Modal
onClose={() => {
setShowEditProfile(false);
}}
>
<EditProfileSheet
onClose={({ state, account } = {}) => {
setShowEditProfile(false);
if (state === 'success' && account) {
onProfileUpdate(account);
}
}}
/>
</Modal>
)}
</>
);
}
@ -1436,12 +1659,12 @@ function lightenRGB([r, g, b]) {
function niceAccountURL(url) {
if (!url) return;
const urlObj = new URL(url);
const urlObj = URL.parse(url);
const { host, pathname } = urlObj;
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
return (
<>
<span class="more-insignificant">{host}/</span>
<span class="more-insignificant">{punycode.toUnicode(host)}/</span>
<wbr />
<span>{path}</span>
</>
@ -1491,13 +1714,12 @@ function AddRemoveListsSheet({ accountID, onClose }) {
setUIState('loading');
(async () => {
try {
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
const lists = await getLists();
setLists(lists);
const listsContainingAccount = await masto.v1.accounts
.$select(accountID)
.lists.list();
console.log({ lists, listsContainingAccount });
setLists(lists);
setListsContainingAccount(listsContainingAccount);
setUIState('default');
} catch (e) {
@ -1675,6 +1897,7 @@ function PrivateNoteSheet({
ref={textareaRef}
name="note"
disabled={uiState === 'loading'}
dir="auto"
>
{initialNote}
</textarea>
@ -1702,4 +1925,217 @@ function PrivateNoteSheet({
);
}
function EditProfileSheet({ onClose = () => {} }) {
const { masto } = api();
const [uiState, setUIState] = useState('loading');
const [account, setAccount] = useState(null);
useEffect(() => {
(async () => {
try {
const acc = await masto.v1.accounts.verifyCredentials();
setAccount(acc);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, []);
console.log('EditProfileSheet', account);
const { displayName, source } = account || {};
const { note, fields } = source || {};
const fieldsAttributesRef = useRef(null);
return (
<div class="sheet" id="edit-profile-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<b>Edit profile</b>
</header>
<main>
{uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const displayName = formData.get('display_name');
const note = formData.get('note');
const fieldsAttributesFields =
fieldsAttributesRef.current.querySelectorAll(
'input[name^="fields_attributes"]',
);
const fieldsAttributes = [];
fieldsAttributesFields.forEach((field) => {
const name = field.name;
const [_, index, key] =
name.match(/fields_attributes\[(\d+)\]\[(.+)\]/) || [];
const value = field.value ? field.value.trim() : '';
if (index && key && value) {
if (!fieldsAttributes[index]) fieldsAttributes[index] = {};
fieldsAttributes[index][key] = value;
}
});
// Fill in the blanks
fieldsAttributes.forEach((field) => {
if (field.name && !field.value) {
field.value = '';
}
});
(async () => {
try {
const newAccount = await masto.v1.accounts.updateCredentials({
displayName,
note,
fieldsAttributes,
});
console.log('updated account', newAccount);
onClose?.({
state: 'success',
account: newAccount,
});
} catch (e) {
console.error(e);
alert(e?.message || 'Unable to update profile.');
}
})();
}}
>
<p>
<label>
Name{' '}
<input
type="text"
name="display_name"
defaultValue={displayName}
maxLength={30}
disabled={uiState === 'loading'}
dir="auto"
/>
</label>
</p>
<p>
<label>
Bio
<textarea
defaultValue={note}
name="note"
maxLength={500}
rows="5"
disabled={uiState === 'loading'}
dir="auto"
/>
</label>
</p>
{/* Table for fields; name and values are in fields, min 4 rows */}
<p>Extra fields</p>
<table ref={fieldsAttributesRef}>
<thead>
<tr>
<th>Label</th>
<th>Content</th>
</tr>
</thead>
<tbody>
{Array.from({ length: Math.max(4, fields.length) }).map(
(_, i) => {
const { name = '', value = '' } = fields[i] || {};
return (
<FieldsAttributesRow
key={i}
name={name}
value={value}
index={i}
disabled={uiState === 'loading'}
/>
);
},
)}
</tbody>
</table>
<footer>
<button
type="button"
class="light"
disabled={uiState === 'loading'}
onClick={() => {
onClose?.();
}}
>
Cancel
</button>
<button type="submit" disabled={uiState === 'loading'}>
Save
</button>
</footer>
</form>
)}
</main>
</div>
);
}
function FieldsAttributesRow({ name, value, disabled, index: i }) {
const [hasValue, setHasValue] = useState(!!value);
return (
<tr>
<td>
<input
type="text"
name={`fields_attributes[${i}][name]`}
defaultValue={name}
disabled={disabled}
maxLength={255}
required={hasValue}
dir="auto"
/>
</td>
<td>
<input
type="text"
name={`fields_attributes[${i}][value]`}
defaultValue={value}
disabled={disabled}
maxLength={255}
onChange={(e) => setHasValue(!!e.currentTarget.value)}
dir="auto"
/>
</td>
</tr>
);
}
function AccountHandleInfo({ acct, instance }) {
// acct = username or username@server
let [username, server] = acct.split('@');
if (!server) server = instance;
return (
<div class="handle-info">
<span class="handle-handle">
<b class="handle-username">{username}</b>
<span class="handle-at">@</span>
<b class="handle-server">{server}</b>
</span>
<div class="handle-legend">
<span class="ib">
<span class="handle-legend-icon username" /> username
</span>{' '}
<span class="ib">
<span class="handle-legend-icon server" /> server domain name
</span>
</div>
</div>
);
}
export default AccountInfo;

View file

@ -58,7 +58,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
if (result.accounts.length) {
return result.accounts[0];
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = new URL(account);
const accountURL = URL.parse(account);
const { hostname, pathname } = accountURL;
const acct =
pathname.replace(/^\//, '').replace(/\/$/, '') +

View file

@ -21,6 +21,7 @@ const canvas = window.OffscreenCanvas
const ctx = canvas.getContext('2d', {
willReadFrequently: true,
});
ctx.imageSmoothingEnabled = false;
function Avatar({ url, size, alt = '', squircle, ...props }) {
size = SIZES[size] || size || SIZES.m;
@ -62,7 +63,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
if (avatarRef.current) avatarRef.current.dataset.loaded = true;
if (alphaCache[url] !== undefined) return;
if (isMissing) return;
queueMicrotask(() => {
setTimeout(() => {
try {
// Check if image has alpha channel
const { width, height } = e.target;
@ -87,7 +88,7 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
// Silent fail
alphaCache[url] = false;
}
});
}, 1);
}}
/>
)}

View file

@ -9,7 +9,7 @@ import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility';
const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds
const POLL_INTERVAL = 15_000; // 15 seconds
const POLL_INTERVAL = 20_000; // 20 seconds
export default memo(function BackgroundService({ isLoggedIn }) {
// Notifications service
@ -46,6 +46,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
useEffect(() => {
let sub;
let streamTimeout;
let pollNotifications;
if (isLoggedIn && visible) {
const { masto, streaming, instance } = api();
@ -56,7 +57,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
let hasStreaming = false;
// 2. Start streaming
if (streaming) {
pollNotifications = setTimeout(() => {
streamTimeout = setTimeout(() => {
(async () => {
try {
hasStreaming = true;
@ -94,7 +95,7 @@ export default memo(function BackgroundService({ isLoggedIn }) {
return () => {
sub?.unsubscribe?.();
sub = null;
clearTimeout(pollNotifications);
clearTimeout(streamTimeout);
clearInterval(pollNotifications);
};
}, [visible, isLoggedIn]);

View file

@ -39,6 +39,8 @@ function Columns() {
if (!Component) return null;
// Don't show Search column with no query, for now
if (type === 'search' && !params.query) return null;
// Don't show List column with no list, for now
if (type === 'list' && !params.id) return null;
return (
<Component key={type + JSON.stringify(params)} {...params} columnMode />
);

View file

@ -1,12 +1,22 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio';
import openCompose from '../utils/open-compose';
import openOSK from '../utils/open-osk';
import states from '../utils/states';
import Icon from './icon';
export default function ComposeButton() {
const snapStates = useSnapshot(states);
function handleButton(e) {
if (snapStates.composerState.minimized) {
states.composerState.minimized = false;
openOSK();
return;
}
if (e.shiftKey) {
const newWin = openCompose();
@ -14,6 +24,7 @@ export default function ComposeButton() {
states.showCompose = true;
}
} else {
openOSK();
states.showCompose = true;
}
}
@ -26,7 +37,14 @@ export default function ComposeButton() {
});
return (
<button type="button" id="compose-button" onClick={handleButton}>
<button
type="button"
id="compose-button"
onClick={handleButton}
class={`${snapStates.composerState.minimized ? 'min' : ''} ${
snapStates.composerState.publishing ? 'loading' : ''
} ${snapStates.composerState.publishingError ? 'error' : ''}`}
>
<Icon icon="quill" size="xl" alt="Compose" />
</button>
);

View file

@ -0,0 +1,48 @@
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
import { useEffect, useState } from 'preact/hooks';
import Loader from './loader';
const supportsIntlSegmenter = !shouldPolyfill();
function importIntlSegmenter() {
if (!supportsIntlSegmenter) {
return import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
}
}
function importCompose() {
return import('./compose');
}
export async function preload() {
try {
await importIntlSegmenter();
importCompose();
} catch (e) {
console.error(e);
}
}
export default function ComposeSuspense(props) {
const [Compose, setCompose] = useState(null);
useEffect(() => {
(async () => {
try {
if (supportsIntlSegmenter) {
const component = await importCompose();
setCompose(component);
} else {
await importIntlSegmenter();
const component = await importCompose();
setCompose(component);
}
} catch (e) {
console.error(e);
}
})();
}, []);
return Compose?.default ? <Compose.default {...props} /> : <Loader />;
}

View file

@ -16,7 +16,6 @@
}
#compose-container .compose-top {
text-align: right;
display: flex;
justify-content: space-between;
gap: 8px;
@ -62,7 +61,7 @@
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
}
#compose-container .status-preview:has(.status-badge:not(:empty)) {
border-top-right-radius: 8px;
border-start-end-radius: 8px;
}
#compose-container .status-preview :is(.content-container, .time) {
pointer-events: none;
@ -95,6 +94,10 @@
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color);
z-index: 2;
strong {
color: var(--red-color);
}
}
#_compose-container .status-preview-legend.reply-to {
color: var(--reply-to-color);
@ -107,8 +110,8 @@
}
#compose-container form {
--form-padding-inline: 12px;
--form-padding-block: 8px;
--form-padding-inline: 8px;
--form-padding-block: 0;
/* border-radius: 16px; */
padding: var(--form-padding-block) var(--form-padding-inline);
background-color: var(--bg-blur-color);
@ -204,7 +207,7 @@
left: -100vw !important;
}
#compose-container .toolbar-button select {
background-color: transparent;
background-color: inherit;
border: 0;
padding: 0 0 0 8px;
margin: 0;
@ -212,8 +215,8 @@
line-height: 1em;
}
#compose-container .toolbar-button:not(.show-field) select {
right: 0;
left: auto !important;
inset-inline-end: 0;
inset-inline-start: auto !important;
}
#compose-container
.toolbar-button:not(:disabled):is(
@ -294,19 +297,28 @@
height: 2.2em;
}
#compose-container .text-expander-menu li:is(:hover, :focus, [aria-selected]) {
color: var(--bg-color);
background-color: var(--link-color);
}
#compose-container
.text-expander-menu:hover
li[aria-selected]:not(:hover, :focus) {
background-color: var(--link-bg-color);
color: var(--text-color);
background-color: var(--bg-color);
}
#compose-container .text-expander-menu li[aria-selected] {
box-shadow: inset 4px 0 0 0 var(--button-bg-color);
:dir(rtl) & {
box-shadow: inset -4px 0 0 0 var(--button-bg-color);
}
}
#compose-container .text-expander-menu li[data-more] {
&:not(:hover, :focus, [aria-selected]) {
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
}
font-size: 0.8em;
justify-content: center;
}
#compose-container .form-visibility-direct {
--yellow-stripes: repeating-linear-gradient(
-45deg,
135deg,
var(--reply-to-faded-color),
var(--reply-to-faded-color) 10px,
var(--reply-to-faded-color) 10px,
@ -330,6 +342,21 @@
display: flex;
gap: 8px;
align-items: stretch;
.media-error {
padding: 2px;
color: var(--orange-fg-color);
background-color: transparent;
border: 1.5px dashed transparent;
line-height: 1;
border-radius: 4px;
display: flex;
&:is(:hover, :focus) {
background-color: var(--bg-color);
border-color: var(--orange-fg-color);
}
}
}
#compose-container .media-preview {
flex-shrink: 0;
@ -469,14 +496,14 @@
display: flex;
gap: 4px;
align-items: center;
border-left: 1px solid var(--outline-color);
padding-left: 8px;
border-inline-start: 1px solid var(--outline-color);
padding-inline-start: 8px;
}
#compose-container .expires-in {
flex-grow: 1;
border-left: 1px solid var(--outline-color);
padding-left: 8px;
border-inline-start: 1px solid var(--outline-color);
padding-inline-start: 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
@ -496,8 +523,9 @@
}
}
@media (min-width: 480px) {
#compose-container button[type='submit'] {
#compose-container button[type='submit'] {
border-radius: 8px;
@media (min-width: 480px) {
padding-inline: 24px;
}
}
@ -590,44 +618,194 @@
} */
}
#mention-sheet {
height: 50vh;
.accounts-list {
--list-gap: 1px;
list-style: none;
margin: 0;
padding: 8px 0;
display: flex;
flex-direction: column;
row-gap: var(--list-gap);
&.loading {
opacity: 0.5;
}
li {
display: flex;
flex-grow: 1;
/* align-items: center; */
margin: 0 -8px;
padding: 8px;
gap: 8px;
position: relative;
justify-content: space-between;
border-radius: 8px;
/* align-items: center; */
&:hover {
background-image: linear-gradient(
var(--to-forward),
transparent 75%,
var(--link-bg-color)
);
}
&.selected {
background-image: linear-gradient(
var(--to-forward),
var(--bg-faded-color) 75%,
var(--link-bg-color)
);
}
&:before {
content: '';
display: block;
border-top: var(--hairline-width) solid var(--divider-color);
position: absolute;
bottom: 0;
inset-inline-start: 58px;
inset-inline-end: 0;
}
&:has(+ li:is(.selected, :hover)):before,
&:is(.selected, :hover):before {
opacity: 0;
}
> button {
border-radius: 4px;
&:hover {
outline: 2px solid var(--button-bg-blur-color);
}
}
}
}
}
#custom-emojis-sheet {
max-height: 50vh;
max-height: 50dvh;
}
#custom-emojis-sheet main {
mask-image: none;
}
#custom-emojis-sheet .custom-emojis-list .section-header {
font-size: 80%;
text-transform: uppercase;
color: var(--text-insignificant-color);
padding: 8px 0 4px;
position: sticky;
top: 0;
background-color: var(--bg-blur-color);
backdrop-filter: blur(1px);
}
#custom-emojis-sheet .custom-emojis-list section {
display: flex;
flex-wrap: wrap;
}
#custom-emojis-sheet .custom-emojis-list button {
border-radius: 8px;
background-image: radial-gradient(
closest-side,
var(--img-bg-color),
transparent
);
}
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) {
filter: none;
background-color: var(--bg-faded-color);
}
#custom-emojis-sheet .custom-emojis-list button img {
transition: transform 0.1s ease-out;
}
#custom-emojis-sheet .custom-emojis-list button:is(:hover, :focus) img {
transform: scale(1.5);
header {
.loader-container {
margin: 0;
}
form {
margin: 8px 0 0;
input {
width: 100%;
min-width: 0;
}
}
}
main {
mask-image: none;
min-height: 40vh;
padding-bottom: 88px;
}
.custom-emojis-matches {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
}
.custom-emojis-list {
.section-header {
font-size: 80%;
text-transform: uppercase;
color: var(--text-insignificant-color);
padding: 8px 0 4px;
position: sticky;
top: 0;
background-color: var(--bg-color);
z-index: 1;
}
section {
display: flex;
flex-wrap: wrap;
}
button {
color: var(--text-color);
border-radius: 8px;
background-image: radial-gradient(
closest-side,
var(--img-bg-color),
transparent
);
text-shadow: 0 1px 0 var(--bg-color);
position: relative;
min-width: 44px;
min-height: 44px;
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
&[data-title]:after {
max-width: 50vw;
pointer-events: none;
position: absolute;
content: attr(data-title);
left: 50%;
top: 0;
background-color: var(--bg-color);
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
border: 1px solid var(--text-color);
transform: translate(-50%, -110%);
opacity: 0;
transition: opacity 0.1s ease-out 0.1s;
font-family: var(--monospace-font);
line-height: 1;
}
&.edge-left[data-title]:after {
left: 0;
transform: translate(0, -110%);
}
&.edge-right[data-title]:after {
left: 100%;
transform: translate(-100%, -110%);
}
&:is(:hover, :focus) {
z-index: 1;
filter: none;
background-color: var(--bg-faded-color);
&[data-title]:after {
opacity: 1;
}
}
img {
transition: transform 0.1s ease-out;
}
&:is(:hover, :focus) img {
transform: scale(2);
}
&.edge-left img {
transform-origin: left center;
}
&.edge-right img {
transform-origin: right center;
}
code {
font-size: 0.8em;
}
}
}
}
.compose-field-container {
@ -723,3 +901,165 @@
}
}
}
@keyframes gif-shake {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(5deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0deg);
}
}
.gif-picker-button {
span {
font-weight: bold;
font-size: 11.5px;
display: block;
}
&:is(:hover, :focus) {
span {
animation: gif-shake 0.3s 3;
}
}
}
#gif-picker-sheet {
height: 50vh;
form {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
input[type='search'] {
flex-grow: 1;
min-width: 0;
}
}
main {
overflow-x: auto;
overflow-y: hidden;
mask-image: linear-gradient(
var(--to-forward),
transparent 2px,
black 16px,
black calc(100% - 16px),
transparent calc(100% - 2px)
);
@media (min-height: 480px) {
overflow-y: auto;
max-height: 50vh;
}
&.loading {
opacity: 0.25;
}
.ui-state {
min-height: 100px;
}
ul {
min-height: 100px;
display: flex;
gap: 4px;
list-style: none;
padding: 8px 2px;
margin: 0;
@media (min-height: 480px) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-auto-rows: 1fr;
}
li {
list-style: none;
padding: 0;
margin: 0;
max-width: 100%;
display: flex;
button {
padding: 4px;
margin: 0;
border: none;
background-color: transparent;
color: inherit;
cursor: pointer;
border-radius: 8px;
background-color: var(--bg-faded-color);
@media (min-height: 480px) {
width: 100%;
text-align: center;
}
&:is(:hover, :focus) {
background-color: var(--link-bg-color);
box-shadow: 0 0 0 2px var(--link-light-color);
filter: none;
}
}
figure {
margin: 0;
padding: 0;
width: var(--figure-width);
max-width: 100%;
@media (min-height: 480px) {
width: 100%;
text-align: center;
}
figcaption {
font-size: 0.8em;
padding: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-insignificant-color);
}
}
img {
background-color: var(--img-bg-color);
border-radius: 4px;
vertical-align: top;
object-fit: contain;
}
}
}
.pagination {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 0;
margin: 0;
position: sticky;
bottom: 0;
left: 0;
right: 0;
@media (min-height: 480px) {
position: static;
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
export default function CustomEmoji({ staticUrl, alt, url }) {
return (
<picture>
{staticUrl && (
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
)}
<img
key={alt || url}
src={url}
alt={alt}
class="shortcode-emoji emoji"
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
);
}

View file

@ -27,7 +27,7 @@ button.draft-item {
background-color: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--link-faded-color);
text-align: left;
text-align: start;
padding: 0;
}
button.draft-item:is(:hover, :focus) {

View file

@ -1,5 +1,7 @@
import { memo } from 'preact/compat';
import CustomEmoji from './custom-emoji';
function EmojiText({ text, emojis }) {
if (!text) return '';
if (!emojis?.length) return text;
@ -12,21 +14,7 @@ function EmojiText({ text, emojis }) {
const emoji = emojis.find((e) => e.shortcode === word);
if (emoji) {
const { url, staticUrl } = emoji;
return (
<picture>
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
<img
key={word}
src={url}
alt={word}
class="shortcode-emoji emoji"
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>
);
return <CustomEmoji staticUrl={staticUrl} alt={word} url={url} />;
}
return word;
});

View file

@ -1,4 +1,39 @@
#generic-accounts-container {
.post-preview {
--max-height: 120px;
max-height: var(--max-height);
overflow: hidden;
margin-block: 8px;
border: 1px solid var(--outline-color);
border-radius: 8px;
pointer-events: none;
.status {
font-size: calc(var(--text-size) * 0.9);
mask-image: linear-gradient(
to bottom,
black calc(var(--max-height) / 2),
transparent calc(var(--max-height) - 8px)
);
filter: saturate(0.5);
}
&:is(a) {
pointer-events: auto;
display: block;
text-decoration: none;
color: inherit;
&:hover {
border-color: var(--outline-hover-color);
}
> * {
pointer-events: none;
}
}
}
.accounts-list {
--list-gap: 16px;
list-style: none;
@ -27,13 +62,13 @@
border-top: var(--hairline-width) solid var(--divider-color);
position: absolute;
bottom: calc(-1 * var(--list-gap) / 2);
left: 40px;
right: 0;
inset-inline-start: 40px;
inset-inline-end: 0;
}
&:has(.reactions-block):before {
/* avatar + reactions + gap */
left: calc(40px + 16px + 8px);
inset-inline-start: calc(40px + 16px + 8px);
}
}

View file

@ -11,12 +11,16 @@ import useLocationChange from '../utils/useLocationChange';
import AccountBlock from './account-block';
import Icon from './icon';
import Link from './link';
import Loader from './loader';
import Status from './status';
export default function GenericAccounts({
instance,
excludeRelationshipAttrs = [],
postID,
onClose = () => {},
blankCopy = 'Nothing to show',
}) {
const { masto, instance: currentInstance } = api();
const isCurrentInstance = instance ? instance === currentInstance : true;
@ -129,6 +133,8 @@ export default function GenericAccounts({
}
}, [snapStates.reloadGenericAccounts.counter]);
const post = states.statuses[postID];
return (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
@ -138,6 +144,14 @@ export default function GenericAccounts({
<h2>{heading || 'Accounts'}</h2>
</header>
<main>
{post && (
<Link
to={`/${instance || currentInstance}/s/${post.id}`}
class="post-preview"
>
<Status status={post} size="s" readOnly />
</Link>
)}
{accounts.length > 0 ? (
<>
<ul class="accounts-list">
@ -208,7 +222,7 @@ export default function GenericAccounts({
) : uiState === 'error' ? (
<p class="ui-state">Error loading accounts</p>
) : (
<p class="ui-state insignificant">Nothing to show</p>
<p class="ui-state insignificant">{blankCopy}</p>
)}
</main>
</div>

View file

@ -53,9 +53,14 @@ function Icon({
return null;
}
let rotate, flip;
let rotate,
flip,
rtl = false;
if (Array.isArray(iconBlock)) {
[iconBlock, rotate, flip] = iconBlock;
} else if (typeof iconBlock === 'object') {
({ rotate, flip, rtl } = iconBlock);
iconBlock = iconBlock.module;
}
const [iconData, setIconData] = useState(ICONDATA[icon]);
@ -72,13 +77,14 @@ function Icon({
return (
<span
class={`icon ${className}`}
class={`icon ${className} ${rtl ? 'rtl-flip' : ''}`}
title={title || alt}
style={{
width: `${iconSize}px`,
height: `${iconSize}px`,
...style,
}}
data-icon={icon}
>
{iconData && (
// <svg

View file

@ -0,0 +1,29 @@
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
const IntersectionView = ({ children, root = null, fallback = null }) => {
const ref = useRef();
const [show, setShow] = useState(false);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
setShow(true);
observer.unobserve(ref.current);
}
},
{
root,
rootMargin: `${screen.height}px`,
},
);
if (ref.current) observer.observe(ref.current);
return () => {
if (ref.current) observer.unobserve(ref.current);
};
}, []);
return show ? children : <div ref={ref}>{fallback}</div>;
};
export default IntersectionView;

View file

@ -0,0 +1,36 @@
import { shouldPolyfill } from '@formatjs/intl-segmenter/should-polyfill';
import { Suspense } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import Loader from './loader';
const supportsIntlSegmenter = !shouldPolyfill();
// Preload IntlSegmenter
setTimeout(() => {
queueMicrotask(() => {
if (!supportsIntlSegmenter) {
import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
}
});
}, 1000);
export default function IntlSegmenterSuspense({ children }) {
if (supportsIntlSegmenter) {
return <Suspense fallback={<Loader />}>{children}</Suspense>;
}
const [polyfillLoaded, setPolyfillLoaded] = useState(false);
useEffect(() => {
(async () => {
await import('@formatjs/intl-segmenter/polyfill-force');
setPolyfillLoaded(true);
})();
}, []);
return polyfillLoaded ? (
<Suspense fallback={<Loader />}>{children}</Suspense>
) : (
<Loader />
);
}

View file

@ -0,0 +1,59 @@
/*
Rendered but hidden. Only show when visible
*/
import { useEffect, useRef, useState } from 'preact/hooks';
import { useInView } from 'react-intersection-observer';
// The sticky header, usually at the top
const TOP = 48;
const shazamIDs = {};
export default function LazyShazam({ id, children }) {
const containerRef = useRef();
const hasID = !!shazamIDs[id];
const [visible, setVisible] = useState(false);
const [visibleStart, setVisibleStart] = useState(hasID || false);
const { ref } = useInView({
root: null,
rootMargin: `-${TOP}px 0px 0px 0px`,
trackVisibility: true,
delay: 1000,
onChange: (inView) => {
if (inView) {
setVisible(true);
if (id) shazamIDs[id] = true;
}
},
triggerOnce: true,
skip: visibleStart || visible,
});
useEffect(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (rect.bottom > TOP) {
if (rect.top < window.innerHeight) {
setVisible(true);
} else {
setVisibleStart(true);
}
if (id) shazamIDs[id] = true;
}
}, []);
if (visibleStart) return children;
return (
<div
ref={containerRef}
class="shazam-container no-animation"
hidden={!visible}
>
<div ref={ref} class="shazam-container-inner">
{children}
</div>
</div>
);
}

View file

@ -22,15 +22,13 @@ const Link = forwardRef((props, ref) => {
// Handle encodeURIComponent of searchParams values
if (!!hash && hash !== '/' && hash.includes('?')) {
try {
const parsedHash = new URL(hash, location.origin); // Fake base URL
if (parsedHash.searchParams.size) {
const searchParamsStr = Array.from(parsedHash.searchParams.entries())
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
hash = parsedHash.pathname + '?' + searchParamsStr;
}
} catch (e) {}
const parsedHash = URL.parse(hash, location.origin); // Fake base URL
if (parsedHash?.searchParams?.size) {
const searchParamsStr = Array.from(parsedHash.searchParams.entries())
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
hash = parsedHash.pathname + '?' + searchParamsStr;
}
}
const isActive = hash === to || decodeURIComponent(hash) === to;

View file

@ -6,7 +6,7 @@
overflow-x: auto;
background-color: var(--bg-faded-color);
mask-image: linear-gradient(
to right,
var(--to-forward),
transparent,
black 16px,
black calc(100% - 16px),
@ -20,6 +20,9 @@
width: 95vw;
max-width: calc(320px * 3.3);
transform: translateX(calc(-50% + var(--main-width) / 2));
&:dir(rtl) {
transform: translateX(calc(50% - var(--main-width) / 2));
}
}
}
@ -38,12 +41,16 @@
color: var(--text-insignificant-color);
position: absolute;
top: 8px;
left: 0;
inset-inline-start: 0;
transform-origin: top left;
transform: rotate(-90deg) translateX(-100%);
&:dir(rtl) {
transform-origin: top right;
transform: rotate(90deg) translateX(100%);
}
user-select: none;
background-image: linear-gradient(
to left,
var(--to-backward),
var(--text-color),
var(--link-color)
);
@ -95,6 +102,29 @@
filter: brightness(0.8);
}
figure {
transition: 1s ease-out;
transition-property: opacity, mix-blend-mode;
}
&.inactive:not(:active, :hover) {
figure {
transition-duration: 0.3s;
opacity: 0.5;
mix-blend-mode: luminosity;
}
}
&.active {
border-color: var(--accent-color, var(--link-light-color));
height: 100%;
max-height: 100%;
+ button[disabled] {
display: none;
}
}
article {
width: 100%;
display: flex;

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import { addListStore, deleteListStore, updateListStore } from '../utils/lists';
import supports from '../utils/supports';
import Icon from './icon';
@ -75,6 +76,14 @@ function ListAddEdit({ list, onClose }) {
state: 'success',
list: listResult,
});
setTimeout(() => {
if (editMode) {
updateListStore(listResult);
} else {
addListStore(listResult);
}
}, 1);
} catch (e) {
console.error(e);
setUIState('error');
@ -146,6 +155,9 @@ function ListAddEdit({ list, onClose }) {
onClose?.({
state: 'deleted',
});
setTimeout(() => {
deleteListStore(list.id);
}, 1);
} catch (e) {
console.error(e);
setUIState('error');

View file

@ -10,14 +10,15 @@ import {
import { useHotkeys } from 'react-hotkeys-hook';
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import isRTL from '../utils/is-rtl';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import Icon from './icon';
import Link from './link';
import Media from './media';
import Menu2 from './menu2';
import MenuLink from './menu-link';
import Menu2 from './menu2';
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
@ -54,7 +55,7 @@ function MediaModal({
const differentStatusID = prevStatusID.current !== statusID;
if (differentStatusID) prevStatusID.current = statusID;
carouselRef.current.scrollTo({
left: scrollLeft,
left: scrollLeft * (isRTL() ? -1 : 1),
behavior: differentStatusID ? 'auto' : 'smooth',
});
carouselRef.current.focus();
@ -91,7 +92,7 @@ function MediaModal({
useEffect(() => {
let handleScroll = () => {
const { clientWidth, scrollLeft } = carouselRef.current;
const index = Math.round(scrollLeft / clientWidth);
const index = Math.round(Math.abs(scrollLeft) / clientWidth);
setCurrentIndex(index);
};
if (carouselRef.current) {
@ -178,7 +179,7 @@ function MediaModal({
? {
backgroundAttachment: 'local',
backgroundImage: `linear-gradient(
to right, ${mediaAccentGradient})`,
to ${isRTL() ? 'left' : 'right'}, ${mediaAccentGradient})`,
}
: {}
}
@ -257,7 +258,8 @@ function MediaModal({
e.preventDefault();
e.stopPropagation();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * i,
left:
carouselRef.current.clientWidth * i * (isRTL() ? -1 : 1),
behavior: 'smooth',
});
carouselRef.current.focus();
@ -368,7 +370,10 @@ function MediaModal({
e.stopPropagation();
carouselRef.current.focus();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex - 1),
left:
carouselRef.current.clientWidth *
(currentIndex - 1) *
(isRTL() ? -1 : 1),
behavior: 'smooth',
});
}}
@ -384,7 +389,10 @@ function MediaModal({
e.stopPropagation();
carouselRef.current.focus();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex + 1),
left:
carouselRef.current.clientWidth *
(currentIndex + 1) *
(isRTL() ? -1 : 1),
behavior: 'smooth',
});
}}

View file

@ -23,7 +23,7 @@
pointer-events: none;
position: absolute;
top: 0;
left: 0;
inset-inline-start: 0;
z-index: 1;
background-color: var(--bg-blur-color);
margin: 8px;

View file

@ -8,6 +8,7 @@ import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states';
import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import Media from './media';
@ -88,7 +89,7 @@ function MediaPost({
};
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
return getCurrentAccountID();
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;

View file

@ -1,5 +1,6 @@
import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import {
useCallback,
useLayoutEffect,
@ -9,12 +10,12 @@ import {
} from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import formatDuration from '../utils/format-duration';
import mem from '../utils/mem';
import states from '../utils/states';
import Icon from './icon';
import Link from './link';
import { formatDuration } from './status';
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
@ -74,7 +75,7 @@ function Media({
altIndex,
onClick = () => {},
}) {
const {
let {
blurhash,
description,
meta,
@ -84,15 +85,27 @@ function Media({
url,
type,
} = media;
if (/no\-preview\./i.test(previewUrl)) {
previewUrl = null;
}
const { original = {}, small, focus } = meta || {};
const width = showOriginal ? original?.width : small?.width;
const height = showOriginal ? original?.height : small?.height;
const width = showOriginal
? original?.width
: small?.width || original?.width;
const height = showOriginal
? original?.height
: small?.height || original?.height;
const mediaURL = showOriginal ? url : previewUrl || url;
const remoteMediaURL = showOriginal
? remoteUrl
: previewRemoteUrl || remoteUrl;
const orientation = width >= height ? 'landscape' : 'portrait';
const hasDimensions = width && height;
const orientation = hasDimensions
? width > height
? 'landscape'
: 'portrait'
: null;
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
@ -133,7 +146,8 @@ function Media({
enabled: pinchZoomEnabled,
draggableUnZoomed: false,
inertiaFriction: 0.9,
doubleTapZoomOutOnMaxScale: true,
tapZoomFactor: 2,
doubleTapToggleZoom: true,
containerProps: {
className: 'media-zoom',
style: {
@ -153,7 +167,7 @@ function Media({
[to],
);
const remoteMediaURLObj = remoteMediaURL ? new URL(remoteMediaURL) : null;
const remoteMediaURLObj = remoteMediaURL ? getURLObj(remoteMediaURL) : null;
const isVideoMaybe =
type === 'unknown' &&
remoteMediaURLObj &&
@ -235,6 +249,8 @@ function Media({
);
};
const [hasNaturalAspectRatio, setHasNaturalAspectRatio] = useState(undefined);
if (isImage) {
// Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit';
@ -259,7 +275,8 @@ function Media({
class={`media media-image ${className}`}
onClick={onClick}
data-orientation={orientation}
data-has-alt={!showInlineDesc}
data-has-alt={!showInlineDesc || undefined}
data-has-natural-aspect-ratio={hasNaturalAspectRatio || undefined}
style={
showOriginal
? {
@ -290,7 +307,11 @@ function Media({
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL && mediaURL !== remoteMediaURL) {
if (
src === mediaURL &&
remoteMediaURL &&
mediaURL !== remoteMediaURL
) {
e.target.src = remoteMediaURL;
}
}}
@ -321,6 +342,48 @@ function Media({
onLoad={(e) => {
// e.target.closest('.media-image').style.backgroundImage = '';
e.target.dataset.loaded = true;
const $media = e.target.closest('.media');
if (!hasDimensions && $media) {
const { naturalWidth, naturalHeight } = e.target;
$media.dataset.orientation =
naturalWidth > naturalHeight ? 'landscape' : 'portrait';
$media.style.setProperty('--width', `${naturalWidth}px`);
$media.style.setProperty('--height', `${naturalHeight}px`);
$media.style.aspectRatio = `${naturalWidth}/${naturalHeight}`;
}
// Check natural aspect ratio vs display aspect ratio
if ($media) {
const {
clientWidth,
clientHeight,
naturalWidth,
naturalHeight,
} = e.target;
if (
clientWidth &&
clientHeight &&
naturalWidth &&
naturalHeight
) {
const minDimension = 88;
if (
naturalWidth < minDimension ||
naturalHeight < minDimension
) {
$media.dataset.hasSmallDimension = true;
} else {
const displayNaturalHeight =
(naturalHeight * clientWidth) / naturalWidth;
const almostSimilarHeight =
Math.abs(displayNaturalHeight - clientHeight) < 5;
if (almostSimilarHeight) {
setHasNaturalAspectRatio(true);
}
}
}
}
}}
onError={(e) => {
const { src } = e.target;
@ -338,6 +401,7 @@ function Media({
</Figure>
);
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
const hasDuration = original.duration > 0;
const shortDuration = original.duration < 31;
const isGIF = type === 'gifv' && shortDuration;
// If GIF is too long, treat it as a video
@ -347,28 +411,43 @@ function Media({
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
const showProgress = original.duration > 5;
const videoHTML = `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
data-orientation="${orientation}"
preload="auto"
autoplay
muted="${isGIF}"
${isGIF ? '' : 'controls'}
playsinline
loop="${loopable}"
${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''}
${
isGIF && showProgress
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
: ''
}
></video>
// This string is only for autoplay + muted to work on Mobile Safari
const gifHTML = `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
data-orientation="${orientation}"
preload="auto"
autoplay
muted
playsinline
${loopable ? 'loop' : ''}
ondblclick="this.paused ? this.play() : this.pause()"
${
showProgress
? "ontimeupdate=\"this.closest('.media-gif') && this.closest('.media-gif').style.setProperty('--progress', `${~~((this.currentTime / this.duration) * 100)}%`)\""
: ''
}
></video>
`;
const videoHTML = `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
data-orientation="${orientation}"
preload="auto"
autoplay
playsinline
${loopable ? 'loop' : ''}
controls
></video>
`;
return (
<Figure>
<Parent
@ -379,8 +458,10 @@ function Media({
data-formatted-duration={
!showOriginal ? formattedDuration : undefined
}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
data-has-alt={!showInlineDesc}
data-label={
isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : undefined
}
data-has-alt={!showInlineDesc || undefined}
// style={{
// backgroundColor:
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
@ -429,17 +510,22 @@ function Media({
<div
ref={mediaRef}
dangerouslySetInnerHTML={{
__html: videoHTML,
__html: gifHTML,
}}
/>
</QuickPinchZoom>
) : (
) : isGIF ? (
<div
class="video-container"
dangerouslySetInnerHTML={{
__html: videoHTML,
__html: gifHTML,
}}
/>
) : (
<div
class="video-container"
dangerouslySetInnerHTML={{ __html: videoHTML }}
/>
)
) : isGIF ? (
<video
@ -473,14 +559,61 @@ function Media({
/>
) : (
<>
<img
src={previewUrl}
alt={showInlineDesc ? '' : description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
/>
{previewUrl ? (
<img
src={previewUrl}
alt={showInlineDesc ? '' : description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
decoding="async"
onLoad={(e) => {
if (!hasDimensions) {
const $media = e.target.closest('.media');
if ($media) {
const { naturalHeight, naturalWidth } = e.target;
$media.dataset.orientation =
naturalWidth > naturalHeight
? 'landscape'
: 'portrait';
$media.style.setProperty(
'--width',
`${naturalWidth}px`,
);
$media.style.setProperty(
'--height',
`${naturalHeight}px`,
);
$media.style.aspectRatio = `${naturalWidth}/${naturalHeight}`;
}
}
}}
/>
) : (
<video
src={url + '#t=0.1'} // Make Safari show 1st-frame preview
width={width}
height={height}
data-orientation={orientation}
preload="metadata"
muted
disablePictureInPicture
onLoadedMetadata={(e) => {
if (!hasDuration) {
const { duration } = e.target;
if (duration) {
const formattedDuration = formatDuration(duration);
const container = e.target.closest('.media-video');
if (container) {
container.dataset.formattedDuration =
formattedDuration;
}
}
}
}}
/>
)}
<div class="media-play">
<Icon icon="play" size="xl" />
</div>
@ -501,12 +634,12 @@ function Media({
data-formatted-duration={
!showOriginal ? formattedDuration : undefined
}
data-has-alt={!showInlineDesc}
data-has-alt={!showInlineDesc || undefined}
onClick={onClick}
style={!showOriginal && mediaStyles}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
<audio src={remoteUrl || url} preload="none" controls autoPlay />
) : previewUrl ? (
<img
src={previewUrl}
@ -539,4 +672,19 @@ function Media({
}
}
export default Media;
function getURLObj(url) {
// Fake base URL if url doesn't have https:// prefix
return URL.parse(url, location.origin);
}
export default memo(Media, (oldProps, newProps) => {
const oldMedia = oldProps.media || {};
const newMedia = newProps.media || {};
return (
oldMedia?.id === newMedia?.id &&
oldMedia.url === newMedia.url &&
oldProps.to === newProps.to &&
oldProps.class === newProps.class
);
});

View file

@ -1,8 +1,8 @@
import { Menu, MenuItem, SubMenu } from '@szhsin/react-menu';
import { MenuItem } from '@szhsin/react-menu';
import { cloneElement } from 'preact';
import { useRef } from 'preact/hooks';
import Menu2 from './menu2';
import SubMenu2 from './submenu2';
function MenuConfirm({
subMenu = false,
@ -10,6 +10,7 @@ function MenuConfirm({
confirmLabel,
menuItemClassName,
menuFooter,
menuExtras,
...props
}) {
const { children, onClick, ...restProps } = props;
@ -22,11 +23,9 @@ function MenuConfirm({
}
return children;
}
const Parent = subMenu ? SubMenu : Menu2;
const menuRef = useRef();
const Parent = subMenu ? SubMenu2 : Menu2;
return (
<Parent
instanceRef={menuRef}
openTrigger="clickOnly"
direction="bottom"
overflow="auto"
@ -36,23 +35,11 @@ function MenuConfirm({
{...restProps}
menuButton={subMenu ? undefined : children}
label={subMenu ? children : undefined}
// Test fix for bug; submenus not opening on Android
itemProps={{
onPointerMove: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
onPointerLeave: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
}}
>
<MenuItem className={menuItemClassName} onClick={onClick}>
{confirmLabel}
</MenuItem>
{menuExtras}
{menuFooter}
</Parent>
);

View file

@ -1,21 +1,33 @@
import { Menu } from '@szhsin/react-menu';
import { useWindowSize } from '@uidotdev/usehooks';
import { useRef } from 'preact/hooks';
import isRTL from '../utils/is-rtl';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import useWindowSize from '../utils/useWindowSize';
// It's like Menu but with sensible defaults, bug fixes and improvements.
function Menu2(props) {
const { containerProps, instanceRef: _instanceRef } = props;
const { containerProps, instanceRef: _instanceRef, align } = props;
const size = useWindowSize();
const instanceRef = _instanceRef?.current ? _instanceRef : useRef();
// Values: start, end, center
// Note: don't mess with 'center'
const rtlAlign = isRTL()
? align === 'end'
? 'start'
: align === 'start'
? 'end'
: align
: align;
return (
<Menu
boundingBoxPadding={safeBoundingBoxPadding()}
repositionFlag={`${size.width}x${size.height}`}
unmountOnClose
{...props}
align={rtlAlign}
instanceRef={instanceRef}
containerProps={{
onClick: (e) => {

View file

@ -1,7 +1,7 @@
#modal-container > div {
position: fixed;
top: 0;
right: 0;
inset-inline-end: 0;
height: 100%;
width: 100%;
z-index: 1000;
@ -10,17 +10,65 @@
align-items: center;
background-color: var(--backdrop-color);
animation: appear 0.5s var(--timing-function) both;
transition: all 0.5s var(--timing-function);
&.solid {
background-color: var(--backdrop-solid-color);
}
--compose-button-dimension: 56px;
--compose-button-dimension-half: calc(var(--compose-button-dimension) / 2);
--compose-button-dimension-margin: 16px;
&.min {
/* Minimized */
pointer-events: none;
user-select: none;
overflow: hidden;
transform: scale(0);
--end: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-right)
);
:dir(rtl) & {
--end: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-left)
);
}
--bottom: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-bottom)
);
--origin-end: calc(
100% - var(--compose-button-dimension-half) - var(--end)
);
:dir(rtl) & {
--origin-end: calc(var(--compose-button-dimension-half) + var(--end));
}
--origin-bottom: calc(
100% - var(--compose-button-dimension-half) - var(--bottom)
);
transform-origin: var(--origin-end) var(--origin-bottom);
}
.sheet {
transition: transform 0.3s var(--timing-function);
transform-origin: center bottom;
transform-origin: 80% 80%;
}
&:has(~ div) .sheet {
transform: scale(0.975);
}
}
@media (max-width: calc(40em - 1px)) {
#app[data-shortcuts-view-mode='tab-menu-bar'] ~ #modal-container > div.min {
border: 2px solid red;
--bottom: calc(
var(--compose-button-dimension-margin) + env(safe-area-inset-bottom) +
52px
);
}
}

View file

@ -8,7 +8,7 @@ import useCloseWatcher from '../utils/useCloseWatcher';
const $modalContainer = document.getElementById('modal-container');
function Modal({ children, onClose, onClick, class: className }) {
function Modal({ children, onClose, onClick, class: className, minimized }) {
if (!children) return null;
const modalRef = useRef();
@ -41,6 +41,33 @@ function Modal({ children, onClose, onClick, class: className }) {
);
useCloseWatcher(onClose, [onClose]);
useEffect(() => {
const $deckContainers = document.querySelectorAll('.deck-container');
if (minimized) {
// Similar to focusDeck in focus-deck.jsx
// Focus last deck
const page = $deckContainers[$deckContainers.length - 1]; // last one
if (page && page.tabIndex === -1) {
page.focus();
}
} else {
if (children) {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.setAttribute('inert', '');
});
} else {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.removeAttribute('inert');
});
}
}
return () => {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.removeAttribute('inert');
});
};
}, [children, minimized]);
const Modal = (
<div
ref={(node) => {
@ -54,7 +81,8 @@ function Modal({ children, onClose, onClick, class: className }) {
onClose?.(e);
}
}}
tabIndex="-1"
tabIndex={minimized ? 0 : '-1'}
inert={minimized}
onFocus={(e) => {
try {
if (e.target === e.currentTarget) {

View file

@ -1,3 +1,4 @@
import { useEffect } from 'preact/hooks';
import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio';
@ -8,7 +9,7 @@ import showToast from '../utils/show-toast';
import states from '../utils/states';
import AccountSheet from './account-sheet';
import Compose from './compose';
import ComposeSuspense, { preload } from './compose-suspense';
import Drafts from './drafts';
import EmbedModal from './embed-modal';
import GenericAccounts from './generic-accounts';
@ -32,11 +33,18 @@ export default function Modals() {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
setTimeout(preload, 1000);
}, []);
return (
<>
{!!snapStates.showCompose && (
<Modal class="solid">
<Compose
<Modal
class={`solid ${snapStates.composerState.minimized ? 'min' : ''}`}
minimized={!!snapStates.composerState.minimized}
>
<ComposeSuspense
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
@ -179,7 +187,9 @@ export default function Modals() {
excludeRelationshipAttrs={
snapStates.showGenericAccounts.excludeRelationshipAttrs
}
postID={snapStates.showGenericAccounts.postID}
onClose={() => (states.showGenericAccounts = false)}
blankCopy={snapStates.showGenericAccounts.blankCopy}
/>
</Modal>
)}

View file

@ -5,9 +5,14 @@
unicode-bidi: isolate;
b {
font-weight: 500;
font-weight: 600;
unicode-bidi: isolate;
}
i {
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
}
}
.name-text.show-acct {
display: inline-block;
@ -21,6 +26,9 @@ a.name-text.short:is(:hover, :focus) i {
font-style: normal;
opacity: 0.75;
}
.name-text i.instance {
opacity: 0.35;
}
.name-text .avatar {
vertical-align: middle;

View file

@ -2,6 +2,7 @@ import './name-text.css';
import { memo } from 'preact/compat';
import { api } from '../utils/api';
import states from '../utils/states';
import Avatar from './avatar';
@ -20,42 +21,60 @@ function NameText({
external,
onClick,
}) {
const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } =
account;
let { username } = account;
const {
acct,
avatar,
avatarStatic,
id,
url,
displayName,
emojis,
bot,
username,
} = account;
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (!instance) instance = api().instance;
const trimmedUsername = username.toLowerCase().trim();
const trimmedDisplayName = (displayName || '').toLowerCase().trim();
const shortenedDisplayName = trimmedDisplayName
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
.replace(/\s+/g, ''); // E.g. "My name" === "myname"
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
/[^a-z0-9]/gi,
/[^a-z0-9@\.]/gi,
'',
); // Remove non-alphanumeric characters
if (
!short &&
(trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName ||
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)
) {
username = null;
}
const hideUsername =
(!short &&
(trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName ||
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)) ||
shortenedAlphaNumericDisplayName === acct.toLowerCase();
return (
<a
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
href={url}
target={external ? '_blank' : null}
title={`${displayName ? `${displayName} ` : ''}@${acct}`}
title={
displayName
? `${displayName} (${acct2 ? '' : '@'}${acct})`
: `${acct2 ? '' : '@'}${acct}`
}
onClick={(e) => {
if (external) return;
if (e.shiftKey) return; // Save link? 🤷
e.preventDefault();
e.stopPropagation();
if (onClick) return onClick(e);
if (e.metaKey || e.ctrlKey || e.shiftKey || e.which === 2) {
const internalURL = `#/${instance}/a/${id}`;
window.open(internalURL, '_blank');
return;
}
states.showAccount = {
account,
instance,
@ -69,15 +88,16 @@ function NameText({
)}
{displayName && !short ? (
<>
<b>
<b dir="auto">
<EmojiText text={displayName} emojis={emojis} />
</b>
{!showAcct && username && (
{!showAcct && !hideUsername ? (
<>
{' '}
<i>@{username}</i>
<i class="bidi-isolate">@{username}</i>
</>
)}
) : ' '}
<i class="instance">{acct2}</i>
</>
) : short ? (
<i>{username}</i>
@ -87,9 +107,10 @@ function NameText({
{showAcct && (
<>
<br />
<i>
@{acct1}
<span class="ib">{acct2}</span>
<i class="bidi-isolate">
{acct2 ? '' : '@'}
{acct1}
{!!acct2 && <span class="ib">{acct2}</span>}
</i>
</>
)}

View file

@ -35,11 +35,15 @@
}
.nav-menu section:last-child {
background-image: linear-gradient(
to right,
var(--to-forward),
var(--divider-color) 1px,
transparent 1px
),
linear-gradient(to bottom left, var(--bg-blur-color), transparent),
linear-gradient(
to bottom var(--backward),
var(--bg-blur-color),
transparent
),
url(../assets/phanpy-bg.svg);
background-repeat: no-repeat;
/* background-size: auto, auto, 200%; */
@ -49,8 +53,8 @@
position: sticky;
top: 0;
animation: phanpying 0.2s ease-in-out both;
border-top-right-radius: inherit;
border-bottom-right-radius: inherit;
border-start-end-radius: inherit;
border-end-end-radius: inherit;
margin-bottom: 0;
display: flex;
flex-direction: column;
@ -88,3 +92,7 @@
.sparkle-icon {
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
}
.nav-submenu {
max-width: 14em;
}

View file

@ -1,39 +1,35 @@
import './nav-menu.css';
import {
ControlledMenu,
MenuDivider,
MenuItem,
SubMenu,
} from '@szhsin/react-menu';
import { ControlledMenu, FocusableItem, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import states from '../utils/states';
import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import supports from '../utils/supports';
import Avatar from './avatar';
import Icon from './icon';
import MenuLink from './menu-link';
import SubMenu2 from './submenu2';
import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
function NavMenu(props) {
const snapStates = useSnapshot(states);
const { masto, instance, authenticated } = api();
const [currentAccount, setCurrentAccount] = useState();
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
useEffect(() => {
const [currentAccount, moreThanOneAccount] = useMemo(() => {
const accounts = store.local.getJSON('accounts') || [];
const acc = accounts.find(
(account) => account.info.id === store.session.get('currentAccount'),
);
if (acc) setCurrentAccount(acc);
setMoreThanOneAccount(accounts.length > 1);
const acc =
accounts.find((account) => account.info.id === getCurrentAccountID()) ||
accounts[0];
return [acc, accounts.length > 1];
}, []);
// Home = Following
@ -89,6 +85,15 @@ function NavMenu(props) {
return results;
}
const supportsLists = supports('@mastodon/lists');
const [lists, setLists] = useState([]);
useEffect(() => {
if (!supportsLists) return;
if (menuState === 'open') {
getLists().then(setLists);
}
}, [menuState === 'open']);
const buttonClickTS = useRef();
return (
<>
@ -97,7 +102,7 @@ function NavMenu(props) {
type="button"
class={`button plain nav-menu-button ${
moreThanOneAccount ? 'with-avatar' : ''
} ${open ? 'active' : ''}`}
} ${menuState === 'open' ? 'active' : ''}`}
style={{ position: 'relative' }}
onClick={() => {
buttonClickTS.current = Date.now();
@ -185,9 +190,11 @@ function NavMenu(props) {
<Icon icon="history2" size="l" />
<span>Catch-up</span>
</MenuLink>
<MenuLink to="/mentions">
<Icon icon="at" size="l" /> <span>Mentions</span>
</MenuLink>
{supports('@mastodon/mentions') && (
<MenuLink to="/mentions">
<Icon icon="at" size="l" /> <span>Mentions</span>
</MenuLink>
)}
<MenuLink to="/notifications">
<Icon icon="notification" size="l" /> <span>Notifications</span>
{snapStates.notificationsShowNew && (
@ -203,13 +210,50 @@ function NavMenu(props) {
<Icon icon="user" size="l" /> <span>Profile</span>
</MenuLink>
)}
<MenuLink to="/l">
<Icon icon="list" size="l" /> <span>Lists</span>
</MenuLink>
{currentAccount && accountsIsDtth(currentAccount) &&
<FocusableItem title="Takes you to DTTHDon settings">
<a href={gtsDtthSettings} target='_blank'><Icon icon="user-setting" size="l" /> <span>User Settings&hellip;</span></a>
</FocusableItem>}
{lists?.length > 0 ? (
<SubMenu2
menuClassName="nav-submenu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon="list" size="l" />
<span class="menu-grow">Lists</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
{lists?.length > 0 && (
<>
<MenuDivider />
{lists.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</>
)}
</SubMenu2>
) : (
supportsLists && (
<MenuLink to="/l">
<Icon icon="list" size="l" />
<span>Lists</span>
</MenuLink>
)
)}
<MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink>
<SubMenu
<SubMenu2
menuClassName="nav-submenu"
overflow="auto"
gap={-8}
label={
@ -223,11 +267,17 @@ function NavMenu(props) {
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink>
<MenuLink to="/ft">
<MenuLink to="/fh">
<Icon icon="hashtag" size="l" />{' '}
<span>Followed Hashtags</span>
</MenuLink>
<MenuDivider />
{supports('@mastodon/filters') && (
<MenuLink to="/ft">
<Icon icon="filters" size="l" />
Filters
</MenuLink>
)}
<MenuItem
onClick={() => {
states.showGenericAccounts = {
@ -253,7 +303,7 @@ function NavMenu(props) {
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>{' '}
</SubMenu>
</SubMenu2>
<MenuDivider />
<MenuItem
onClick={() => {

View file

@ -2,11 +2,13 @@ import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import states, { statusKey } from '../utils/states';
import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import useTruncated from '../utils/useTruncated';
import Avatar from './avatar';
import CustomEmoji from './custom-emoji';
import FollowRequestButtons from './follow-request-buttons';
import Icon from './icon';
import Link from './link';
@ -25,6 +27,10 @@ const NOTIFICATION_ICONS = {
update: 'pencil',
'admin.signup': 'account-edit',
'admin.report': 'account-warning',
severed_relationships: 'heart-break',
moderation_warning: 'alert',
emoji_reaction: 'emoji2',
'pleroma:emoji_reaction': 'emoji2',
};
/*
@ -40,8 +46,28 @@ poll = A poll you have voted in or created has ended
update = A status you interacted with has been edited
admin.sign_up = Someone signed up (optionally sent to admins)
admin.report = A new report has been filed
severed_relationships = Severed relationships
moderation_warning = Moderation warning
*/
function emojiText(emoji, emoji_url) {
let url;
let staticUrl;
if (typeof emoji_url === 'string') {
url = emoji_url;
} else {
url = emoji_url?.url;
staticUrl = emoji_url?.staticUrl;
}
return url ? (
<>
reacted to your post with{' '}
<CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} />
</>
) : (
`reacted to your post with ${emoji}.`
);
}
const contentText = {
mention: 'mentioned you in their post.',
status: 'published a post.',
@ -63,9 +89,50 @@ const contentText = {
'favourite+reblog_reply': 'boosted & liked your reply.',
'admin.sign_up': 'signed up.',
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
severed_relationships: (name) => (
<>
Lost connections with <i>{name}</i>.
</>
),
moderation_warning: <b>Moderation warning</b>,
emoji_reaction: emojiText,
'pleroma:emoji_reaction': emojiText,
};
const AVATARS_LIMIT = 50;
// account_suspension, domain_block, user_domain_block
const SEVERED_RELATIONSHIPS_TEXT = {
account_suspension: ({ from, targetName }) => (
<>
An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means
you can no longer receive updates from them or interact with them.
</>
),
domain_block: ({ from, targetName, followersCount, followingCount }) => (
<>
An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected
followers: {followersCount}, followings: {followingCount}.
</>
),
user_domain_block: ({ targetName, followersCount, followingCount }) => (
<>
You have blocked <i>{targetName}</i>. Removed followers: {followersCount},
followings: {followingCount}.
</>
),
};
const MODERATION_WARNING_TEXT = {
none: 'Your account has received a moderation warning.',
disable: 'Your account has been disabled.',
mark_statuses_as_sensitive:
'Some of your posts have been marked as sensitive.',
delete_statuses: 'Some of your posts have been deleted.',
sensitive: 'Your posts will be marked as sensitive from now on.',
silence: 'Your account has been limited.',
suspend: 'Your account has been suspended.',
};
const AVATARS_LIMIT = 30;
function Notification({
notification,
@ -73,14 +140,28 @@ function Notification({
isStatic,
disableContextMenu,
}) {
const { id, status, account, report, _accounts, _statuses } = notification;
const {
id,
status,
account,
report,
event,
moderation_warning,
// Client-side grouped notification
_ids,
_accounts,
_statuses,
// Server-side grouped notification
sampleAccounts,
notificationsCount,
} = notification;
let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
const actualStatus = status?.reblog || status;
const actualStatusID = actualStatus?.id;
const currentAccount = store.session.get('currentAccount');
const currentAccount = getCurrentAccountID();
const isSelf = currentAccount === account?.id;
const isVoted = status?.poll?.voted;
const isReplyToOthers =
@ -91,12 +172,14 @@ function Notification({
let favsCount = 0;
let reblogsCount = 0;
if (type === 'favourite+reblog') {
for (const account of _accounts) {
if (account._types?.includes('favourite')) {
favsCount++;
}
if (account._types?.includes('reblog')) {
reblogsCount++;
if (_accounts) {
for (const account of _accounts) {
if (account._types?.includes('favourite')) {
favsCount++;
}
if (account._types?.includes('reblog')) {
reblogsCount++;
}
}
}
if (!reblogsCount && favsCount) type = 'favourite';
@ -128,13 +211,30 @@ function Notification({
if (typeof text === 'function') {
const count = _statuses?.length || _accounts?.length;
if (count) {
text = text(count);
} else if (type === 'admin.report') {
if (type === 'admin.report') {
const targetAccount = report?.targetAccount;
if (targetAccount) {
text = text(<NameText account={targetAccount} showAvatar />);
}
} else if (type === 'severed_relationships') {
const targetName = event?.targetName;
if (targetName) {
text = text(targetName);
}
} else if (
(type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') &&
notification.emoji
) {
const emojiURL =
notification.emoji_url || // This is string
status?.emojis?.find?.(
(emoji) =>
emoji?.shortcode ===
notification.emoji.replace(/^:/, '').replace(/:$/, ''),
); // Emoji object instead of string
text = text(notification.emoji, emojiURL);
} else if (count) {
text = text(count);
}
}
@ -159,6 +259,7 @@ function Notification({
accounts: _accounts,
showReactions: type === 'favourite+reblog',
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
postID: statusKey(actualStatusID, instance),
};
};
@ -167,7 +268,7 @@ function Notification({
return (
<div
class={`notification notification-${type}`}
data-notification-id={id}
data-notification-id={_ids || id}
tabIndex="0"
>
<div
@ -191,7 +292,7 @@ function Notification({
{type !== 'mention' && (
<>
<p>
{!/poll|update/i.test(type) && (
{!/poll|update|severed_relationships/i.test(type) && (
<>
{_accounts?.length > 1 ? (
<>
@ -202,10 +303,21 @@ function Notification({
people
</b>{' '}
</>
) : (
) : notificationsCount > 1 ? (
<>
<NameText account={account} showAvatar />{' '}
<b>
<span title={notificationsCount}>
{shortenNumber(notificationsCount)}
</span>{' '}
people
</b>{' '}
</>
) : (
account && (
<>
<NameText account={account} showAvatar />{' '}
</>
)
)}
</>
)}
@ -224,6 +336,37 @@ function Notification({
{type === 'follow_request' && (
<FollowRequestButtons accountID={account.id} />
)}
{type === 'severed_relationships' && (
<div>
{SEVERED_RELATIONSHIPS_TEXT[event.type]({
from: instance,
...event,
})}
<br />
<a
href={`https://${instance}/severed_relationships`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
</a>
.
</div>
)}
{type === 'moderation_warning' && !!moderation_warning && (
<div>
{MODERATION_WARNING_TEXT[moderation_warning.action]}
<br />
<a
href={`/disputes/strikes/${moderation_warning.id}`}
target="_blank"
rel="noopener noreferrer"
>
Learn more <Icon icon="external" size="s" />
</a>
.
</div>
)}
</>
)}
{_accounts?.length > 1 && (
@ -247,11 +390,7 @@ function Notification({
? 'xxl'
: _accounts.length < 20
? 'xl'
: _accounts.length < 30
? 'l'
: _accounts.length < 40
? 'm'
: 's' // My god, this person is popular!
: 'l'
}
key={account.id}
alt={`${account.displayName} @${account.acct}`}
@ -282,6 +421,54 @@ function Notification({
</button>
</p>
)}
{!_accounts?.length && sampleAccounts?.length > 1 && (
<p class="avatars-stack">
{sampleAccounts.map((account) => (
<Fragment key={account.id}>
<a
key={account.id}
href={account.url}
rel="noopener noreferrer"
class="account-avatar-stack"
onClick={(e) => {
e.preventDefault();
states.showAccount = account;
}}
>
<Avatar
url={account.avatarStatic}
size="xxl"
key={account.id}
alt={`${account.displayName} @${account.acct}`}
squircle={account?.bot}
/>
{/* {type === 'favourite+reblog' && (
<div class="account-sub-icons">
{account._types.map((type) => (
<Icon
icon={NOTIFICATION_ICONS[type]}
size="s"
class={`${type}-icon`}
/>
))}
</div>
)} */}
</a>{' '}
</Fragment>
))}
{notificationsCount > sampleAccounts.length && (
<Link
to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
class="button small plain centered"
>
+{notificationsCount - sampleAccounts.length}
<Icon icon="chevron-right" />
</Link>
)}
</p>
)}
{_statuses?.length > 1 && (
<ul class="notification-group-statuses">
{_statuses.map((status) => (

View file

@ -187,9 +187,6 @@ export default function Poll({
type="button"
class="plain small"
disabled={uiState === 'loading'}
style={{
marginLeft: -8,
}}
onClick={(e) => {
e.preventDefault();
setUIState('loading');

View file

@ -8,7 +8,7 @@ import dayjs from 'dayjs';
import dayjsTwitter from 'dayjs-twitter';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useMemo } from 'preact/hooks';
import { useEffect, useMemo, useReducer } from 'preact/hooks';
dayjs.extend(dayjsTwitter);
dayjs.extend(localizedFormat);
@ -18,22 +18,51 @@ const dtf = new Intl.DateTimeFormat();
export default function RelativeTime({ datetime, format }) {
if (!datetime) return null;
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
const date = useMemo(() => dayjs(datetime), [datetime]);
const dateStr = useMemo(() => {
const [dateStr, dt, title] = useMemo(() => {
if (!date.isValid()) return ['' + datetime, '', ''];
let str;
if (format === 'micro') {
// If date <= 1 day ago or day is within this year
const now = dayjs();
const dayDiff = now.diff(date, 'day');
if (dayDiff <= 1 || now.year() === date.year()) {
return date.twitter();
str = date.twitter();
} else {
return dtf.format(date.toDate());
str = dtf.format(date.toDate());
}
}
return date.fromNow();
}, [date, format]);
const dt = useMemo(() => date.toISOString(), [date]);
const title = useMemo(() => date.format('LLLL'), [date]);
if (!str) str = date.fromNow();
return [str, date.toISOString(), date.format('LLLL')];
}, [date, format, renderCount]);
useEffect(() => {
if (!date.isValid()) return;
let timeout;
let raf;
function rafRerender() {
raf = requestAnimationFrame(() => {
rerender();
scheduleRerender();
});
}
function scheduleRerender() {
// If less than 1 minute, rerender every 10s
// If less than 1 hour rerender every 1m
// Else, don't need to rerender
if (date.diff(dayjs(), 'minute', true) < 1) {
timeout = setTimeout(rafRerender, 10_000);
} else if (date.diff(dayjs(), 'hour', true) < 1) {
timeout = setTimeout(rafRerender, 60_000);
}
}
scheduleRerender();
return () => {
clearTimeout(timeout);
cancelAnimationFrame(raf);
};
}, []);
return (
<time datetime={dt} title={title}>

View file

@ -26,6 +26,8 @@
background-color: var(--bg-blur-color);
backdrop-filter: blur(16px);
padding: 16px;
padding: calc(var(--sai-top, 0) + 16px) calc(var(--sai-right, 0) + 16px)
16px calc(var(--sai-left, 0) + 16px);
display: flex;
gap: 8px;
justify-content: space-between;
@ -41,6 +43,8 @@
main {
padding: 0 16px 16px;
padding: 0 calc(var(--sai-right, 0) + 16px)
calc(var(--sai-bottom, 0) + 16px) calc(var(--sai-left, 0) + 16px);
/* display: flex;
flex-direction: column;
gap: 16px; */
@ -88,7 +92,7 @@
pointer-events: none;
user-select: none;
position: absolute;
right: 32px;
inset-inline-end: 32px;
margin-top: -48px;
animation: rubber-stamp 0.3s ease-in both;
position: absolute;
@ -144,7 +148,7 @@
}
.report-rules {
margin-left: 1.75em;
margin-inline-start: 1.75em;
}
}

View file

@ -232,8 +232,8 @@ function ReportModal({ account, post, onClose }) {
disabled={uiState === 'loading'}
/>
</section>
<section>
{domain !== currentDomain && (
{!!domain && domain !== currentDomain && (
<section>
<p>
<label>
<input
@ -247,8 +247,8 @@ function ReportModal({ account, post, onClose }) {
</span>
</label>
</p>
)}
</section>
</section>
)}
<footer>
<button type="submit" disabled={uiState === 'loading'}>
Send Report

View file

@ -73,7 +73,7 @@ const SearchForm = forwardRef((props, ref) => {
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
spellCheck="false"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
@ -273,6 +273,7 @@ const SearchForm = forwardRef((props, ref) => {
class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
// hidden={hidden}
onClick={(e) => {
console.log('onClick', e);
props?.onSubmit?.(e);
}}
>

View file

@ -18,8 +18,8 @@
counter-increment: index;
display: inline-block;
width: 1.2em;
text-align: right;
margin-right: 8px;
text-align: end;
margin-inline-end: 8px;
color: var(--text-insignificant-color);
font-size: 90%;
flex-shrink: 0;
@ -55,12 +55,12 @@
justify-content: center;
}
#shortcuts-settings-container .shortcuts-view-mode label:first-child {
border-top-left-radius: 16px;
border-bottom-left-radius: 16px;
border-start-start-radius: 16px;
border-end-start-radius: 16px;
}
#shortcuts-settings-container .shortcuts-view-mode label:last-child {
border-top-right-radius: 16px;
border-bottom-right-radius: 16px;
border-start-end-radius: 16px;
border-end-end-radius: 16px;
}
#shortcuts-settings-container .shortcuts-view-mode label img {
max-height: 64px;
@ -114,7 +114,7 @@
}
#shortcut-settings-form label > span:first-child {
flex-basis: 5em;
text-align: right;
text-align: end;
}
#shortcut-settings-form :is(input[type='text'], select) {
flex-grow: 1;
@ -185,8 +185,8 @@
counter-increment: index;
display: inline-block;
width: 1.2em;
text-align: right;
margin-right: 8px;
text-align: end;
margin-inline-end: 8px;
color: var(--text-insignificant-color);
font-size: 90%;
flex-shrink: 0;

View file

@ -14,10 +14,12 @@ import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
import { api } from '../utils/api';
import { fetchFollowedTags } from '../utils/followed-tags';
import { getLists, getListTitle } from '../utils/lists';
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
import AsyncText from './AsyncText';
import Icon from './icon';
@ -43,7 +45,7 @@ const TYPES = [
const TYPE_TEXT = {
following: 'Home / Following',
notifications: 'Notifications',
list: 'List',
list: 'Lists',
public: 'Public (Local / Federated)',
search: 'Search',
'account-statuses': 'Account',
@ -58,6 +60,7 @@ const TYPE_PARAMS = {
{
text: 'List ID',
name: 'id',
notRequired: true,
},
],
public: [
@ -122,10 +125,6 @@ const TYPE_PARAMS = {
},
],
};
const fetchListTitle = pmem(async ({ id }) => {
const list = await api().masto.v1.lists.$select(id).fetch();
return list.title;
});
const fetchAccountTitle = pmem(async ({ id }) => {
const account = await api().masto.v1.accounts.$select(id).fetch();
return account.username || account.acct || account.displayName;
@ -150,10 +149,11 @@ export const SHORTCUTS_META = {
icon: 'notification',
},
list: {
id: 'list',
title: fetchListTitle,
path: ({ id }) => `/l/${id}`,
id: ({ id }) => (id ? 'list' : 'lists'),
title: ({ id }) => (id ? getListTitle(id) : 'Lists'),
path: ({ id }) => (id ? `/l/${id}` : '/l'),
icon: 'list',
excludeViewMode: ({ id }) => (!id ? ['multi-column'] : []),
},
public: {
id: 'public',
@ -496,18 +496,8 @@ function ShortcutsSettings({ onClose }) {
);
}
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
const fetchLists = pmem(
() => {
const { masto } = api();
return masto.v1.lists.list();
},
{
maxAge: FETCH_MAX_AGE,
},
);
const FORM_NOTES = {
list: `Specific list is optional. For multi-column mode, list is required, else the column will not be shown.`,
search: `For multi-column mode, search term is required, else the column will not be shown.`,
hashtag: 'Multiple hashtags are supported. Space-separated.',
};
@ -532,8 +522,7 @@ function ShortcutForm({
if (currentType !== 'list') return;
try {
setUIState('loading');
const lists = await fetchLists();
lists.sort((a, b) => a.title.localeCompare(b.title));
const lists = await getLists();
setLists(lists);
setUIState('default');
} catch (e) {
@ -623,6 +612,7 @@ function ShortcutForm({
}}
defaultValue={editMode ? shortcut.type : undefined}
name="type"
dir="auto"
>
<option></option>
{TYPES.map((type) => (
@ -643,7 +633,9 @@ function ShortcutForm({
required={!notRequired}
disabled={disabled || uiState === 'loading'}
defaultValue={editMode ? shortcut.id : undefined}
dir="auto"
>
<option value=""></option>
{lists.map((list) => (
<option value={list.id}>{list.title}</option>
))}
@ -671,8 +663,9 @@ function ShortcutForm({
}
autocorrect="off"
autocapitalize="off"
spellcheck={false}
spellCheck={false}
pattern={pattern}
dir="auto"
/>
{currentType === 'hashtag' &&
followedHashtags.length > 0 && (
@ -790,6 +783,7 @@ function ImportExport({ shortcuts, onClose }) {
onInput={(e) => {
setImportShortcutStr(e.target.value);
}}
dir="auto"
/>
{states.settings.shortcutSettingsCloudImportExport && (
<button
@ -798,7 +792,7 @@ function ImportExport({ shortcuts, onClose }) {
disabled={importUIState === 'cloud-downloading'}
onClick={async () => {
setImportUIState('cloud-downloading');
const currentAccount = store.session.get('currentAccount');
const currentAccount = getCurrentAccountID();
showToast(
'Downloading saved shortcuts from instance server…',
);
@ -1006,6 +1000,7 @@ function ImportExport({ shortcuts, onClose }) {
showToast('Unable to copy shortcuts');
}
}}
dir="auto"
/>
</p>
<p>
@ -1054,7 +1049,7 @@ function ImportExport({ shortcuts, onClose }) {
disabled={importUIState === 'cloud-uploading'}
onClick={async () => {
setImportUIState('cloud-uploading');
const currentAccount = store.session.get('currentAccount');
const currentAccount = getCurrentAccountID();
try {
const relationships =
await masto.v1.accounts.relationships.fetch({
@ -1065,16 +1060,16 @@ function ImportExport({ shortcuts, onClose }) {
const { note = '' } = relationship;
// const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`;
let newNote = '';
const settingsJSON = JSON.stringify({
v: '1', // version
dt: Date.now(), // datetime stamp
data: shortcutsStr, // shortcuts settings string
});
if (
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
note,
)
) {
const settingsJSON = JSON.stringify({
v: '1', // version
dt: Date.now(), // datetime stamp
data: shortcutsStr, // shortcuts settings string
});
newNote = note.replace(
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
`<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`,

View file

@ -2,8 +2,8 @@
position: fixed;
bottom: 16px;
bottom: max(16px, env(safe-area-inset-bottom));
left: 16px;
left: max(16px, env(safe-area-inset-left));
inset-inline-start: 16px;
inset-inline-start: max(16px, env(safe-area-inset-left));
padding: 16px;
background-color: var(--bg-faded-blur-color);
z-index: 101;
@ -34,9 +34,9 @@
@media (min-width: calc(40em + 56px + 8px)) {
#shortcuts-button {
right: 16px;
right: max(16px, env(safe-area-inset-right));
left: auto;
inset-inline-end: 16px;
inset-inline-end: max(16px, env(safe-area-inset-right));
inset-inline-start: auto;
top: 16px;
top: max(16px, env(safe-area-inset-top));
bottom: auto;

View file

@ -1,21 +1,23 @@
import './shortcuts.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { MenuDivider } from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useMemo, useRef } from 'preact/hooks';
import { useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import { SHORTCUTS_META } from '../components/shortcuts-settings';
import { api } from '../utils/api';
import { getLists } from '../utils/lists';
import states from '../utils/states';
import AsyncText from './AsyncText';
import Icon from './icon';
import Link from './link';
import Menu2 from './menu2';
import MenuLink from './menu-link';
import Menu2 from './menu2';
import SubMenu2 from './submenu2';
function Shortcuts() {
const { instance } = api();
@ -34,47 +36,48 @@ function Shortcuts() {
const menuRef = useRef();
const formattedShortcuts = useMemo(
() =>
shortcuts
.map((pin, i) => {
const { type, ...data } = pin;
if (!SHORTCUTS_META[type]) return null;
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
const hasLists = useRef(false);
const formattedShortcuts = shortcuts
.map((pin, i) => {
const { type, ...data } = pin;
if (!SHORTCUTS_META[type]) return null;
let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
if (typeof id === 'function') {
id = id(data, i);
}
if (typeof path === 'function') {
path = path(
{
...data,
instance: data.instance || instance,
},
i,
);
}
if (typeof title === 'function') {
title = title(data, i);
}
if (typeof subtitle === 'function') {
subtitle = subtitle(data, i);
}
if (typeof icon === 'function') {
icon = icon(data, i);
}
if (typeof id === 'function') {
id = id(data, i);
}
if (typeof path === 'function') {
path = path(
{
...data,
instance: data.instance || instance,
},
i,
);
}
if (typeof title === 'function') {
title = title(data, i);
}
if (typeof subtitle === 'function') {
subtitle = subtitle(data, i);
}
if (typeof icon === 'function') {
icon = icon(data, i);
}
return {
id,
path,
title,
subtitle,
icon,
};
})
.filter(Boolean),
[shortcuts],
);
if (id === 'lists') {
hasLists.current = true;
}
return {
id,
path,
title,
subtitle,
icon,
};
})
.filter(Boolean);
const navigate = useNavigate();
useHotkeys(['1', '2', '3', '4', '5', '6', '7', '8', '9'], (e, handler) => {
@ -88,6 +91,8 @@ function Shortcuts() {
}
});
const [lists, setLists] = useState([]);
return (
<div id="shortcuts">
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
@ -147,6 +152,11 @@ function Shortcuts() {
menuClassName="glass-menu shortcuts-menu"
gap={8}
position="anchor"
onMenuChange={(e) => {
if (e.open && hasLists.current) {
getLists().then(setLists);
}
}}
menuButton={
<button
type="button"
@ -171,6 +181,35 @@ function Shortcuts() {
}
>
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
if (id === 'lists') {
return (
<SubMenu2
menuClassName="glass-menu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon={icon} size="l" />
<span class="menu-grow">
<AsyncText>{title}</AsyncText>
</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
<MenuDivider />
{lists?.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</SubMenu2>
);
}
return (
<MenuLink
to={path}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
import { SubMenu } from '@szhsin/react-menu';
import { useRef } from 'preact/hooks';
export default function SubMenu2(props) {
const menuRef = useRef();
return (
<SubMenu
{...props}
instanceRef={menuRef}
// Test fix for bug; submenus not opening on Android
itemProps={{
onPointerMove: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
onPointerLeave: (e) => {
if (e.pointerType === 'touch') {
menuRef.current?.openMenu?.();
}
},
}}
/>
);
}

View file

@ -1,5 +1,11 @@
import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce';
@ -7,8 +13,10 @@ import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context';
import { filteredItems, isFiltered } from '../utils/filters';
import isRTL from '../utils/is-rtl';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import { isMediaFirstInstance } from '../utils/store-utils';
import { groupBoosts, groupContext } from '../utils/timeline-utils';
import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility';
@ -48,10 +56,11 @@ function Timeline({
filterContext,
showFollowedTags,
showReplyParent,
clearWhenRefresh,
}) {
const snapStates = useSnapshot(states);
const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default');
const [uiState, setUIState] = useState('start');
const [showMore, setShowMore] = useState(false);
const [showNew, setShowNew] = useState(false);
const [visible, setVisible] = useState(true);
@ -59,15 +68,20 @@ function Timeline({
console.debug('RENDER Timeline', id, refresh);
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
const allowGrouping = view !== 'media';
const loadItemsTS = useRef(0); // Ensures only one loadItems at a time
const loadItems = useDebouncedCallback(
(firstLoad) => {
setShowNew(false);
if (uiState === 'loading') return;
// if (uiState === 'loading') return;
setUIState('loading');
(async () => {
try {
const ts = (loadItemsTS.current = Date.now());
let { done, value } = await fetchItems(firstLoad);
if (ts !== loadItemsTS.current) return;
if (Array.isArray(value)) {
// Avoid grouping for pinned posts
const [pinnedPosts, otherPosts] = value.reduce(
@ -111,10 +125,10 @@ function Timeline({
}
})();
},
1500,
1_000,
{
leading: true,
trailing: false,
// trailing: false,
},
);
@ -200,8 +214,8 @@ function Timeline({
const oRef = useHotkeys(['enter', 'o'], () => {
// open active status
const activeItem = document.activeElement.closest(itemsSelector);
if (activeItem) {
const activeItem = document.activeElement;
if (activeItem?.matches(itemsSelector)) {
activeItem.click();
}
});
@ -209,17 +223,13 @@ function Timeline({
const showNewPostsIndicator =
items.length > 0 && uiState !== 'loading' && showNew;
const handleLoadNewPosts = useCallback(() => {
loadItems(true);
if (showNewPostsIndicator) loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}, [loadItems]);
const dotRef = useHotkeys('.', () => {
if (showNewPostsIndicator) {
handleLoadNewPosts();
}
});
}, [loadItems, showNewPostsIndicator]);
const dotRef = useHotkeys('.', handleLoadNewPosts);
// const {
// scrollDirection,
@ -268,9 +278,18 @@ function Timeline({
scrollableRef.current?.scrollTo({ top: 0 });
loadItems(true);
}, []);
const firstLoad = useRef(true);
useEffect(() => {
if (firstLoad.current) {
firstLoad.current = false;
return;
}
if (clearWhenRefresh && items?.length) {
loadItems.cancel?.();
setItems([]);
}
loadItems(true);
}, [refresh]);
}, [clearWhenRefresh, refresh]);
// useEffect(() => {
// if (reachStart) {
@ -359,14 +378,28 @@ function Timeline({
<FilterContext.Provider value={filterContext}>
<div
id={`${id}-page`}
class="deck-container"
class={`deck-container ${
mediaFirst ? 'deck-container-media-first' : ''
}`}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
dotRef.current = node;
}}
tabIndex="-1"
onClick={(e) => {
// If click on timeline item, unhide header
if (
headerRef.current &&
e.target.closest('.timeline-item, .timeline-item-alt')
) {
setTimeout(() => {
headerRef.current.hidden = false;
}, 250);
}
}}
>
<div class="timeline-deck deck">
<header
@ -435,6 +468,7 @@ function Timeline({
view={view}
showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent}
mediaFirst={mediaFirst}
/>
))}
{showMore &&
@ -446,14 +480,14 @@ function Timeline({
height: '20vh',
}}
>
<Status skeleton />
<Status skeleton mediaFirst={mediaFirst} />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
<Status skeleton mediaFirst={mediaFirst} />
</li>
</>
))}
@ -493,13 +527,14 @@ function Timeline({
/>
) : (
<li key={i}>
<Status skeleton />
<Status skeleton mediaFirst={mediaFirst} />
</li>
),
)}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
uiState !== 'error' &&
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
)}
{uiState === 'error' && (
<p class="ui-state">
@ -527,6 +562,7 @@ const TimelineItem = memo(
view,
showFollowedTags,
showReplyParent,
mediaFirst,
}) => {
console.debug('RENDER TimelineItem', status.id);
const { id: statusID, reblog, items, type, _pinned } = status;
@ -535,16 +571,18 @@ const TimelineItem = memo(
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
let title = '';
if (type === 'boosts') {
title = `${items.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) {
const fItems = filteredItems(items, filterContext);
let fItems = filteredItems(items, filterContext);
let title = '';
if (type === 'boosts') {
title = `${fItems.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (isCarousel) {
const filteredItemsIDs = new Set();
// Here, we don't hide filtered posts, but we sort them last
fItems.sort((a, b) => {
// if (a._filtered && !b._filtered) {
@ -555,6 +593,8 @@ const TimelineItem = memo(
// }
const aFiltered = isFiltered(a.filtered, filterContext);
const bFiltered = isFiltered(b.filtered, filterContext);
if (aFiltered) filteredItemsIDs.add(a.id);
if (bFiltered) filteredItemsIDs.add(b.id);
if (aFiltered && !bFiltered) {
return 1;
}
@ -563,11 +603,69 @@ const TimelineItem = memo(
}
return 0;
});
if (filteredItemsIDs.size >= 2) {
const GROUP_SIZE = 5;
// If 2 or more, group filtered items into one, limit to GROUP_SIZE in a group
const unfiltered = [];
const filtered = [];
fItems.forEach((item) => {
if (filteredItemsIDs.has(item.id)) {
filtered.push(item);
} else {
unfiltered.push(item);
}
});
const filteredItems = [];
for (let i = 0; i < filtered.length; i += GROUP_SIZE) {
filteredItems.push({
_grouped: true,
posts: filtered.slice(i, i + GROUP_SIZE),
});
}
fItems = unfiltered.concat(filteredItems);
}
return (
<li key={`timeline-${statusID}`} class="timeline-item-carousel">
<StatusCarousel title={title} class={`${type}-carousel`}>
{fItems.map((item) => {
const { id: statusID, reblog, _pinned } = item;
const { id: statusID, reblog, _pinned, _grouped } = item;
if (_grouped) {
return (
<li key={statusID} class="timeline-item-carousel-group">
{item.posts.map((item) => {
const { id: statusID, reblog, _pinned } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
if (_pinned) useItemID = false;
return (
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
/>
) : (
<Status
status={item}
instance={instance}
size="s"
/>
)}
</Link>
);
})}
</li>
);
}
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
@ -587,6 +685,7 @@ const TimelineItem = memo(
contentTextWeight
enableCommentHint
// allowFilters={allowFilters}
mediaFirst={mediaFirst}
/>
) : (
<Status
@ -596,6 +695,7 @@ const TimelineItem = memo(
contentTextWeight
enableCommentHint
// allowFilters={allowFilters}
mediaFirst={mediaFirst}
/>
)}
</Link>
@ -632,7 +732,11 @@ const TimelineItem = memo(
>
<Link class="status-link timeline-item" to={url}>
{showCompact ? (
<TimelineStatusCompact status={item} instance={instance} />
<TimelineStatusCompact
status={item}
instance={instance}
filterContext={filterContext}
/>
) : useItemID ? (
<Status
statusID={statusID}
@ -691,6 +795,7 @@ const TimelineItem = memo(
showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent}
// allowFilters={allowFilters}
mediaFirst={mediaFirst}
/>
) : (
<Status
@ -700,6 +805,7 @@ const TimelineItem = memo(
showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent}
// allowFilters={allowFilters}
mediaFirst={mediaFirst}
/>
)}
</Link>
@ -759,8 +865,11 @@ function StatusCarousel({ title, class: className, children }) {
class="small plain2"
// disabled={reachStart}
onClick={() => {
const left =
Math.min(320, carouselRef.current?.offsetWidth) *
(isRTL() ? 1 : -1);
carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth),
left,
behavior: 'smooth',
});
}}
@ -773,8 +882,11 @@ function StatusCarousel({ title, class: className, children }) {
class="small plain2"
// disabled={reachEnd}
onClick={() => {
const left =
Math.min(320, carouselRef.current?.offsetWidth) *
(isRTL() ? -1 : 1);
carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth),
left,
behavior: 'smooth',
});
}}
@ -804,11 +916,12 @@ function StatusCarousel({ title, class: className, children }) {
);
}
function TimelineStatusCompact({ status, instance }) {
function TimelineStatusCompact({ status, instance, filterContext }) {
const snapStates = useSnapshot(states);
const { id, visibility, language } = status;
const statusPeekText = statusPeek(status);
const sKey = statusKey(id, instance);
const filterInfo = isFiltered(status.filtered, filterContext);
return (
<article
class={`status compact-thread ${
@ -834,13 +947,24 @@ function TimelineStatusCompact({ status, instance }) {
lang={language}
dir="auto"
>
{statusPeekText}
{status.sensitive && status.spoilerText && (
{!!filterInfo ? (
<b
class="status-filtered-badge badge-meta horizontal"
title={filterInfo?.titlesStr || ''}
>
<span>Filtered</span>: <span>{filterInfo?.titlesStr || ''}</span>
</b>
) : (
<>
{' '}
<span class="spoiler-badge">
<Icon icon="eye-close" size="s" />
</span>
{statusPeekText}
{status.sensitive && status.spoilerText && (
<>
{' '}
<span class="spoiler-badge">
<Icon icon="eye-close" size="s" />
</span>
</>
)}
</>
)}
</div>

View file

@ -35,7 +35,7 @@
border-bottom: 0;
margin-bottom: -1px;
background-image: linear-gradient(
to top left,
to top var(--backward),
var(--bg-color) 50%,
var(--bg-faded-blur-color)
);
@ -44,12 +44,13 @@
.status-translation-block .translated-block {
border: 1px solid var(--outline-color);
line-height: 1.3;
border-radius: 0 8px 8px 8px;
border-radius: 8px;
border-start-start-radius: 0;
margin: 0;
padding: 8px;
background-color: var(--bg-color);
background-image: linear-gradient(
to bottom right,
to bottom var(--forward),
var(--bg-color),
var(--bg-faded-blur-color)
);

View file

@ -10,6 +10,7 @@ import localeCode2Text from '../utils/localeCode2Text';
import pmem from '../utils/pmem';
import Icon from './icon';
import LazyShazam from './lazy-shazam';
import Loader from './loader';
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
@ -76,6 +77,7 @@ function TranslationBlock({
onTranslate,
text = '',
mini,
autoDetected,
}) {
const targetLang = getTranslateTargetLanguage(true);
const [uiState, setUIState] = useState('default');
@ -142,23 +144,21 @@ function TranslationBlock({
detectedLang !== targetLangText
) {
return (
<div class="shazam-container">
<div class="shazam-container-inner">
<div class="status-translation-block-mini">
<Icon
icon="translate"
alt={`Auto-translated from ${sourceLangText}`}
/>
<output
lang={targetLang}
dir="auto"
title={pronunciationContent || ''}
>
{translatedContent}
</output>
</div>
<LazyShazam>
<div class="status-translation-block-mini">
<Icon
icon="translate"
alt={`Auto-translated from ${sourceLangText}`}
/>
<output
lang={targetLang}
dir="auto"
title={pronunciationContent || ''}
>
{translatedContent}
</output>
</div>
</div>
</LazyShazam>
);
}
return null;
@ -188,7 +188,9 @@ function TranslationBlock({
{uiState === 'loading'
? 'Translating…'
: sourceLanguage && sourceLangText && !detectedLang
? `Translate from ${sourceLangText}`
? autoDetected
? `Translate from ${sourceLangText} (auto-detected)`
: `Translate from ${sourceLangText}`
: `Translate`}
</span>
</button>

View file

@ -1,11 +1,12 @@
import './index.css';
import './app.css';
import './polyfills';
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import Compose from './components/compose';
import ComposeSuspense from './components/compose-suspense';
import { initStates } from './utils/states';
import useTitle from './utils/useTitle';
if (window.opener) {
@ -27,6 +28,10 @@ function App() {
: 'Compose',
);
useEffect(() => {
initStates();
}, []);
useEffect(() => {
if (uiState === 'closed') {
try {
@ -57,7 +62,7 @@ function App() {
console.debug('OPEN COMPOSE');
return (
<Compose
<ComposeSuspense
editStatus={editStatus}
replyToStatus={replyToStatus}
draftStatus={draftStatus}

View file

@ -1,4 +1,8 @@
{
"@mastodon/edit-media-attributes": ">=4.1",
"@mastodon/list-exclusive": ">=4.2"
"@mastodon/list-exclusive": ">=4.2",
"@mastodon/filtered-notifications": "~4.3 || >=4.3",
"@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3",
"@mastodon/trending-link-posts": "~4.3 || >=4.3",
"@mastodon/grouped-notifications": "~4.3 || >=4.3"
}

View file

@ -8,7 +8,7 @@
--sai-left: env(safe-area-inset-left);
--text-size: 16px;
--main-width: 40em;
--main-width: max(60dvw, 40em);
text-size-adjust: none;
--hairline-width: 1px;
--monospace-font: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono',
@ -16,6 +16,12 @@
--blue-color: royalblue;
--purple-color: blueviolet;
--purple-fg-color: color-mix(
in srgb-linear,
var(--purple-color) 60%,
var(--text-color) 40%
);
--purple-bg-color: color-mix(in srgb, var(--purple-color) 10%, transparent);
--green-color: darkgreen;
--orange-color: darkorange;
--orange-light-bg-color: color-mix(
@ -23,7 +29,18 @@
var(--orange-color) 20%,
transparent
);
--orange-fg-color: color-mix(
in srgb-linear,
var(--orange-color) 60%,
var(--text-color) 40%
);
--orange-bg-color: color-mix(in srgb, var(--orange-color) 10%, transparent);
--red-color: orangered;
--red-text-color: color-mix(
in srgb-linear,
var(--red-color) 60%,
var(--text-color) 40%
);
--red-bg-color: color-mix(in lch, var(--red-color) 40%, transparent);
--bg-color: #fff;
--bg-faded-color: #f0f2f5;
@ -91,6 +108,19 @@
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--min-dimension: 88px;
--forward: right;
--backward: left;
--to-forward: to right;
--to-backward: to left;
&:dir(rtl) {
--forward: left;
--backward: right;
--to-forward: to left;
--to-backward: to right;
}
}
@media (min-resolution: 2dppx) {
@ -227,7 +257,7 @@ button[hidden] {
}
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
cursor: pointer;
filter: brightness(1.2);
filter: brightness(1.05);
}
:is(button, .button):not(:disabled, .disabled):active {
filter: brightness(0.8);
@ -328,6 +358,7 @@ button[hidden] {
}
input[type='text'],
input[type='search'],
textarea,
select {
color: var(--text-color);
@ -337,6 +368,7 @@ select {
border-radius: 4px;
}
input[type='text']:focus,
input[type='search']:focus,
textarea:focus,
select:focus {
border-color: var(--outline-color);
@ -352,16 +384,22 @@ textarea:disabled {
background-color: var(--bg-faded-color);
}
:is(input[type='text'], textarea, select).block {
:is(input[type='text'], input[type='search'], textarea, select).block {
display: block;
width: 100%;
}
button.small {
:is(button, .button).small {
font-size: 90%;
padding: 4px 8px;
}
.button.centered {
display: inline-flex;
justify-content: center;
align-items: center;
}
select.plain {
border: 0;
background-color: transparent;
@ -415,6 +453,11 @@ kbd {
display: initial;
}
.bidi-isolate {
direction: initial;
unicode-bidi: isolate;
}
/* KEYFRAMES */
@keyframes appear {
@ -526,3 +569,9 @@ kbd {
.shazam-container-horizontal[hidden] {
grid-template-columns: 0fr;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -1,10 +1,10 @@
import './index.css';
import './cloak-mode.css';
import './polyfills';
// Polyfill needed for Firefox < 122
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
import '@formatjs/intl-segmenter/polyfill';
// import '@formatjs/intl-segmenter/polyfill';
import { render } from 'preact';
import { HashRouter } from 'react-router-dom';
@ -14,19 +14,6 @@ if (import.meta.env.DEV) {
import('preact/debug');
}
// AbortSignal.timeout polyfill
// Temporary fix from https://github.com/mo/abortcontroller-polyfill/issues/73#issuecomment-1541180943
// Incorrect implementation, but should be good enough for now
if ('AbortSignal' in window) {
AbortSignal.timeout =
AbortSignal.timeout ||
((duration) => {
const controller = new AbortController();
setTimeout(() => controller.abort(), duration);
return controller.signal;
});
}
render(
<HashRouter>
<App />

View file

@ -6,6 +6,7 @@ import {
useRef,
useState,
} from 'preact/hooks';
import punycode from 'punycode/';
import { useParams, useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -18,8 +19,8 @@ import Timeline from '../components/timeline';
import { api } from '../utils/api';
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import states, { saveStatus } from '../utils/states';
import { isMediaFirstInstance } from '../utils/store-utils';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -67,6 +68,8 @@ function AccountStatuses() {
searchOffsetRef.current = 0;
}, allSearchParams);
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
const sameCurrentInstance = useMemo(
() => instance === currentInstance,
[instance, currentInstance],
@ -150,7 +153,7 @@ function AccountStatuses() {
}
}
const results = [];
let results = [];
if (firstLoad) {
const { value } = await masto.v1.accounts
.$select(id)
@ -185,12 +188,32 @@ function AccountStatuses() {
limit: LIMIT,
exclude_replies: excludeReplies,
exclude_reblogs: excludeBoosts,
only_media: media,
only_media: media || undefined,
tagged,
});
}
const { value, done } = await accountStatusesIterator.current.next();
if (value?.length) {
// Check if value is same as pinned post (results)
// If the index for every post is the same, means API might not support pinned posts
if (results.length) {
let pinnedStatusesIds = [];
if (results[0]?.type === 'pinned') {
pinnedStatusesIds = results[0].id;
} else {
pinnedStatusesIds = results
.filter((status) => status._pinned)
.map((status) => status.id);
}
const containsAllPinned = pinnedStatusesIds.every((postId) =>
value.some((status) => status.id === postId),
);
if (containsAllPinned) {
// Remove pinned posts
results = [];
}
}
results.push(...value);
value.forEach((item) => {
@ -206,8 +229,12 @@ function AccountStatuses() {
const [featuredTags, setFeaturedTags] = useState([]);
useTitle(
account?.acct
? `${account?.displayName ? account.displayName + ' ' : ''}@${
account.acct
? `${
account?.displayName
? `${account.displayName} (${/@/.test(account.acct) ? '' : '@'}${
account.acct
})`
: `${/@/.test(account.acct) ? '' : '@'}${account.acct}`
}${
!excludeReplies
? ' (+ Replies)'
@ -245,17 +272,21 @@ function AccountStatuses() {
} catch (e) {
console.error(e);
}
try {
const featuredTags = await masto.v1.accounts
.$select(id)
.featuredTags.list();
console.log({ featuredTags });
setFeaturedTags(featuredTags);
} catch (e) {
console.error(e);
// No need, because the whole filter bar is hidden
// TODO: Revisit this
if (!mediaFirst) {
try {
const featuredTags = await masto.v1.accounts
.$select(id)
.featuredTags.list();
console.log({ featuredTags });
setFeaturedTags(featuredTags);
} catch (e) {
console.error(e);
}
}
})();
}, [id]);
}, [id, mediaFirst]);
const { displayName, acct, emojis } = account || {};
@ -274,95 +305,126 @@ function AccountStatuses() {
authenticated={authenticated}
standalone
/>
<div
class="filter-bar"
ref={filterBarRef}
style={{
position: 'relative',
}}
>
{filtered ? (
{!mediaFirst && (
<div
class="filter-bar"
ref={filterBarRef}
style={{
position: 'relative',
}}
>
{filtered ? (
<Link
to={`/${instance}/a/${id}`}
class="insignificant filter-clear"
title="Clear filters"
key="clear-filters"
>
<Icon icon="x" size="l" />
</Link>
) : (
<Icon icon="filter" class="insignificant" size="l" />
)}
<Link
to={`/${instance}/a/${id}`}
class="insignificant filter-clear"
title="Clear filters"
key="clear-filters"
>
<Icon icon="x" size="l" />
</Link>
) : (
<Icon icon="filter" class="insignificant" size="l" />
)}
<Link
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
onClick={() => {
if (excludeReplies) {
showToast('Showing post with replies');
}
}}
class={excludeReplies ? '' : 'is-active'}
>
+ Replies
</Link>
<Link
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
onClick={() => {
if (!excludeBoosts) {
showToast('Showing posts without boosts');
}
}}
class={!excludeBoosts ? '' : 'is-active'}
>
- Boosts
</Link>
<Link
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
onClick={() => {
if (!media) {
showToast('Showing posts with media');
}
}}
class={media ? 'is-active' : ''}
>
Media
</Link>
{featuredTags.map((tag) => (
<Link
key={tag.id}
to={`/${instance}/a/${id}${
tagged === tag.name
? ''
: `?tagged=${encodeURIComponent(tag.name)}`
}`}
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
onClick={() => {
if (tagged !== tag.name) {
showToast(`Showing posts tagged with #${tag.name}`);
if (excludeReplies) {
showToast('Showing post with replies');
}
}}
class={tagged === tag.name ? 'is-active' : ''}
class={excludeReplies ? '' : 'is-active'}
>
<span>
<span class="more-insignificant">#</span>
{tag.name}
</span>
{
// The count differs based on instance 😅
}
{/* <span class="filter-count">{tag.statusesCount}</span> */}
+ Replies
</Link>
))}
{searchEnabled &&
(supportsInputMonth ? (
<label class={`filter-field ${month ? 'is-active' : ''}`}>
<Icon icon="month" size="l" />
<input
type="month"
<Link
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
onClick={() => {
if (!excludeBoosts) {
showToast('Showing posts without boosts');
}
}}
class={!excludeBoosts ? '' : 'is-active'}
>
- Boosts
</Link>
<Link
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
onClick={() => {
if (!media) {
showToast('Showing posts with media');
}
}}
class={media ? 'is-active' : ''}
>
Media
</Link>
{featuredTags.map((tag) => (
<Link
key={tag.id}
to={`/${instance}/a/${id}${
tagged === tag.name
? ''
: `?tagged=${encodeURIComponent(tag.name)}`
}`}
onClick={() => {
if (tagged !== tag.name) {
showToast(`Showing posts tagged with #${tag.name}`);
}
}}
class={tagged === tag.name ? 'is-active' : ''}
>
<span>
<span class="more-insignificant">#</span>
{tag.name}
</span>
{
// The count differs based on instance 😅
}
{/* <span class="filter-count">{tag.statusesCount}</span> */}
</Link>
))}
{searchEnabled &&
(supportsInputMonth ? (
<label class={`filter-field ${month ? 'is-active' : ''}`}>
<Icon icon="month" size="l" />
<input
type="month"
disabled={!account?.acct}
value={month || ''}
min={MIN_YEAR_MONTH}
max={new Date().toISOString().slice(0, 7)}
onInput={(e) => {
const { value, validity } = e.currentTarget;
if (!validity.valid) return;
setSearchParams(
value
? {
month: value,
}
: {},
);
const [year, month] = value.split('-');
const monthIndex = parseInt(month, 10) - 1;
const date = new Date(year, monthIndex);
showToast(
`Showing posts in ${date.toLocaleString('default', {
month: 'long',
year: 'numeric',
})}`,
);
}}
/>
</label>
) : (
// Fallback to <select> for month and <input type="number"> for year
<MonthPicker
class={`filter-field ${month ? 'is-active' : ''}`}
disabled={!account?.acct}
value={month || ''}
min={MIN_YEAR_MONTH}
max={new Date().toISOString().slice(0, 7)}
onInput={(e) => {
const { value, validity } = e.currentTarget;
const { value, validity } = e;
if (!validity.valid) return;
setSearchParams(
value
@ -371,40 +433,11 @@ function AccountStatuses() {
}
: {},
);
const [year, month] = value.split('-');
const monthIndex = parseInt(month, 10) - 1;
const date = new Date(year, monthIndex);
showToast(
`Showing posts in ${date.toLocaleString('default', {
month: 'long',
year: 'numeric',
})}`,
);
}}
/>
</label>
) : (
// Fallback to <select> for month and <input type="number"> for year
<MonthPicker
class={`filter-field ${month ? 'is-active' : ''}`}
disabled={!account?.acct}
value={month || ''}
min={MIN_YEAR_MONTH}
max={new Date().toISOString().slice(0, 7)}
onInput={(e) => {
const { value, validity } = e;
if (!validity.valid) return;
setSearchParams(
value
? {
month: value,
}
: {},
);
}}
/>
))}
</div>
))}
</div>
)}
</>
);
}, [
@ -433,7 +466,7 @@ function AccountStatuses() {
const accountInstance = useMemo(() => {
if (!account?.url) return null;
const domain = new URL(account.url).hostname;
const domain = URL.parse(account.url).hostname;
return domain;
}, [account]);
const sameInstance = instance === accountInstance;
@ -467,7 +500,7 @@ function AccountStatuses() {
errorText="Unable to load posts"
fetchItems={fetchAccountStatuses}
useItemID
view={media ? 'media' : undefined}
view={media || mediaFirst ? 'media' : undefined}
boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart}
refresh={[
@ -512,7 +545,13 @@ function AccountStatuses() {
>
<Icon icon="transfer" />{' '}
<small class="menu-double-lines">
Switch to account's instance (<b>{accountInstance}</b>)
Switch to account's instance{' '}
{accountInstance ? (
<>
{' '}
(<b>{punycode.toUnicode(accountInstance)}</b>)
</>
) : null}
</small>
</MenuItem>
{!sameCurrentInstance && (

View file

@ -28,7 +28,7 @@
}
#accounts-container section > ul > li .current {
margin-right: 8px;
margin-inline-end: 8px;
color: var(--green-color);
opacity: 0.1;
}
@ -47,7 +47,7 @@
}
#accounts-container .avatar {
margin-right: 8px;
margin-inline-end: 8px;
}
#accounts-container .accounts-list li div {

View file

@ -7,18 +7,19 @@ import { useReducer } from 'preact/hooks';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
import Link from '../components/link';
import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
import Menu2 from '../components/menu2';
import NameText from '../components/name-text';
import { api } from '../utils/api';
import states from '../utils/states';
import store from '../utils/store';
import { getCurrentAccountID, setCurrentAccountID } from '../utils/store-utils';
function Accounts({ onClose }) {
const { masto } = api();
// Accounts
const accounts = store.local.getJSON('accounts');
const currentAccount = store.session.get('currentAccount');
const currentAccount = getCurrentAccountID();
const moreThanOneAccount = accounts.length > 1;
const [_, reload] = useReducer((x) => x + 1, 0);
@ -81,7 +82,7 @@ function Accounts({ onClose }) {
if (isCurrent) {
states.showAccount = `${account.info.username}@${account.instanceURL}`;
} else {
store.session.set('currentAccount', account.info.id);
setCurrentAccountID(account.info.id);
location.reload();
}
}}

View file

@ -111,7 +111,7 @@
margin-bottom: 8px;
align-items: center;
gap: 8px;
text-align: left;
text-align: start;
justify-content: space-between;
a {
@ -146,6 +146,9 @@
input[type='range'] {
accent-color: var(--link-color);
direction: rtl;
&:dir(rtl) {
direction: ltr;
}
}
}
@ -251,7 +254,7 @@
overflow-y: hidden;
max-width: 100%;
mask-image: linear-gradient(
to right,
var(--to-forward),
transparent,
black 16px calc(100% - 16px),
transparent
@ -315,7 +318,7 @@
.count {
font-size: 70%;
margin-left: 4px;
margin-inline-start: 4px;
background-color: var(--bg-color);
padding: 4px 6px;
border-radius: 12px;
@ -386,7 +389,7 @@
.count {
position: absolute;
right: -4px;
inset-inline-end: -4px;
top: -4px;
font-size: 10px;
background-color: var(--bg-color);
@ -406,7 +409,7 @@
overflow: hidden;
text-align: center;
mask-image: linear-gradient(
to right,
var(--to-forward),
black calc(100% - 0.5em),
transparent 100%
);
@ -478,13 +481,13 @@
> li {
&:first-child > a {
border-top-left-radius: var(--corner-radius);
border-top-right-radius: var(--corner-radius);
border-start-start-radius: var(--corner-radius);
border-start-end-radius: var(--corner-radius);
}
&:last-child > a {
border-bottom-left-radius: var(--corner-radius);
border-bottom-right-radius: var(--corner-radius);
border-end-start-radius: var(--corner-radius);
border-end-end-radius: var(--corner-radius);
}
}
}
@ -502,13 +505,13 @@
@media (min-width: 40em) {
&.separator + li a {
border-top-left-radius: var(--corner-radius);
border-top-right-radius: var(--corner-radius);
border-start-start-radius: var(--corner-radius);
border-start-end-radius: var(--corner-radius);
}
&:has(+ .separator) a {
border-bottom-left-radius: var(--corner-radius);
border-bottom-right-radius: var(--corner-radius);
border-end-start-radius: var(--corner-radius);
border-end-end-radius: var(--corner-radius);
}
}
@ -525,10 +528,13 @@
background-color: var(--bg-faded-color);
box-shadow: 0 8px 16px -8px var(--drop-shadow-color),
inset 0 1px var(--bg-color);
outline: 1px solid var(--outline-color);
text-shadow: 0 1px var(--bg-color);
}
&:hover:not(:focus-visible) {
outline: 1px solid var(--outline-color);
}
&:active {
filter: brightness(0.95);
box-shadow: none;
@ -569,8 +575,12 @@
'author meta'
'content content';
/* align-items: center; */
--bg-gradient-angle: 140deg;
&:dir(rtl) {
--bg-gradient-angle: -140deg;
}
background-image: linear-gradient(
140deg,
var(--bg-gradient-angle),
var(--post-bg-color),
transparent min(160px, 50%)
);
@ -611,7 +621,7 @@
}
&.visibility-direct {
--yellow-stripes: repeating-linear-gradient(
-45deg,
135deg,
var(--reply-to-faded-color),
var(--reply-to-faded-color) 10px,
var(--reply-to-faded-color) 10px,
@ -626,10 +636,24 @@
gap: 4px;
align-items: center;
flex-shrink: 0;
min-height: 24px;
.icon {
> .avatar {
outline: 1px solid var(--bg-blur-color);
}
> .avatar ~ .avatar {
margin-inline-start: -8px;
}
> .icon {
color: var(--reblog-color);
}
> .name-text {
opacity: 0.75;
filter: grayscale(0.75);
}
}
.post-author {
@ -638,7 +662,7 @@
white-space: nowrap;
overflow: hidden;
mask-image: linear-gradient(
to right,
var(--to-forward),
black calc(100% - 1em),
transparent 100%
);
@ -796,6 +820,10 @@
text-decoration: none;
text-decoration-color: transparent;
color: var(--link-text-color);
span {
text-decoration: none;
}
}
}
@ -854,6 +882,7 @@
position: relative;
z-index: 1;
animation: position-object 5s ease-in-out 5;
animation-duration: var(--anim-duration, 5s);
/* @media (min-width: 40em) and (min-height: 600px) {
transform: scale(3);
@ -865,12 +894,15 @@
&:has(.post-peek-media),
.post-peek-media:first-child img {
transform-origin: left center;
:dir(rtl) & {
transform-origin: right center;
}
}
}
@media (max-width: 480px) {
.post-peek-media:not(:last-child) {
margin-right: -24px;
margin-inline-end: -24px;
box-shadow: 0 0 0 2px var(--bg-blur-color);
}
/* Max 10, I'm not going to code more than this */
@ -1077,6 +1109,20 @@
dd {
margin-block-end: 1em;
margin-inline: 1em;
+ dd {
margin-block-start: -0.9em;
}
}
}
kbd {
border-radius: 4px;
display: inline-block;
padding: 0.2em 0.3em;
margin: 1px 0;
line-height: 1;
border: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
}
}

View file

@ -13,6 +13,7 @@ import {
useRef,
useState,
} from 'preact/hooks';
import punycode from 'punycode/';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSearchParams } from 'react-router-dom';
import { uid } from 'uid/single';
@ -32,14 +33,15 @@ import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import db from '../utils/db';
import emojifyText from '../utils/emojify-text';
import { isFiltered } from '../utils/filters';
import getHTMLText from '../utils/getHTMLText';
import htmlContentLength from '../utils/html-content-length';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import store from '../utils/store';
import { getCurrentAccountNS } from '../utils/store-utils';
import { getCurrentAccountID, getCurrentAccountNS } from '../utils/store-utils';
import supports from '../utils/supports';
import { assignFollowedTags } from '../utils/timeline-utils';
import useTitle from '../utils/useTitle';
@ -111,10 +113,12 @@ function Catchup() {
const [showTopLinks, setShowTopLinks] = useState(false);
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
return getCurrentAccountID();
}, []);
const isSelf = (accountID) => accountID === currentAccount;
const supportsPixelfed = supports('@pixelfed/home-include-reblogs');
async function fetchHome({ maxCreatedAt }) {
const maxCreatedAtDate = maxCreatedAt ? new Date(maxCreatedAt) : null;
console.debug('fetchHome', maxCreatedAtDate);
@ -122,6 +126,13 @@ function Catchup() {
const homeIterator = masto.v1.timelines.home.list({ limit: 40 });
mainloop: while (true) {
try {
if (supportsPixelfed && homeIterator.nextParams) {
if (typeof homeIterator.nextParams === 'string') {
homeIterator.nextParams += '&include_reblogs=true';
} else {
homeIterator.nextParams.include_reblogs = true;
}
}
const results = await homeIterator.next();
const { value } = results;
if (value?.length) {
@ -191,6 +202,7 @@ function Catchup() {
const [posts, setPosts] = useState([]);
const catchupRangeRef = useRef();
const catchupLastRef = useRef();
const NS = useMemo(() => getCurrentAccountNS(), []);
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
const now = Date.now();
@ -429,9 +441,28 @@ function Catchup() {
return postFilterMatches;
});
// Deduplicate boosts
const boostedPosts = {};
filteredPosts.forEach((post) => {
if (post.reblog) {
if (boostedPosts[post.reblog.id]) {
if (boostedPosts[post.reblog.id].__BOOSTERS) {
boostedPosts[post.reblog.id].__BOOSTERS.add(post.account);
} else {
boostedPosts[post.reblog.id].__BOOSTERS = new Set([post.account]);
}
post.__HIDDEN = true;
} else {
boostedPosts[post.reblog.id] = post;
}
}
});
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
filteredPosts = filteredPosts.filter(
(post) => post.account.id === selectedAuthor,
(post) =>
post.account.id === selectedAuthor ||
[...(post.__BOOSTERS || [])].find((a) => a.id === selectedAuthor),
);
}
@ -459,39 +490,41 @@ function Catchup() {
authorCountsList.forEach((authorID, index) => {
authorIndices[authorID] = index;
});
return filteredPosts.sort((a, b) => {
if (groupBy === 'account') {
const aAccountID = a.account.id;
const bAccountID = b.account.id;
const aIndex = authorIndices[aAccountID];
const bIndex = authorIndices[bAccountID];
const order = aIndex - bIndex;
if (order !== 0) {
return order;
return filteredPosts
.filter((post) => !post.__HIDDEN)
.sort((a, b) => {
if (groupBy === 'account') {
const aAccountID = a.account.id;
const bAccountID = b.account.id;
const aIndex = authorIndices[aAccountID];
const bIndex = authorIndices[bAccountID];
const order = aIndex - bIndex;
if (order !== 0) {
return order;
}
}
}
if (sortBy !== 'createdAt') {
a = a.reblog || a;
b = b.reblog || b;
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
return a.createdAt > b.createdAt ? 1 : -1;
if (sortBy !== 'createdAt') {
a = a.reblog || a;
b = b.reblog || b;
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
return a.createdAt > b.createdAt ? 1 : -1;
}
}
if (sortBy === 'density') {
const aDensity = postDensity(a);
const bDensity = postDensity(b);
if (sortOrder === 'asc') {
return aDensity > bDensity ? 1 : -1;
} else {
return bDensity > aDensity ? 1 : -1;
}
}
}
if (sortBy === 'density') {
const aDensity = postDensity(a);
const bDensity = postDensity(b);
if (sortOrder === 'asc') {
return aDensity > bDensity ? 1 : -1;
return a[sortBy] > b[sortBy] ? 1 : -1;
} else {
return bDensity > aDensity ? 1 : -1;
return b[sortBy] > a[sortBy] ? 1 : -1;
}
}
if (sortOrder === 'asc') {
return a[sortBy] > b[sortBy] ? 1 : -1;
} else {
return b[sortBy] > a[sortBy] ? 1 : -1;
}
});
});
}, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]);
const prevGroup = useRef(null);
@ -589,41 +622,46 @@ function Catchup() {
authors,
]);
const prevSelectedAuthorMissing = useRef(false);
useEffect(() => {
// console.log({
// prevSelectedAuthorMissing,
// selectedAuthor,
// authors,
// });
let timer;
if (selectedAuthor) {
if (authors[selectedAuthor]) {
if (prevSelectedAuthorMissing.current) {
timer = setTimeout(() => {
authorsListParent.current
.querySelector(`[data-author="${selectedAuthor}"]`)
?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}, 500);
prevSelectedAuthorMissing.current = false;
// Check if author is visible and within the scrollable area viewport
const authorElement = authorsListParent.current.querySelector(
`[data-author="${selectedAuthor}"]`,
);
const scrollableRect =
authorsListParent.current?.getBoundingClientRect();
const authorRect = authorElement?.getBoundingClientRect();
console.log({
sLeft: scrollableRect.left,
sRight: scrollableRect.right,
aLeft: authorRect.left,
aRight: authorRect.right,
});
if (
authorRect.left < scrollableRect.left ||
authorRect.right > scrollableRect.right
) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
} else if (authorRect.top < 0) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
});
}
} else {
prevSelectedAuthorMissing.current = true;
}
}
return () => {
clearTimeout(timer);
};
}, [selectedAuthor, authors]);
const [showHelp, setShowHelp] = useState(false);
const itemsSelector = '.catchup-list > li > a';
useHotkeys(
const jRef = useHotkeys(
'j',
() => {
const activeItem = document.activeElement.closest(itemsSelector);
@ -663,12 +701,121 @@ function Catchup() {
},
{
preventDefault: true,
ignoreModifiers: true,
},
);
const kRef = useHotkeys(
'k',
() => {
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let prevItem = allItems[activeItemIndex - 1];
if (prevItem) {
prevItem.focus();
prevItem.scrollIntoView({
block: 'center',
inline: 'center',
behavior: 'smooth',
});
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
}
}
},
{
preventDefault: true,
ignoreModifiers: true,
},
);
const hlRef = useHotkeys(
'h, l',
(_, handler) => {
// Go next/prev selectedAuthor in authorCountsList list
const key = handler.keys[0];
if (selectedAuthor) {
const index = authorCountsList.indexOf(selectedAuthor);
if (key === 'h') {
if (index > 0 && index < authorCountsList.length) {
setSelectedAuthor(authorCountsList[index - 1]);
scrollableRef.current?.focus();
}
} else if (key === 'l') {
if (index < authorCountsList.length - 1 && index >= 0) {
setSelectedAuthor(authorCountsList[index + 1]);
scrollableRef.current?.focus();
}
}
} else if (key === 'l') {
setSelectedAuthor(authorCountsList[0]);
scrollableRef.current?.focus();
}
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
const escRef = useHotkeys(
'esc',
() => {
setSelectedAuthor(null);
scrollableRef.current?.focus();
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
const dotRef = useHotkeys(
'.',
() => {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
return (
<div
ref={scrollableRef}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
hlRef.current = node;
escRef.current = node;
}}
id="catchup-page"
class="deck-container"
tabIndex="-1"
@ -790,7 +937,15 @@ function Catchup() {
type="button"
onClick={() => {
if (range < RANGES[RANGES.length - 1].value) {
const duration = range * 60 * 60 * 1000;
let duration;
if (
range === RANGES[RANGES.length - 1].value &&
catchupLastRef.current?.checked
) {
duration = Date.now() - lastCatchupEndAt;
} else {
duration = range * 60 * 60 * 1000;
}
handleCatchupClick({ duration });
} else {
handleCatchupClick();
@ -800,11 +955,25 @@ function Catchup() {
Catch up
</button>
</div>
{lastCatchupRange && range > lastCatchupRange && (
{lastCatchupRange && range > lastCatchupRange ? (
<p class="catchup-info">
<Icon icon="info" /> Overlaps with your last catch-up
</p>
)}
) : range === RANGES[RANGES.length - 1].value &&
lastCatchupEndAt ? (
<p class="catchup-info">
<label>
<input
type="checkbox"
switch
checked
ref={catchupLastRef}
/>{' '}
Until the last catch-up (
{dtf.format(new Date(lastCatchupEndAt))})
</label>
</p>
) : null}
<p class="insignificant">
<small>
Note: your instance might only show a maximum of 800 posts in
@ -821,10 +990,12 @@ function Catchup() {
<Link to={`/catchup?id=${pc.id}`}>
<Icon icon="history2" />{' '}
<span>
{formatRange(
new Date(pc.startAt),
new Date(pc.endAt),
)}
{pc.startAt
? dtf.formatRange(
new Date(pc.startAt),
new Date(pc.endAt),
)
: `… – ${dtf.format(new Date(pc.endAt))}`}
</span>
</Link>{' '}
<span>
@ -876,7 +1047,7 @@ function Catchup() {
{posts.length > 0 && (
<p>
<b class="ib">
{formatRange(
{dtf.formatRange(
new Date(posts[0].createdAt),
new Date(posts[posts.length - 1].createdAt),
)}
@ -939,9 +1110,11 @@ function Catchup() {
height,
publishedAt,
} = card;
const domain = new URL(url).hostname
.replace(/^www\./, '')
.replace(/\/$/, '');
const domain = punycode.toUnicode(
URL.parse(url)
.hostname.replace(/^www\./, '')
.replace(/\/$/, ''),
);
let accentColor;
if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash);
@ -997,7 +1170,12 @@ function Catchup() {
)}
</div>
{!!title && (
<h1 class="title" lang={language} dir="auto">
<h1
class="title"
lang={language}
dir="auto"
title={title}
>
{title}
</h1>
)}
@ -1007,6 +1185,7 @@ function Catchup() {
class="description"
lang={language}
dir="auto"
title={description}
>
{description}
</p>
@ -1078,6 +1257,10 @@ function Catchup() {
}
onChange={() => {
setSelectedFilterCategory(label);
if (label === 'Boosts') {
setSortBy('reblogsCount');
setGroupBy(null);
}
// setSelectedAuthor(null);
}}
/>
@ -1120,7 +1303,7 @@ function Catchup() {
authors[author].avatarStatic || authors[author].avatar
}
size="xxl"
alt={`${authors[author].displayName} (@${authors[author].username})`}
alt={`${authors[author].displayName} (@${authors[author].acct})`}
/>{' '}
<span class="count">{authorCounts[author]}</span>
<span class="username">{authors[author].username}</span>
@ -1330,6 +1513,25 @@ function Catchup() {
Posts are grouped by authors, sorted by posts count per
author.
</dd>
<dt>Keyboard shortcuts</dt>
<dd>
<kbd>j</kbd>: Next post
</dd>
<dd>
<kbd>k</kbd>: Previous post
</dd>
<dd>
<kbd>l</kbd>: Next author
</dd>
<dd>
<kbd>h</kbd>: Previous author
</dd>
<dd>
<kbd>Enter</kbd>: Open post details
</dd>
<dd>
<kbd>.</kbd>: Scroll to top
</dd>
</dl>
</main>
</div>
@ -1351,6 +1553,7 @@ const PostLine = memo(
_followedTags: isFollowedTags,
_filtered: filterInfo,
visibility,
__BOOSTERS,
} = post;
const isReplyTo = inReplyToId && inReplyToAccountId !== account.id;
const isFiltered = !!filterInfo;
@ -1384,7 +1587,12 @@ const PostLine = memo(
<Avatar
url={account.avatarStatic || account.avatar}
squircle={account.bot}
/>{' '}
/>
{__BOOSTERS?.size > 0
? [...__BOOSTERS].map((b) => (
<Avatar url={b.avatarStatic || b.avatar} squircle={b.bot} />
))
: ''}{' '}
<Icon icon="rocket" />{' '}
{/* <Avatar
url={reblog.account.avatarStatic || reblog.account.avatar}
@ -1483,55 +1691,70 @@ function PostPeek({ post, filterInfo }) {
} = post;
const isThread =
(inReplyToId && inReplyToAccountId === account.id) || !!_thread;
const showMedia = !spoilerText && !sensitive;
const postText = content ? getHTMLText(content) : '';
const readingExpandSpoilers = useMemo(() => {
const prefs = store.account.get('preferences') || {};
return !!prefs['reading:expand:spoilers'];
}, []);
// const readingExpandSpoilers = true;
const showMedia = readingExpandSpoilers || (!spoilerText && !sensitive);
const postText = content ? statusPeek(post) : '';
const showPostContent = !spoilerText || readingExpandSpoilers;
return (
<div class="post-peek" title={!spoilerText ? postText : ''}>
<span class="post-peek-content">
{isThread && !showPostContent && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
{!!filterInfo ? (
<>
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
<span class="post-peek-filtered">
Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''}
</span>
</>
) : !!spoilerText ? (
<>
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
<span class="post-peek-spoiler">
<Icon icon="eye-close" /> {spoilerText}
</span>
</>
<span class="post-peek-filtered">
Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''}
</span>
) : (
<div class="post-peek-html">
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
<>
{!!spoilerText && (
<span class="post-peek-spoiler">
<Icon
icon={`${readingExpandSpoilers ? 'eye-open' : 'eye-close'}`}
/>{' '}
{spoilerText}
</span>
)}
{content ? (
<div
dangerouslySetInnerHTML={{
__html: emojifyText(content, emojis),
}}
/>
) : mediaAttachments?.length === 1 &&
mediaAttachments[0].description ? (
<>
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
<div>{mediaAttachments[0].description}</div>
</>
) : null}
</div>
{showPostContent && (
<div class="post-peek-html">
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
{!!content && (
<div
dangerouslySetInnerHTML={{
__html: emojifyText(content, emojis),
}}
/>
)}
{!!poll?.options?.length &&
poll.options.map((o) => (
<div>
{poll.multiple ? '▪️' : '•'} {o.title}
</div>
))}
{!content &&
mediaAttachments?.length === 1 &&
mediaAttachments[0].description && (
<>
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
<div>{mediaAttachments[0].description}</div>
</>
)}
</div>
)}
</>
)}
</span>
{!filterInfo && (
@ -1546,6 +1769,12 @@ function PostPeek({ post, filterInfo }) {
? mediaAttachments.map((m) => {
const mediaURL = m.previewUrl || m.url;
const remoteMediaURL = m.previewRemoteUrl || m.remoteUrl;
const width = m.meta?.original
? m.meta.original.width
: m.meta?.small?.width || m.meta?.original?.width;
const height = m.meta?.original
? m.meta.original.height
: m.meta?.small?.height || m.meta?.original?.height;
return (
<span key={m.id} class="post-peek-media">
{{
@ -1563,6 +1792,12 @@ function PostPeek({ post, filterInfo }) {
e.target.src = remoteMediaURL;
}
}}
style={{
'--anim-duration': `${Math.min(
Math.max(Math.max(width, height) / 100, 5),
120,
)}s`,
}}
/>
) : (
<span class="post-peek-faux-media">🖼</span>
@ -1625,6 +1860,18 @@ function PostPeek({ post, filterInfo }) {
card.title || card.description || card.imageDescription
}
loading="lazy"
style={{
'--anim-duration':
card.width &&
card.height &&
`${Math.min(
Math.max(
Math.max(card.width, card.height) / 100,
5,
),
120,
)}s`,
}}
/>
) : (
<span class="post-peek-faux-media">🔗</span>
@ -1668,9 +1915,6 @@ const dtf = new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: 'numeric',
});
function formatRange(startDate, endDate) {
return dtf.formatRange(startDate, endDate);
}
function binByTime(data, key, numBins) {
// Extract dates from data objects

149
src/pages/filters.css Normal file
View file

@ -0,0 +1,149 @@
#filters-page {
.filters-list {
list-style: none;
padding: 0;
margin: 0;
li {
padding: 8px 16px;
border-bottom: var(--hairline-width) solid var(--outline-color);
display: flex;
align-items: center;
justify-content: space-between;
}
h2 {
font-weight: 500;
margin: 0;
padding: 0;
font-size: 1em;
}
}
}
#filters-add-edit-modal {
.filter-form-row {
margin-bottom: 16px;
+ .filter-form-row {
margin-top: 16px;
border-top: 1px solid var(--outline-color);
padding-top: 16px;
}
}
main {
padding-top: 10px;
line-height: 1.5;
p {
margin-block: 1em;
}
}
label {
display: flex;
align-items: center;
gap: 4px;
}
.filter-form-keywords {
margin: 0 -16px 16px;
}
.filter-form-cols {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
.filter-form-col {
flex-basis: 160px;
flex-grow: 1;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
}
}
.filter-keywords {
--gap: 16px;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--gap);
padding: var(--gap);
overflow-y: auto;
min-height: 80px;
max-height: 25vh;
background-color: var(--bg-faded-blur-color);
counter-reset: index;
scroll-behavior: smooth;
li {
counter-increment: index;
display: flex;
gap: 4px;
align-items: center;
flex-wrap: wrap;
&:not(:only-child):before {
content: counter(index);
font-size: 10px;
color: var(--text-insignificant-color);
align-self: flex-start;
}
input[type='text'] {
flex-basis: 160px;
flex-grow: 100;
}
.filter-keyword-actions {
display: flex;
gap: 8px;
flex-grow: 1;
align-items: center;
justify-content: space-between;
label {
font-size: 0.8em;
line-height: 1;
}
}
}
}
.filter-keywords-footer {
padding: 8px 16px 0;
display: flex;
justify-content: space-between;
}
input[type='text'] {
display: block;
width: 100%;
}
.filter-form-footer {
display: flex;
gap: 16px;
justify-content: space-between;
align-items: center;
> span {
display: flex;
align-items: center;
}
button[type='submit'] {
padding-inline: 24px;
}
}
}

588
src/pages/filters.jsx Normal file
View file

@ -0,0 +1,588 @@
import './filters.css';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import MenuConfirm from '../components/menu-confirm';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import RelativeTime from '../components/relative-time';
import { api } from '../utils/api';
import useInterval from '../utils/useInterval';
import useTitle from '../utils/useTitle';
const FILTER_CONTEXT = ['home', 'public', 'notifications', 'thread', 'account'];
const FILTER_CONTEXT_UNIMPLEMENTED = ['notifications', 'thread', 'account'];
const FILTER_CONTEXT_LABELS = {
home: 'Home and lists',
notifications: 'Notifications',
public: 'Public timelines',
thread: 'Conversations',
account: 'Profiles',
};
const EXPIRY_DURATIONS = [
0, // forever
30 * 60, // 30 minutes
60 * 60, // 1 hour
6 * 60 * 60, // 6 hours
12 * 60 * 60, // 12 hours
60 * 60 * 24, // 24 hours
60 * 60 * 24 * 7, // 7 days
60 * 60 * 24 * 30, // 30 days
];
const EXPIRY_DURATIONS_LABELS = {
0: 'Never',
1800: '30 minutes',
3600: '1 hour',
21600: '6 hours',
43200: '12 hours',
86_400: '24 hours',
604_800: '7 days',
2_592_000: '30 days',
};
function Filters() {
const { masto } = api();
useTitle(`Filters`, `/ft`);
const [uiState, setUIState] = useState('default');
const [showFiltersAddEditModal, setShowFiltersAddEditModal] = useState(false);
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
const [filters, setFilters] = useState([]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const filters = await masto.v2.filters.list();
filters.sort((a, b) => a.title.localeCompare(b.title));
filters.forEach((filter) => {
if (filter.keywords?.length) {
filter.keywords.sort((a, b) => a.id - b.id);
}
});
console.log(filters);
setFilters(filters);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, [reloadCount]);
return (
<div id="filters-page" class="deck-container" tabIndex="-1">
<div class="timeline-deck deck">
<header>
<div class="header-grid">
<div class="header-side">
<NavMenu />
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Filters</h1>
<div class="header-side">
<button
type="button"
class="plain"
onClick={() => {
setShowFiltersAddEditModal(true);
}}
>
<Icon icon="plus" size="l" alt="New filter" />
</button>
</div>
</div>
</header>
<main>
{filters.length > 0 ? (
<>
<ul class="filters-list">
{filters.map((filter) => {
const { id, title, expiresAt, keywords } = filter;
return (
<li key={id}>
<div>
<h2>{title}</h2>
{keywords?.length > 0 && (
<div>
{keywords.map((k) => (
<>
<span class="tag collapsed insignificant">
{k.wholeWord ? `${k.keyword}` : k.keyword}
</span>{' '}
</>
))}
</div>
)}
<small class="insignificant">
<ExpiryStatus expiresAt={expiresAt} />
</small>
</div>
<button
type="button"
class="plain"
onClick={() => {
setShowFiltersAddEditModal({
filter,
});
}}
>
<Icon icon="pencil" size="l" alt="Edit filter" />
</button>
</li>
);
})}
</ul>
{filters.length > 1 && (
<footer class="ui-state">
<small class="insignificant">
{filters.length} filter
{filters.length === 1 ? '' : 's'}
</small>
</footer>
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load filters.</p>
) : (
<p class="ui-state">No filters yet.</p>
)}
</main>
</div>
{!!showFiltersAddEditModal && (
<Modal
title="Add filter"
onClose={() => {
setShowFiltersAddEditModal(false);
}}
>
<FiltersAddEdit
filter={showFiltersAddEditModal?.filter}
onClose={(result) => {
if (result.state === 'success') {
reload();
}
setShowFiltersAddEditModal(false);
}}
/>
</Modal>
)}
</div>
);
}
let _id = 1;
const incID = () => _id++;
function FiltersAddEdit({ filter, onClose }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const editMode = !!filter;
const { context, expiresAt, id, keywords, title, filterAction } =
filter || {};
const hasExpiry = !!expiresAt;
const expiresAtDate = hasExpiry && new Date(expiresAt);
const [editKeywords, setEditKeywords] = useState(keywords || []);
const keywordsRef = useRef();
// Hacky way of handling removed keywords for both existing and new ones
const [removedKeywordIDs, setRemovedKeywordIDs] = useState([]);
const [removedKeyword_IDs, setRemovedKeyword_IDs] = useState([]);
const filteredEditKeywords = editKeywords.filter(
(k) =>
!removedKeywordIDs.includes(k.id) && !removedKeyword_IDs.includes(k._id),
);
return (
<div class="sheet" id="filters-add-edit-modal">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<h2>{editMode ? 'Edit filter' : 'New filter'}</h2>
</header>
<main>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const title = formData.get('title');
const keywordIDs = formData.getAll('keyword_attributes[][id]');
const keywordKeywords = formData.getAll(
'keyword_attributes[][keyword]',
);
// const keywordWholeWords = formData.getAll(
// 'keyword_attributes[][whole_word]',
// );
// Not using getAll because it skips the empty checkboxes
const keywordWholeWords = [
...keywordsRef.current.querySelectorAll(
'input[name="keyword_attributes[][whole_word]"]',
),
].map((i) => i.checked);
const keywordsAttributes = keywordKeywords.map((k, i) => ({
id: keywordIDs[i] || undefined,
keyword: k,
wholeWord: keywordWholeWords[i],
}));
// if (editMode && keywords?.length) {
// // Find which one got deleted and add to keywordsAttributes
// keywords.forEach((k) => {
// if (!keywordsAttributes.find((ka) => ka.id === k.id)) {
// keywordsAttributes.push({
// ...k,
// _destroy: true,
// });
// }
// });
// }
if (editMode && removedKeywordIDs?.length) {
removedKeywordIDs.forEach((id) => {
keywordsAttributes.push({
id,
_destroy: true,
});
});
}
const context = formData.getAll('context');
let expiresIn = formData.get('expires_in');
const filterAction = formData.get('filter_action');
console.log({
title,
keywordIDs,
keywords: keywordKeywords,
wholeWords: keywordWholeWords,
keywordsAttributes,
context,
expiresIn,
filterAction,
});
// Required fields
if (!title || !context?.length) {
return;
}
setUIState('loading');
(async () => {
try {
let filterResult;
if (editMode) {
if (expiresIn === '' || expiresIn === null) {
// No value
// Preserve existing expiry if not specified
// Seconds from now to expiresAtDate
// Other clients don't do this
if (hasExpiry) {
expiresIn = Math.floor(
(expiresAtDate - new Date()) / 1000,
);
} else {
expiresIn = null;
}
} else if (expiresIn === '0' || expiresIn === 0) {
// 0 = Never
expiresIn = null;
} else {
expiresIn = +expiresIn;
}
filterResult = await masto.v2.filters.$select(id).update({
title,
context,
expiresIn,
keywordsAttributes,
filterAction,
});
} else {
expiresIn = +expiresIn || null;
filterResult = await masto.v2.filters.create({
title,
context,
expiresIn,
keywordsAttributes,
filterAction,
});
}
console.log({ filterResult });
setUIState('default');
onClose?.({
state: 'success',
filter: filterResult,
});
} catch (error) {
console.error(error);
setUIState('error');
alert(
editMode
? 'Unable to edit filter'
: 'Unable to create filter',
);
}
})();
}}
>
<div class="filter-form-row">
<label>
<b>Title</b>
<input
type="text"
name="title"
defaultValue={title}
disabled={uiState === 'loading'}
dir="auto"
required
/>
</label>
</div>
<div class="filter-form-keywords" ref={keywordsRef}>
{filteredEditKeywords.length ? (
<ul class="filter-keywords">
{filteredEditKeywords.map((k) => {
const { id, keyword, wholeWord, _id } = k;
return (
<li key={`${id}-${_id}`}>
<input
type="hidden"
name="keyword_attributes[][id]"
value={id}
/>
<input
name="keyword_attributes[][keyword]"
type="text"
defaultValue={keyword}
disabled={uiState === 'loading'}
required
dir="auto"
/>
<div class="filter-keyword-actions">
<label>
<input
name="keyword_attributes[][whole_word]"
type="checkbox"
value={id} // Hacky way to map checkbox boolean to the keyword id
defaultChecked={wholeWord}
disabled={uiState === 'loading'}
/>{' '}
Whole word
</label>
<button
type="button"
class="light danger small"
disabled={uiState === 'loading'}
onClick={() => {
if (id) {
removedKeywordIDs.push(id);
setRemovedKeywordIDs([...removedKeywordIDs]);
} else if (_id) {
removedKeyword_IDs.push(_id);
setRemovedKeyword_IDs([...removedKeyword_IDs]);
}
}}
>
<Icon icon="x" />
</button>
</div>
</li>
);
})}
</ul>
) : (
<div class="filter-keywords">
<div class="insignificant">No keywords. Add one.</div>
</div>
)}
<footer class="filter-keywords-footer">
<button
type="button"
class="light"
onClick={() => {
setEditKeywords([
...editKeywords,
{
_id: incID(),
keyword: '',
wholeWord: true,
},
]);
setTimeout(() => {
// Focus last input
const fields =
keywordsRef.current.querySelectorAll(
'input[type="text"]',
);
fields[fields.length - 1]?.focus?.();
}, 10);
}}
>
Add keyword
</button>{' '}
{filteredEditKeywords?.length > 1 && (
<small class="insignificant">
{filteredEditKeywords.length} keyword
{filteredEditKeywords.length === 1 ? '' : 's'}
</small>
)}
</footer>
</div>
<div class="filter-form-cols">
<div class="filter-form-col">
<div>
<b>Filter from</b>
</div>
{FILTER_CONTEXT.map((ctx) => (
<div>
<label
class={
FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx)
? 'insignificant'
: ''
}
>
<input
type="checkbox"
name="context"
value={ctx}
defaultChecked={!!context ? context.includes(ctx) : true}
disabled={uiState === 'loading'}
/>{' '}
{FILTER_CONTEXT_LABELS[ctx]}
{FILTER_CONTEXT_UNIMPLEMENTED.includes(ctx) ? '*' : ''}
</label>{' '}
</div>
))}
<p>
<small class="insignificant">* Not implemented yet</small>
</p>
</div>
<div class="filter-form-col">
{editMode && (
<>
Status:{' '}
<b>
<ExpiryStatus expiresAt={expiresAt} showNeverExpires />
</b>
</>
)}
<div>
<label for="filters-expires_in">
{editMode ? 'Change expiry' : 'Expiry'}
</label>
<select
id="filters-expires_in"
name="expires_in"
disabled={uiState === 'loading'}
defaultValue={editMode ? undefined : 0}
>
{editMode && <option></option>}
{EXPIRY_DURATIONS.map((v) => (
<option value={v}>{EXPIRY_DURATIONS_LABELS[v]}</option>
))}
</select>
</div>
<p>
Filtered post will be
<br />
<label class="ib">
<input
type="radio"
name="filter_action"
value="warn"
defaultChecked={filterAction === 'warn' || !editMode}
disabled={uiState === 'loading'}
/>{' '}
minimized
</label>{' '}
<label class="ib">
<input
type="radio"
name="filter_action"
value="hide"
defaultChecked={filterAction === 'hide'}
disabled={uiState === 'loading'}
/>{' '}
hidden
</label>
</p>
</div>
</div>
<footer class="filter-form-footer">
<span>
<button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'}
</button>{' '}
<Loader abrupt hidden={uiState !== 'loading'} />
</span>
{editMode && (
<MenuConfirm
disabled={uiState === 'loading'}
align="end"
menuItemClassName="danger"
confirmLabel="Delete this filter?"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v2.filters.$select(id).remove();
setUIState('default');
onClose?.({
state: 'success',
});
} catch (e) {
console.error(e);
setUIState('error');
alert('Unable to delete filter.');
}
})();
}}
>
<button
type="button"
class="light danger"
onClick={() => {}}
disabled={uiState === 'loading'}
>
Delete
</button>
</MenuConfirm>
)}
</footer>
</form>
</main>
</div>
);
}
function ExpiryStatus({ expiresAt, showNeverExpires }) {
const hasExpiry = !!expiresAt;
const expiresAtDate = hasExpiry && new Date(expiresAt);
const expired = hasExpiry && expiresAtDate <= new Date();
// If less than a minute left, re-render interval every second, else every minute
const [_, rerender] = useReducer((c) => c + 1, 0);
useInterval(rerender, expired || 30_000);
return expired ? (
'Expired'
) : hasExpiry ? (
<>
Expiring <RelativeTime datetime={expiresAtDate} />
</>
) : (
showNeverExpires && 'Never expires'
);
}
export default Filters;

View file

@ -10,7 +10,7 @@ import useTitle from '../utils/useTitle';
function FollowedHashtags() {
const { masto, instance } = api();
useTitle(`Followed Hashtags`, `/ft`);
useTitle(`Followed Hashtags`, `/fh`);
const [uiState, setUIState] = useState('default');
const [followedHashtags, setFollowedHashtags] = useState([]);

View file

@ -4,8 +4,8 @@ import { useSnapshot } from 'valtio';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import states from '../utils/states';
import { getStatus, saveStatus } from '../utils/states';
import states, { getStatus, saveStatus } from '../utils/states';
import supports from '../utils/supports';
import {
assignFollowedTags,
clearFollowedTagsState,
@ -23,11 +23,19 @@ function Following({ title, path, id, ...props }) {
const latestItem = useRef();
console.debug('RENDER Following', title, id);
const supportsPixelfed = supports('@pixelfed/home-include-reblogs');
async function fetchHome(firstLoad) {
if (firstLoad || !homeIterator.current) {
homeIterator.current = masto.v1.timelines.home.list({ limit: LIMIT });
}
if (supportsPixelfed && homeIterator.current?.nextParams) {
if (typeof homeIterator.current.nextParams === 'string') {
homeIterator.current.nextParams += '&include_reblogs=true';
} else {
homeIterator.current.nextParams.include_reblogs = true;
}
}
const results = await homeIterator.current.next();
let { value } = results;
if (value?.length) {
@ -63,15 +71,18 @@ function Following({ title, path, id, ...props }) {
async function checkForUpdates() {
try {
const results = await masto.v1.timelines.home
.list({
limit: 5,
since_id: latestItem.current,
})
.next();
const opts = {
limit: 5,
since_id: latestItem.current,
};
if (supports('@pixelfed/home-include-reblogs')) {
opts.include_reblogs = true;
}
const results = await masto.v1.timelines.home.list(opts).next();
let { value } = results;
console.log('checkForUpdates', latestItem.current, value);
if (value?.length) {
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
if (value?.length && !valueContainsLatestItem) {
latestItem.current = value[0].id;
value = dedupeBoosts(value, instance);
value = filteredItems(value, 'home');

View file

@ -5,19 +5,19 @@ import {
MenuHeader,
MenuItem,
} from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Icon from '../components/icon';
import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
import Menu2 from '../components/menu2';
import { SHORTCUTS_LIMIT } from '../components/shortcuts-settings';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import states, { saveStatus } from '../utils/states';
import { isMediaFirstInstance } from '../utils/store-utils';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -55,6 +55,8 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
useTitle(title, `/:instance?/t/:hashtag`);
const latestItem = useRef();
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
// const hashtagsIterator = useRef();
const maxID = useRef(undefined);
async function fetchHashtags(firstLoad) {
@ -73,7 +75,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
limit: LIMIT,
any: hashtags.slice(1),
maxId: firstLoad ? undefined : maxID.current,
onlyMedia: media,
onlyMedia: media ? true : undefined,
})
.next();
let { value } = results;
@ -85,7 +87,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
// value = filteredItems(value, 'public');
value.forEach((item) => {
saveStatus(item, instance, {
skipThreading: media, // If media view, no need to form threads
skipThreading: media || mediaFirst, // If media view, no need to form threads
});
});
@ -109,8 +111,9 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
})
.next();
let { value } = results;
value = filteredItems(value, 'public');
if (value?.length) {
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
if (value?.length && !valueContainsLatestItem) {
value = filteredItems(value, 'public');
return true;
}
return false;
@ -136,6 +139,26 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
const reachLimit = hashtags.length >= TOTAL_TAGS_LIMIT;
const [featuredUIState, setFeaturedUIState] = useState('default');
const [featuredTags, setFeaturedTags] = useState([]);
const [isFeaturedTag, setIsFeaturedTag] = useState(false);
useEffect(() => {
if (!authenticated) return;
(async () => {
try {
const featuredTags = await masto.v1.featuredTags.list();
setFeaturedTags(featuredTags);
setIsFeaturedTag(
featuredTags.some(
(tag) => tag.name.toLowerCase() === hashtag.toLowerCase(),
),
);
} catch (e) {
console.error(e);
}
})();
}, []);
return (
<Timeline
key={instance + hashtagTitle}
@ -143,7 +166,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
titleComponent={
!!instance && (
<h1 class="header-double-lines">
<b>{hashtagTitle}</b>
<b dir="auto">{hashtagTitle}</b>
<div>{instance}</div>
</h1>
)
@ -155,7 +178,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
fetchItems={fetchHashtags}
checkForUpdates={checkForUpdates}
useItemID
view={media ? 'media' : undefined}
view={media || mediaFirst ? 'media' : undefined}
refresh={media}
// allowFilters
filterContext="public"
@ -229,26 +252,93 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
</>
)}
</MenuConfirm>
<MenuItem
type="checkbox"
checked={isFeaturedTag}
disabled={featuredUIState === 'loading' || !authenticated}
onClick={() => {
setFeaturedUIState('loading');
if (isFeaturedTag) {
const featuredTagID = featuredTags.find(
(tag) => tag.name.toLowerCase() === hashtag.toLowerCase(),
).id;
if (featuredTagID) {
masto.v1.featuredTags
.$select(featuredTagID)
.remove()
.then(() => {
setIsFeaturedTag(false);
showToast('Unfeatured on profile');
setFeaturedTags(
featuredTags.filter(
(tag) => tag.id !== featuredTagID,
),
);
})
.catch((e) => {
console.error(e);
})
.finally(() => {
setFeaturedUIState('default');
});
} else {
showToast('Unable to unfeature on profile');
}
} else {
masto.v1.featuredTags
.create({
name: hashtag,
})
.then((value) => {
setIsFeaturedTag(true);
showToast('Featured on profile');
setFeaturedTags(featuredTags.concat(value));
})
.catch((e) => {
console.error(e);
})
.finally(() => {
setFeaturedUIState('default');
});
}
}}
>
{isFeaturedTag ? (
<>
<Icon icon="check-circle" />
<span>Featured on profile</span>
</>
) : (
<>
<Icon icon="check-circle" />
<span>Feature on profile</span>
</>
)}
</MenuItem>
<MenuDivider />
</>
)}
{!mediaFirst && (
<>
<MenuHeader className="plain">Filters</MenuHeader>
<MenuItem
type="checkbox"
checked={!!media}
onClick={() => {
if (media) {
searchParams.delete('media');
} else {
searchParams.set('media', '1');
}
setSearchParams(searchParams);
}}
>
<Icon icon="check-circle" />{' '}
<span class="menu-grow">Media only</span>
</MenuItem>
<MenuDivider />
</>
)}
<MenuHeader className="plain">Filters</MenuHeader>
<MenuItem
type="checkbox"
checked={!!media}
onClick={() => {
if (media) {
searchParams.delete('media');
} else {
searchParams.set('media', '1');
}
setSearchParams(searchParams);
}}
>
<Icon icon="check-circle" />{' '}
<span class="menu-grow">Media only</span>
</MenuItem>
<MenuDivider />
<FocusableItem className="menu-field" disabled={reachLimit}>
{({ ref }) => (
<form
@ -285,10 +375,11 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
required
autocorrect="off"
autocapitalize="off"
spellcheck={false}
spellCheck={false}
// no spaces, no hashtags
pattern="[^#][^\s#]+[^#]"
disabled={reachLimit}
dir="auto"
/>
</form>
)}
@ -312,7 +403,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
<span>
<span class="bidi-isolate">
<span class="more-insignificant">#</span>
{t}
</span>
@ -358,7 +449,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
}
}}
>
<Icon icon="shortcut" /> <span>Add to Shorcuts</span>
<Icon icon="shortcut" /> <span>Add to Shortcuts</span>
</MenuItem>
<MenuItem
onClick={() => {

View file

@ -12,11 +12,15 @@ import Loader from '../components/loader';
import Notification from '../components/notification';
import { api } from '../utils/api';
import db from '../utils/db';
import groupNotifications from '../utils/group-notifications';
import { massageNotifications2 } from '../utils/group-notifications';
import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
import Following from './following';
import {
getGroupedNotifications,
mastoFetchNotifications,
} from './notifications';
function Home() {
const snapStates = useSnapshot(states);
@ -84,20 +88,17 @@ function NotificationsLink() {
);
}
const NOTIFICATIONS_LIMIT = 30;
const NOTIFICATIONS_DISPLAY_LIMIT = 5;
function NotificationsMenu({ anchorRef, state, onClose }) {
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const notificationsIterator = masto.v1.notifications.list({
limit: NOTIFICATIONS_LIMIT,
});
const notificationsIterator = mastoFetchNotifications();
async function fetchNotifications() {
const allNotifications = await notificationsIterator.next();
const notifications = allNotifications.value;
const notifications = massageNotifications2(allNotifications.value);
if (notifications?.length) {
notifications.forEach((notification) => {
@ -106,16 +107,16 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
});
});
const groupedNotifications = groupNotifications(notifications);
const groupedNotifications = getGroupedNotifications(notifications);
states.notificationsLast = notifications[0];
states.notificationsLast = groupedNotifications[0];
states.notifications = groupedNotifications;
// Update last read marker
masto.v1.markers
.create({
notifications: {
lastReadId: notifications[0].id,
lastReadId: groupedNotifications[0].id,
},
})
.catch(() => {});
@ -151,8 +152,11 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
if (state === 'open') loadNotifications();
}, [state]);
const menuRef = useRef();
return (
<ControlledMenu
ref={menuRef}
menuClassName="notifications-menu"
state={state}
anchorRef={anchorRef}
@ -160,6 +164,11 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
portal={{
target: document.body,
}}
containerProps={{
onClick: () => {
menuRef.current?.closeMenu?.();
},
}}
overflow="auto"
viewScroll="close"
position="anchor"
@ -176,7 +185,7 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
.slice(0, NOTIFICATIONS_DISPLAY_LIMIT)
.map((notification) => (
<Notification
key={notification.id}
key={notification._ids || notification.id}
instance={instance}
notification={notification}
disableContextMenu

View file

@ -24,11 +24,13 @@ export default function HttpRoute() {
// Check if status returns 200
try {
const { instance, id } = statusObject;
const { masto } = api({ instance });
const status = await masto.v1.statuses.$select(id).fetch();
if (status) {
window.location.hash = statusURL + '?view=full';
return;
if (id) {
const { masto } = api({ instance });
const status = await masto.v1.statuses.$select(id).fetch();
if (status) {
window.location.hash = statusURL + '?view=full';
return;
}
}
} catch (e) {}

View file

@ -1,6 +1,6 @@
import './lists.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useNavigate, useParams } from 'react-router-dom';
@ -10,12 +10,14 @@ import AccountBlock from '../components/account-block';
import Icon from '../components/icon';
import Link from '../components/link';
import ListAddEdit from '../components/list-add-edit';
import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
import MenuLink from '../components/menu-link';
import Menu2 from '../components/menu2';
import Modal from '../components/modal';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import { getList, getLists } from '../utils/lists';
import states, { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
@ -61,8 +63,9 @@ function List(props) {
since_id: latestItem.current,
});
let { value } = results;
value = filteredItems(value, 'home');
if (value?.length) {
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
if (value?.length && !valueContainsLatestItem) {
value = filteredItems(value, 'home');
return true;
}
return false;
@ -71,13 +74,18 @@ function List(props) {
}
}
const [lists, setLists] = useState([]);
useEffect(() => {
getLists().then(setLists);
}, []);
const [list, setList] = useState({ title: 'List' });
// const [title, setTitle] = useState(`List`);
useTitle(list.title, `/l/:id`);
useEffect(() => {
(async () => {
try {
const list = await masto.v1.lists.$select(id).fetch();
const list = await getList(id);
setList(list);
// setTitle(list.title);
} catch (e) {
@ -107,9 +115,32 @@ function List(props) {
showReplyParent
// refresh={reloadCount}
headerStart={
<Link to="/l" class="button plain">
<Icon icon="list" size="l" />
</Link>
// <Link to="/l" class="button plain">
// <Icon icon="list" size="l" />
// </Link>
<Menu2
overflow="auto"
menuButton={
<button type="button" class="plain">
<Icon icon="list" size="l" alt="Lists" />
<Icon icon="chevron-down" size="s" />
</button>
}
>
<MenuLink to="/l">
<span>All Lists</span>
</MenuLink>
{lists?.length > 0 && (
<>
<MenuDivider />
{lists.map((list) => (
<MenuLink key={list.id} to={`/l/${list.id}`}>
<span>{list.title}</span>
</MenuLink>
))}
</>
)}
</Menu2>
}
headerEnd={
<Menu2

View file

@ -8,11 +8,10 @@ import ListAddEdit from '../components/list-add-edit';
import Loader from '../components/loader';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import { api } from '../utils/api';
import { fetchLists } from '../utils/lists';
import useTitle from '../utils/useTitle';
function Lists() {
const { masto } = api();
useTitle(`Lists`, `/l`);
const [uiState, setUIState] = useState('default');
@ -22,8 +21,7 @@ function Lists() {
setUIState('loading');
(async () => {
try {
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
const lists = await fetchLists();
console.log(lists);
setLists(lists);
setUIState('default');

View file

@ -30,14 +30,15 @@
#instances-suggestions {
margin: 0.2em 0 0;
padding: 0 0 0 1.2em;
padding: 0;
padding-inline-start: 1.2em;
list-style: none;
width: 90vw;
max-width: 40em;
overflow: auto;
white-space: nowrap;
mask-image: linear-gradient(
to right,
var(--to-forward),
transparent,
black 1.2em,
black calc(100% - 5em),

View file

@ -1,5 +1,6 @@
import './login.css';
import Fuse from 'fuse.js';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
@ -11,6 +12,7 @@ import instancesListURL from '../data/instances.json?url';
import { getAuthorizationURL, registerApplication } from '../utils/auth';
import store from '../utils/store';
import useTitle from '../utils/useTitle';
import { gtsDtth } from '../utils/dtth';
const { PHANPY_DEFAULT_INSTANCE: DEFAULT_INSTANCE } = import.meta.env;
@ -23,16 +25,18 @@ function Login() {
const instance = searchParams.get('instance');
const submit = searchParams.get('submit');
const [instanceText, setInstanceText] = useState(
instance || cachedInstanceURL?.toLowerCase() || '',
instance || cachedInstanceURL?.toLowerCase() || gtsDtth,
);
const [instancesList, setInstancesList] = useState([]);
const searcher = useRef();
useEffect(() => {
(async () => {
try {
const res = await fetch(instancesListURL);
const data = await res.json();
setInstancesList(data);
searcher.current = new Fuse(data);
} catch (e) {
// Silently fail
console.error(e);
@ -90,21 +94,11 @@ function Login() {
!/[\s\/\\@]/.test(cleanInstanceText);
const instancesSuggestions = cleanInstanceText
? instancesList
.filter((instance) => instance.includes(instanceText))
.sort((a, b) => {
// Move text that starts with instanceText to the start
const aStartsWith = a
.toLowerCase()
.startsWith(instanceText.toLowerCase());
const bStartsWith = b
.toLowerCase()
.startsWith(instanceText.toLowerCase());
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
return 0;
? searcher.current
?.search(cleanInstanceText, {
limit: 10,
})
.slice(0, 10)
?.map((match) => match.item)
: [];
const selectedInstanceText = instanceTextLooksLikeDomain
@ -160,11 +154,12 @@ function Login() {
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck={false}
spellCheck={false}
placeholder="instance domain"
onInput={(e) => {
setInstanceText(e.target.value);
}}
dir="auto"
/>
{instancesSuggestions?.length > 0 ? (
<ul id="instances-suggestions">

View file

@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
import Link from '../components/link';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { fixNotifications } from '../utils/group-notifications';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
@ -30,6 +31,8 @@ function Mentions({ columnMode, ...props }) {
const results = await mentionsIterator.current.next();
let { value } = results;
if (value?.length) {
value = fixNotifications(value);
if (firstLoad) {
latestItem.current = value[0].id;
console.log('First load', latestItem.current);
@ -95,7 +98,9 @@ function Mentions({ columnMode, ...props }) {
latestConversationItem.current,
value,
);
if (value?.length) {
const valueContainsLatestItem =
value[0]?.id === latestConversationItem.current; // since_id might not be supported
if (value?.length && !valueContainsLatestItem) {
latestConversationItem.current = value[0].lastStatus.id;
return true;
}

View file

@ -143,6 +143,7 @@
border-color: var(--reply-to-color);
box-shadow: 0 0 0 3px var(--reply-to-faded-color);
}
.notification:focus-visible .status-link,
.notification .status-link:is(:hover, :focus) {
background-color: var(--bg-blur-color);
filter: saturate(1);
@ -184,7 +185,7 @@
.notification-group-statuses > li:before {
content: counter(index);
position: absolute;
left: 0;
inset-inline-start: 0;
font-size: 10px;
padding: 8px;
font-weight: bold;
@ -193,16 +194,19 @@
margin-top: -1px;
}
.notification-group-statuses > li:not(:last-child) .status-link {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.notification-group-statuses > li:not(:first-child) .status-link {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-start-start-radius: 0;
border-start-end-radius: 0;
}
#mentions-option {
float: right;
&:dir(rtl) {
float: left;
}
margin-top: 0.5em;
}
#mentions-option label {
@ -387,7 +391,7 @@
width: calc(100% - 16px);
}
.announcements > ul > li:last-child {
border-right: none;
border-inline-end: none;
}
.announcements .announcement-block {
padding: 16px;
@ -420,3 +424,145 @@
color: var(--text-color);
background-color: var(--link-faded-color);
}
/* FILTERED NOTIFICATIONS */
.filtered-notifications {
padding-block-end: 16px;
summary {
padding: 8px 16px;
cursor: pointer;
font-weight: 600;
user-select: none;
margin: 16px 0 0;
color: var(--text-insignificant-color);
&::marker,
&::-webkit-details-marker {
color: var(--text-insignificant-color);
}
}
details[open] summary {
color: var(--text-color);
}
summary + ul {
}
ul {
list-style: none;
padding: 0;
margin: 0;
max-height: 50vh;
max-height: 50dvh;
overflow: auto;
border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
@media (min-width: 40em) {
background-color: var(--bg-color);
border-radius: 16px;
border-width: 0;
}
li {
display: flex;
padding: 16px;
row-gap: 8px;
column-gap: 16px;
border-bottom: 1px solid var(--outline-color);
}
li:last-child {
border-bottom: none;
}
.request-notifcations {
min-width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.last-post {
max-width: 100%;
> .status-link {
border-radius: 8px;
overflow: hidden;
--max-height: 160px;
max-height: var(--max-height);
border: 1px solid var(--outline-color);
&:is(:hover, :focus-visible) {
border-color: var(--outline-hover-color);
}
.status {
mask-image: linear-gradient(
to bottom,
black calc(var(--max-height) / 2),
transparent calc(var(--max-height) - 8px)
);
font-size: calc(var(--text-size) * 0.9);
.content-container {
pointer-events: none;
filter: saturate(0.5);
}
}
}
}
.request-notifications-account {
display: flex;
align-items: center;
gap: 4px;
}
}
.notification-request-buttons {
grid-area: buttons;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
button {
max-width: 30vw;
}
.notification-request-states {
min-height: 32px;
text-align: center;
vertical-align: middle;
.icon {
margin-inline: 8px;
&.notification-accepted {
color: var(--green-color);
}
&.notification-dismissed {
color: var(--red-color);
}
}
}
}
}
}
#notifications-settings {
label {
display: flex;
gap: 8px;
align-items: center;
input[type='checkbox'] {
flex-shrink: 0;
}
}
}

View file

@ -3,6 +3,7 @@ import './notifications.css';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -13,24 +14,76 @@ import FollowRequestButtons from '../components/follow-request-buttons';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import Notification from '../components/notification';
import Status from '../components/status';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import groupNotifications from '../utils/group-notifications';
import groupNotifications, {
groupNotifications2,
massageNotifications2,
} from '../utils/group-notifications';
import handleContentLinks from '../utils/handle-content-links';
import mem from '../utils/mem';
import niceDateTime from '../utils/nice-date-time';
import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils';
import supports from '../utils/supports';
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const LIMIT = 30; // 30 is the maximum limit :(
const NOTIFICATIONS_LIMIT = 80;
const NOTIFICATIONS_GROUPED_LIMIT = 20;
const emptySearchParams = new URLSearchParams();
const scrollIntoViewOptions = {
block: 'center',
inline: 'center',
behavior: 'smooth',
};
const memSupportsGroupedNotifications = mem(
() => supports('@mastodon/grouped-notifications'),
{
maxAge: 1000 * 60 * 5, // 5 minutes
},
);
export function mastoFetchNotifications(opts = {}) {
const { masto } = api();
if (
states.settings.groupedNotificationsAlpha &&
memSupportsGroupedNotifications()
) {
// https://github.com/mastodon/mastodon/pull/29889
return masto.v2_alpha.notifications.list({
limit: NOTIFICATIONS_GROUPED_LIMIT,
...opts,
});
} else {
return masto.v1.notifications.list({
limit: NOTIFICATIONS_LIMIT,
...opts,
});
}
}
export function getGroupedNotifications(notifications) {
if (
states.settings.groupedNotificationsAlpha &&
memSupportsGroupedNotifications()
) {
return groupNotifications2(notifications);
} else {
return groupNotifications(notifications);
}
}
function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications');
const { masto, instance } = api();
@ -56,13 +109,19 @@ function Notifications({ columnMode }) {
async function fetchNotifications(firstLoad) {
if (firstLoad || !notificationsIterator.current) {
// Reset iterator
notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT,
notificationsIterator.current = mastoFetchNotifications({
excludeTypes: ['follow_request'],
});
}
if (/max_id=($|&)/i.test(notificationsIterator.current?.nextParams)) {
// Pixelfed returns next paginationed link with empty max_id
// I assume, it's done (end of list)
return {
done: true,
};
}
const allNotifications = await notificationsIterator.current.next();
const notifications = allNotifications.value;
const notifications = massageNotifications2(allNotifications.value);
if (notifications?.length) {
notifications.forEach((notification) => {
@ -71,17 +130,43 @@ function Notifications({ columnMode }) {
});
});
const groupedNotifications = groupNotifications(notifications);
// TEST: Slot in a fake notification to test 'severed_relationships'
// notifications.unshift({
// id: '123123',
// type: 'severed_relationships',
// createdAt: '2024-03-22T19:20:08.316Z',
// event: {
// type: 'account_suspension',
// targetName: 'mastodon.dev',
// followersCount: 0,
// followingCount: 0,
// },
// });
// TEST: Slot in a fake notification to test 'moderation_warning'
// notifications.unshift({
// id: '123123',
// type: 'moderation_warning',
// createdAt: new Date().toISOString(),
// moderation_warning: {
// id: '1231234',
// action: 'mark_statuses_as_sensitive',
// },
// });
// console.log({ notifications });
const groupedNotifications = getGroupedNotifications(notifications);
if (firstLoad) {
states.notificationsLast = notifications[0];
states.notificationsLast = groupedNotifications[0];
states.notifications = groupedNotifications;
// Update last read marker
masto.v1.markers
.create({
notifications: {
lastReadId: notifications[0].id,
lastReadId: groupedNotifications[0].id,
},
})
.catch(() => {});
@ -129,6 +214,28 @@ function Notifications({ columnMode }) {
}
}
const supportsFilteredNotifications = supports(
'@mastodon/filtered-notifications',
);
const [showNotificationsSettings, setShowNotificationsSettings] =
useState(false);
const [notificationsPolicy, setNotificationsPolicy] = useState({});
function fetchNotificationsPolicy() {
return masto.v1.notifications.policy.fetch().catch(() => {});
}
function loadNotificationsPolicy() {
fetchNotificationsPolicy()
.then((policy) => {
console.log('✨ Notifications policy', policy);
setNotificationsPolicy(policy);
})
.catch(() => {});
}
const [notificationsRequests, setNotificationsRequests] = useState(null);
function fetchNotificationsRequest() {
return masto.v1.notifications.requests.list();
}
const loadNotifications = (firstLoad) => {
setShowNew(false);
setUIState('loading');
@ -154,6 +261,10 @@ function Notifications({ columnMode }) {
setFollowRequests(requests);
})
.catch(() => {});
if (supportsFilteredNotifications) {
loadNotificationsPolicy();
}
}
const { done } = await fetchNotificationsPromise;
@ -161,6 +272,7 @@ function Notifications({ columnMode }) {
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
@ -209,7 +321,6 @@ function Notifications({ columnMode }) {
const lastHiddenTime = useRef();
usePageVisibility((visible) => {
let unsub;
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
@ -220,17 +331,21 @@ function Notifications({ columnMode }) {
} else {
lastHiddenTime.current = Date.now();
}
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
if (v) {
loadUpdates();
}
setShowNew(v);
});
}
return () => {
unsub?.();
};
});
const firstLoad = useRef(true);
useEffect(() => {
let unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
if (firstLoad.current) {
firstLoad.current = false;
return;
}
if (uiState === 'loading') return;
if (v) loadUpdates();
setShowNew(v);
});
return () => unsub?.();
}, []);
const todayDate = new Date();
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
@ -270,11 +385,84 @@ function Notifications({ columnMode }) {
// }
// }, [uiState]);
const itemsSelector = '.notification';
const jRef = useHotkeys('j', () => {
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let nextItem = allItems[activeItemIndex + 1];
if (nextItem) {
nextItem.focus();
nextItem.scrollIntoView(scrollIntoViewOptions);
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView(scrollIntoViewOptions);
}
}
});
const kRef = useHotkeys('k', () => {
// focus on previous status after active item
const activeItem = document.activeElement.closest(itemsSelector);
const activeItemRect = activeItem?.getBoundingClientRect();
const allItems = Array.from(
scrollableRef.current.querySelectorAll(itemsSelector),
);
if (
activeItem &&
activeItemRect.top < scrollableRef.current.clientHeight &&
activeItemRect.bottom > 0
) {
const activeItemIndex = allItems.indexOf(activeItem);
let prevItem = allItems[activeItemIndex - 1];
if (prevItem) {
prevItem.focus();
prevItem.scrollIntoView(scrollIntoViewOptions);
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView(scrollIntoViewOptions);
}
}
});
const oRef = useHotkeys(['enter', 'o'], () => {
const activeItem = document.activeElement.closest(itemsSelector);
const statusLink = activeItem?.querySelector('.status-link');
if (statusLink) {
statusLink.click();
}
});
return (
<div
id="notifications-page"
class="deck-container"
ref={scrollableRef}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
@ -301,7 +489,17 @@ function Notifications({ columnMode }) {
</div>
<h1>Notifications</h1>
<div class="header-side">
{/* <Loader hidden={uiState !== 'loading'} /> */}
{supportsFilteredNotifications && (
<button
type="button"
class="button plain4"
onClick={() => {
setShowNotificationsSettings(true);
}}
>
<Icon icon="settings" size="l" alt="Notifications settings" />
</button>
)}
</div>
</div>
{showNew && uiState !== 'loading' && (
@ -406,6 +604,76 @@ function Notifications({ columnMode }) {
)}
</div>
)}
{supportsFilteredNotifications &&
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
<div class="shazam-container">
<div class="shazam-container-inner">
<div class="filtered-notifications">
<details
onToggle={async (e) => {
const { open } = e.target;
if (open) {
const requests = await fetchNotificationsRequest();
setNotificationsRequests(requests);
console.log({ open, requests });
}
}}
>
<summary>
Filtered notifications from{' '}
{notificationsPolicy.summary.pendingRequestsCount} people
</summary>
{!notificationsRequests ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
notificationsRequests?.length > 0 && (
<ul>
{notificationsRequests.map((request) => (
<li key={request.id}>
<div class="request-notifcations">
{!request.lastStatus?.id && (
<AccountBlock
useAvatarStatic
showStats
account={request.account}
/>
)}
{request.lastStatus?.id && (
<div class="last-post">
<Link
class="status-link"
to={`/${instance}/s/${request.lastStatus.id}`}
>
<Status
status={request.lastStatus}
size="s"
readOnly
/>
</Link>
</div>
)}
<NotificationRequestModalButton
request={request}
/>
</div>
<NotificationRequestButtons
request={request}
onChange={() => {
loadNotifications(true);
}}
/>
</li>
))}
</ul>
)
)}
</details>
</div>
</div>
</div>
)}
<div id="mentions-option">
<label>
<input
@ -419,7 +687,7 @@ function Notifications({ columnMode }) {
</label>
</div>
<h2 class="timeline-header">Today</h2>
{showTodayEmpty && !!snapStates.notifications.length && (
{showTodayEmpty && (
<p class="ui-state insignificant">
{uiState === 'default' ? "You're all caught up." : <>&hellip;</>}
</p>
@ -449,12 +717,12 @@ function Notifications({ columnMode }) {
hideTime: true,
});
return (
<Fragment key={notification.id}>
<Fragment key={notification._ids || notification.id}>
{differentDay && <h2 class="timeline-header">{heading}</h2>}
<Notification
instance={instance}
notification={notification}
key={notification.id}
key={notification._ids || notification.id}
/>
</Fragment>
);
@ -514,6 +782,109 @@ function Notifications({ columnMode }) {
</InView>
)}
</div>
{supportsFilteredNotifications && showNotificationsSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowNotificationsSettings(false);
}
}}
>
<div class="sheet" id="notifications-settings" tabIndex="-1">
<button
type="button"
class="sheet-close"
onClick={() => setShowNotificationsSettings(false)}
>
<Icon icon="x" />
</button>
<header>
<h2>Notifications settings</h2>
</header>
<main>
<form
onSubmit={(e) => {
e.preventDefault();
const {
filterNotFollowing,
filterNotFollowers,
filterNewAccounts,
filterPrivateMentions,
} = e.target;
const allFilters = {
filterNotFollowing: filterNotFollowing.checked,
filterNotFollowers: filterNotFollowers.checked,
filterNewAccounts: filterNewAccounts.checked,
filterPrivateMentions: filterPrivateMentions.checked,
};
setNotificationsPolicy({
...notificationsPolicy,
...allFilters,
});
setShowNotificationsSettings(false);
(async () => {
try {
await masto.v1.notifications.policy.update(allFilters);
showToast('Notifications settings updated');
} catch (e) {
console.error(e);
}
})();
}}
>
<p>Filter out notifications from people:</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNotFollowing}
name="filterNotFollowing"
/>{' '}
You don't follow
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNotFollowers}
name="filterNotFollowers"
/>{' '}
Who don't follow you
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNewAccounts}
name="filterNewAccounts"
/>{' '}
With a new account
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterPrivateMentions}
name="filterPrivateMentions"
/>{' '}
Who unsolicitedly private mention you
</label>
</p>
<p>
<button type="submit">Save</button>
</p>
</form>
</main>
</div>
</Modal>
)}
</div>
);
}
@ -596,4 +967,186 @@ function AnnouncementBlock({ announcement }) {
);
}
function fetchNotficationsByAccount(accountID) {
const { masto } = api();
return masto.v1.notifications.list({
accountID,
});
}
function NotificationRequestModalButton({ request }) {
const { instance } = api();
const [uiState, setUIState] = useState('loading');
const { account, lastStatus } = request;
const [showModal, setShowModal] = useState(false);
const [notifications, setNotifications] = useState([]);
function onClose() {
setShowModal(false);
}
useEffect(() => {
if (!request?.account?.id) return;
if (!showModal) return;
setUIState('loading');
(async () => {
const notifs = await fetchNotficationsByAccount(request.account.id);
setNotifications(notifs || []);
setUIState('default');
})();
}, [showModal, request?.account?.id]);
return (
<>
<button
type="button"
class="plain4 request-notifications-account"
onClick={() => {
setShowModal(true);
}}
>
<Icon icon="notification" class="more-insignificant" />{' '}
<small>View notifications from @{account.username}</small>{' '}
<Icon icon="chevron-down" />
</button>
{showModal && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<b>Notifications from @{account.username}</b>
</header>
<main>
{uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
notifications.map((notification) => (
<div
class="notification-peek"
onClick={(e) => {
const { target } = e;
// If button or links
if (
e.target.tagName === 'BUTTON' ||
e.target.tagName === 'A'
) {
onClose();
}
}}
>
<Notification
instance={instance}
notification={notification}
isStatic
/>
</div>
))
)}
</main>
</div>
</Modal>
)}
</>
);
}
function NotificationRequestButtons({ request, onChange }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [requestState, setRequestState] = useState(null); // accept, dismiss
const hasRequestState = requestState !== null;
return (
<p class="notification-request-buttons">
<button
type="button"
disabled={uiState === 'loading' || hasRequestState}
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.notifications.requests
.$select(request.id)
.accept();
setRequestState('accept');
setUIState('default');
onChange({
request,
state: 'accept',
});
showToast(
`Notifications from @${request.account.username} will not be filtered from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to accept notification request`);
}
})();
}}
>
Allow
</button>{' '}
<button
type="button"
disabled={uiState === 'loading' || hasRequestState}
class="light danger"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.notifications.requests
.$select(request.id)
.dismiss();
setRequestState('dismiss');
setUIState('default');
onChange({
request,
state: 'dismiss',
});
showToast(
`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to dismiss notification request`);
}
})();
}}
>
Dismiss
</button>
<span class="notification-request-states">
{uiState === 'loading' ? (
<Loader abrupt />
) : requestState === 'accept' ? (
<Icon
icon="check-circle"
alt="Accepted"
class="notification-accepted"
/>
) : (
requestState === 'dismiss' && (
<Icon
icon="x-circle"
alt="Dismissed"
class="notification-dismissed"
/>
)
)}
</span>
</p>
);
}
export default memo(Notifications);

View file

@ -8,8 +8,8 @@ import Menu2 from '../components/menu2';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import states, { saveStatus } from '../utils/states';
import supports from '../utils/supports';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -30,10 +30,14 @@ function Public({ local, columnMode, ...props }) {
const publicIterator = useRef();
async function fetchPublic(firstLoad) {
if (firstLoad || !publicIterator.current) {
publicIterator.current = masto.v1.timelines.public.list({
const opts = {
limit: LIMIT,
local: isLocal,
});
local: isLocal || undefined,
};
if (!isLocal && supports('@pixelfed/global-feed')) {
opts.remote = true;
}
publicIterator.current = masto.v1.timelines.public.list(opts);
}
const results = await publicIterator.current.next();
let { value } = results;
@ -63,8 +67,9 @@ function Public({ local, columnMode, ...props }) {
})
.next();
let { value } = results;
value = filteredItems(value, 'public');
if (value?.length) {
const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported
if (value?.length && !valueContainsLatestItem) {
value = filteredItems(value, 'public');
return true;
}
return false;

View file

@ -48,10 +48,10 @@
a {
.icon {
vertical-align: middle;
transition: transform 0.2s;
transition: margin 0.2s;
}
&:hover .icon {
transform: translateX(4px);
margin-inline-start: 4px;
}
}
}
@ -101,9 +101,8 @@ ul.link-list.hashtag-list li a {
}
.search-popover {
position: absolute;
left: 8px;
inset-inline-start: 8px;
max-width: calc(100% - 16px);
/* right: 8px; */
background-color: var(--bg-color);
border: 1px solid var(--outline-color);
box-shadow: 0 4px 24px var(--drop-shadow-color);
@ -118,7 +117,8 @@ ul.link-list.hashtag-list li a {
}
.search-popover-item {
text-decoration: none;
padding: 8px 16px 8px 8px;
padding: 8px;
padding-inline-end: 16px;
display: flex;
gap: 8px;
align-items: center;
@ -132,10 +132,15 @@ ul.link-list.hashtag-list li a {
}
.search-popover-item:is(:focus, .focus) {
box-shadow: inset 4px 0 0 0 var(--button-bg-color);
:dir(rtl) & {
box-shadow: inset -4px 0 0 0 var(--button-bg-color);
}
}
.search-popover-item :is(mark, q) {
color: var(--text-color);
background-color: var(--link-bg-color);
unicode-bidi: isolate;
direction: initial;
}
.search-popover-item:is(:hover, :focus, .focus) :is(mark, q) {
background-color: var(--link-bg-color);

View file

@ -177,6 +177,7 @@ function Search({ columnMode, ...props }) {
['/', 'Slash'],
(e) => {
searchFormRef.current?.focus?.();
searchFormRef.current?.select?.();
},
{
preventDefault: true,

View file

@ -36,12 +36,12 @@
border-bottom: var(--hairline-width) solid var(--outline-color);
}
#settings-container section > ul > li > div:last-child {
text-align: right;
text-align: end;
}
#settings-container section > ul > li .sub-section {
text-align: left !important;
text-align: start !important;
margin-top: 8px;
margin-left: 24px;
margin-inline-start: 24px;
}
#settings-container section > ul > li .sub-section p {
margin-block: 0.5em;
@ -121,11 +121,11 @@
grid-template-rows: 1fr 1fr;
> span:first-child {
text-align: left;
text-align: start;
}
> span:last-child {
text-align: right;
text-align: end;
}
}
}

View file

@ -21,6 +21,7 @@ import {
import showToast from '../utils/show-toast';
import states from '../utils/states';
import store from '../utils/store';
import supports from '../utils/supports';
const DEFAULT_TEXT_SIZE = 16;
const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20];
@ -28,6 +29,7 @@ const {
PHANPY_WEBSITE: WEBSITE,
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
} = import.meta.env;
function Settings({ onClose }) {
@ -433,6 +435,37 @@ function Settings({ onClose }) {
</div>
</div>
</li>
{!!GIPHY_API_KEY && authenticated && (
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.composerGIFPicker}
onChange={(e) => {
states.settings.composerGIFPicker = e.target.checked;
}}
/>{' '}
GIF Picker for composer
</label>
<div class="sub-section insignificant">
<small>
Note: This feature uses external GIF search service, powered
by{' '}
<a
href="https://developers.giphy.com/"
target="_blank"
rel="noopener noreferrer"
>
GIPHY
</a>
. G-rated (suitable for viewing by all ages), tracking
parameters are stripped, referrer information is omitted
from requests, but search queries and IP address information
will still reach their servers.
</small>
</div>
</li>
)}
{!!IMG_ALT_API_URL && authenticated && (
<li>
<label>
@ -464,6 +497,27 @@ function Settings({ onClose }) {
</div>
</li>
)}
{authenticated && supports('@mastodon/grouped-notifications') && (
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.groupedNotificationsAlpha}
onChange={(e) => {
states.settings.groupedNotificationsAlpha =
e.target.checked;
}}
/>{' '}
Server-side grouped notifications
</label>
<div class="sub-section insignificant">
<small>
Alpha-stage feature. Potentially improved grouping window
but basic grouping logic.
</small>
</div>
</li>
)}
{authenticated && (
<li>
<label>
@ -571,14 +625,18 @@ function Settings({ onClose }) {
}}
>
@phanpy
</a>
</a> (
<a href="https://git.dtth.ch/nki/phanpy" target="_blank">
DTTH Fork
</a>
)
<br />
<a
href="https://github.com/cheeaun/phanpy"
target="_blank"
rel="noopener noreferrer"
>
Built
Original
</a>{' '}
by{' '}
<a
@ -633,10 +691,10 @@ function Settings({ onClose }) {
type="text"
class="version-string"
readOnly
size="18" // Manually calculated here
size={10 /* build time */ + (1+8) /* commit hash */ + '-dtth'.length}
value={`${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${
__COMMIT_HASH__ ? `.${__COMMIT_HASH__}` : ''
}`}
__COMMIT_HASH__ ? `.${__COMMIT_HASH__.slice(0, 8)}` : ''
}-dtth`}
onClick={(e) => {
e.target.select();
// Copy to clipboard
@ -653,7 +711,7 @@ function Settings({ onClose }) {
<span class="ib insignificant">
(
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
href={`https://git.dtth.ch/nki/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
rel="noopener noreferrer"
>
@ -690,9 +748,10 @@ function PushNotificationsSection({ onClose }) {
) {
setAllowNotifications(true);
const { alerts, policy } = backendSubscription;
console.log('backendSubscription', backendSubscription);
previousPolicyRef.current = policy;
const { elements } = pushFormRef.current;
const policyEl = elements.namedItem(policy);
const policyEl = elements.namedItem('policy');
if (policyEl) policyEl.value = policy;
// alerts is {}, iterate it
Object.keys(alerts).forEach((alert) => {
@ -721,65 +780,68 @@ function PushNotificationsSection({ onClose }) {
<form
ref={pushFormRef}
onChange={() => {
const values = Object.fromEntries(new FormData(pushFormRef.current));
const allowNotifications = !!values['policy-allow'];
const params = {
policy: values.policy,
data: {
alerts: {
mention: !!values.mention,
favourite: !!values.favourite,
reblog: !!values.reblog,
follow: !!values.follow,
follow_request: !!values.followRequest,
poll: !!values.poll,
update: !!values.update,
status: !!values.status,
setTimeout(() => {
const values = Object.fromEntries(new FormData(pushFormRef.current));
const allowNotifications = !!values['policy-allow'];
const params = {
data: {
policy: values.policy,
alerts: {
mention: !!values.mention,
favourite: !!values.favourite,
reblog: !!values.reblog,
follow: !!values.follow,
follow_request: !!values.followRequest,
poll: !!values.poll,
update: !!values.update,
status: !!values.status,
},
},
},
};
};
let alertsCount = 0;
// Remove false values from data.alerts
// API defaults to false anyway
Object.keys(params.data.alerts).forEach((key) => {
if (!params.data.alerts[key]) {
delete params.data.alerts[key];
} else {
alertsCount++;
}
});
const policyChanged = previousPolicyRef.current !== params.policy;
let alertsCount = 0;
// Remove false values from data.alerts
// API defaults to false anyway
Object.keys(params.data.alerts).forEach((key) => {
if (!params.data.alerts[key]) {
delete params.data.alerts[key];
} else {
alertsCount++;
}
});
const policyChanged =
previousPolicyRef.current !== params.data.policy;
console.log('PN Form', {
values,
allowNotifications: allowNotifications,
params,
});
console.log('PN Form', {
values,
allowNotifications: allowNotifications,
params,
});
if (allowNotifications && alertsCount > 0) {
if (policyChanged) {
console.debug('Policy changed.');
removeSubscription()
.then(() => {
updateSubscription(params);
})
.catch((err) => {
if (allowNotifications && alertsCount > 0) {
if (policyChanged) {
console.debug('Policy changed.');
removeSubscription()
.then(() => {
updateSubscription(params);
})
.catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
} else {
updateSubscription(params).catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
}
} else {
updateSubscription(params).catch((err) => {
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
alert('Failed to remove subscription. Please try again.');
});
}
} else {
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to remove subscription. Please try again.');
});
}
}, 100);
}}
>
<h3>Push Notifications (beta)</h3>

View file

@ -11,7 +11,7 @@
align-self: stretch;
}
header h1 .deck-back {
margin-left: -16px;
margin-inline-start: -16px;
}
.button-refresh .icon {
@ -23,12 +23,6 @@
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.hero-heading {
font-size: var(--text-size);
display: inline-block;
@ -45,7 +39,7 @@
font-size: 70% !important;
& > .avatar ~ .avatar {
margin-left: -4px;
margin-inline-start: -4px;
}
}
.ancestors-indicator:not([hidden]) {

View file

@ -12,10 +12,10 @@ import {
useRef,
useState,
} from 'preact/hooks';
import punycode from 'punycode/';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { matchPath, useSearchParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar';
@ -122,7 +122,7 @@ function StatusPage(params) {
}, [showMedia]);
const mediaAttachments = mediaStatusID
? mediaStatus?.mediaAttachments
? snapStates.statuses[statusKey(mediaStatusID, instance)]?.mediaAttachments
: heroStatus?.mediaAttachments;
const handleMediaClose = useCallback(() => {
@ -153,6 +153,18 @@ function StatusPage(params) {
return () => clearTimeout(timer);
}, [showMediaOnly]);
useEffect(() => {
const $deckContainers = document.querySelectorAll('.deck-container');
$deckContainers.forEach(($deckContainer) => {
$deckContainer.setAttribute('inert', '');
});
return () => {
$deckContainers.forEach(($deckContainer) => {
$deckContainer.removeAttribute('inert');
});
};
}, []);
return (
<div class="deck-backdrop">
{showMedia ? (
@ -557,7 +569,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
if (!heroStatus) return;
const { url } = heroStatus;
if (!url) return;
return new URL(url).hostname;
return URL.parse(url).hostname;
}, [heroStatus]);
const postSameInstance = useMemo(() => {
if (!postInstance) return;
@ -972,6 +984,18 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
[statuses, limit, renderStatus],
);
// If there's spoiler in hero status, auto-expand it
useEffect(() => {
let timer = setTimeout(() => {
if (!heroStatusRef.current) return;
const spoilerButton = heroStatusRef.current.querySelector(
'.spoiler-button:not(.spoiling), .spoiler-media-button:not(.spoiling)',
);
if (spoilerButton) spoilerButton.click();
}, 1000);
return () => clearTimeout(timer);
}, [id]);
return (
<div
tabIndex="-1"
@ -1208,7 +1232,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
{postInstance ? (
<>
{' '}
(<b>{postInstance}</b>)
(<b>{punycode.toUnicode(postInstance)}</b>)
</>
) : (
''
@ -1346,6 +1370,8 @@ function SubComments({
const detailsRef = useRef();
useLayoutEffect(() => {
function handleScroll(e) {
// NOTE: this scrollLeft works for RTL too
// Browsers do the magic for us
e.target.dataset.scrollLeft = e.target.scrollLeft;
}
detailsRef.current?.addEventListener('scroll', handleScroll, {

55
src/pages/trending.css Normal file
View file

@ -0,0 +1,55 @@
#trending-page {
.timeline-header-block {
display: flex;
gap: 12px;
align-items: center;
padding: 16px;
&.blended {
background-image: linear-gradient(
to bottom,
var(--bg-faded-color),
transparent
);
}
@media (min-width: 40em) {
padding: 0 16px;
}
&.loading {
color: var(--text-insignificant-color);
}
p {
margin: 0;
padding: 0;
flex-grow: 1;
min-width: 0;
}
.link-text {
color: var(--text-insignificant-color);
display: block;
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9em;
}
}
.timeline {
transition: opacity 0.3s ease-in-out;
}
.timeline.loading {
pointer-events: none;
opacity: 0.2;
}
.timeline-link-mentions {
.status .card {
display: none;
}
}
}

View file

@ -1,13 +1,16 @@
import '../components/links-bar.css';
import './trending.css';
import { MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { useMemo, useRef, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import punycode from 'punycode/';
import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Menu2 from '../components/menu2';
import RelativeTime from '../components/relative-time';
import Timeline from '../components/timeline';
@ -16,22 +19,46 @@ import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import { filteredItems } from '../utils/filters';
import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import states, { saveStatus } from '../utils/states';
import supports from '../utils/supports';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
const TREND_CACHE_TIME = 10 * 60 * 1000; // 10 minutes
const fetchLinks = pmem(
(masto) => {
return masto.v1.trends.links.list().next();
},
{
// News last much longer
maxAge: 10 * 60 * 1000, // 10 minutes
maxAge: TREND_CACHE_TIME,
},
);
const fetchHashtags = pmem(
(masto) => {
return masto.v1.trends.tags.list().next();
},
{
maxAge: TREND_CACHE_TIME,
},
);
function fetchTrendsStatuses(masto) {
if (supports('@pixelfed/trending')) {
return masto.pixelfed.v2.discover.posts.trending.list({
range: 'daily',
});
}
return masto.v1.trends.statuses.list({
limit: LIMIT,
});
}
function fetchLinkList(masto, params) {
return masto.v1.timelines.link.list(params);
}
function Trending({ columnMode, ...props }) {
const snapStates = useSnapshot(states);
const params = columnMode ? {} : useParams();
@ -44,39 +71,45 @@ function Trending({ columnMode, ...props }) {
// const navigate = useNavigate();
const latestItem = useRef();
const sameCurrentInstance = instance === currentInstance;
const [hashtags, setHashtags] = useState([]);
const [links, setLinks] = useState([]);
const trendIterator = useRef();
async function fetchTrend(firstLoad) {
async function fetchTrends(firstLoad) {
console.log('fetchTrend', firstLoad);
if (firstLoad || !trendIterator.current) {
trendIterator.current = masto.v1.trends.statuses.list({
limit: LIMIT,
});
trendIterator.current = fetchTrendsStatuses(masto);
// Get hashtags
try {
const iterator = masto.v1.trends.tags.list();
const { value: tags } = await iterator.next();
console.log('tags', tags);
if (tags?.length) {
setHashtags(tags);
if (supports('@mastodon/trending-hashtags')) {
try {
// const iterator = masto.v1.trends.tags.list();
const { value: tags } = await fetchHashtags(masto);
console.log('tags', tags);
if (tags?.length) {
setHashtags(tags);
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
// Get links
try {
const { value } = await fetchLinks(masto, instance);
// 4 types available: link, photo, video, rich
// Only want links for now
const links = value?.filter?.((link) => link.type === 'link');
console.log('links', links);
if (links?.length) {
setLinks(links);
if (supports('@mastodon/trending-links')) {
try {
const { value } = await fetchLinks(masto, instance);
// 4 types available: link, photo, video, rich
// Only want links for now
const links = value?.filter?.((link) => link.type === 'link');
console.log('links', links);
if (links?.length) {
setLinks(links);
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
}
const results = await trendIterator.current.next();
@ -97,6 +130,53 @@ function Trending({ columnMode, ...props }) {
};
}
// Link mentions
// https://github.com/mastodon/mastodon/pull/30381
const [currentLinkMentionsLoading, setCurrentLinkMentionsLoading] =
useState(false);
const currentLinkMentionsIterator = useRef();
const [currentLink, setCurrentLink] = useState(null);
const hasCurrentLink = !!currentLink;
const currentLinkRef = useRef();
const supportsTrendingLinkPosts =
sameCurrentInstance && supports('@mastodon/trending-hashtags');
useEffect(() => {
if (currentLink && currentLinkRef.current) {
currentLinkRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
}, [currentLink]);
const prevCurrentLink = useRef();
async function fetchLinkMentions(firstLoad) {
if (firstLoad || !currentLinkMentionsIterator.current) {
setCurrentLinkMentionsLoading(true);
currentLinkMentionsIterator.current = fetchLinkList(masto, {
url: currentLink,
});
}
prevCurrentLink.current = currentLink;
const results = await currentLinkMentionsIterator.current.next();
let { value } = results;
if (value?.length) {
value = filteredItems(value, 'public');
value.forEach((item) => {
saveStatus(item, instance);
});
}
if (prevCurrentLink.current === currentLink) {
setCurrentLinkMentionsLoading(false);
}
return {
...results,
value,
};
}
async function checkForUpdates() {
try {
const results = await masto.v1.trends.statuses
@ -129,7 +209,7 @@ function Trending({ columnMode, ...props }) {
const total = history.reduce((acc, cur) => acc + +cur.uses, 0);
return (
<Link to={`/${instance}/t/${name}`} key={name}>
<span>
<span dir="auto">
<span class="more-insignificant">#</span>
{name}
</span>
@ -161,9 +241,11 @@ function Trending({ columnMode, ...props }) {
url,
width,
} = link;
const domain = new URL(url).hostname
.replace(/^www\./, '')
.replace(/\/$/, '');
const domain = punycode.toUnicode(
URL.parse(url)
.hostname.replace(/^www\./, '')
.replace(/\/$/, ''),
);
let accentColor;
if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash);
@ -176,67 +258,134 @@ function Trending({ columnMode, ...props }) {
}
return (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
style={
accentColor
? {
'--accent-color': `rgb(${accentColor.join(',')})`,
'--accent-alpha-color': `rgba(${accentColor.join(
',',
)}, 0.4)`,
}
: {}
}
>
<article>
<figure>
<img
src={image}
alt={imageDescription}
width={width}
height={height}
loading="lazy"
/>
</figure>
<div class="article-body">
<header>
<div class="article-meta">
<span class="domain">{domain}</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime
datetime={publishedAt}
format="micro"
/>
</>
<div key={url}>
<a
ref={currentLink === url ? currentLinkRef : null}
href={url}
target="_blank"
rel="noopener noreferrer"
class={
hasCurrentLink
? currentLink === url
? 'active'
: 'inactive'
: ''
}
style={
accentColor
? {
'--accent-color': `rgb(${accentColor.join(',')})`,
'--accent-alpha-color': `rgba(${accentColor.join(
',',
)}, 0.4)`,
}
: {}
}
>
<article>
<figure>
<img
src={image}
alt={imageDescription}
width={width}
height={height}
loading="lazy"
/>
</figure>
<div class="article-body">
<header>
<div class="article-meta">
<span class="domain">{domain}</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime
datetime={publishedAt}
format="micro"
/>
</>
)}
</div>
{!!title && (
<h1
class="title"
lang={language}
dir="auto"
title={title}
>
{title}
</h1>
)}
</div>
{!!title && (
<h1 class="title" lang={language} dir="auto">
{title}
</h1>
</header>
{!!description && (
<p
class="description"
lang={language}
dir="auto"
title={description}
>
{description}
</p>
)}
</header>
{!!description && (
<p class="description" lang={language} dir="auto">
{description}
</p>
)}
</div>
</article>
</a>
</div>
</article>
</a>
{supportsTrendingLinkPosts && (
<button
type="button"
class="small plain4 block"
onClick={() => {
setCurrentLink(url);
}}
disabled={url === currentLink}
>
<Icon icon="comment2" /> <span>Mentions</span>{' '}
<Icon icon="chevron-down" />
</button>
)}
</div>
);
})}
</div>
)}
{supportsTrendingLinkPosts && !!links.length && (
<div
class={`timeline-header-block ${hasCurrentLink ? 'blended' : ''}`}
>
{hasCurrentLink ? (
<>
<div style={{ width: 50, flexShrink: 0, textAlign: 'center' }}>
{currentLinkMentionsLoading ? (
<Loader abrupt />
) : (
<button
type="button"
class="light"
onClick={() => {
setCurrentLink(null);
}}
>
<Icon icon="x" />
</button>
)}
</div>
<p>
Showing posts mentioning{' '}
<span class="link-text">
{currentLink
.replace(/^https?:\/\/(www\.)?/i, '')
.replace(/\/$/, '')}
</span>
</p>
</>
) : (
<p class="insignificant">Trending posts</p>
)}
</div>
)}
</>
);
}, [hashtags, links]);
}, [hashtags, links, currentLink, currentLinkMentionsLoading]);
return (
<Timeline
@ -252,8 +401,8 @@ function Trending({ columnMode, ...props }) {
instance={instance}
emptyText="No trending posts."
errorText="Unable to load posts"
fetchItems={fetchTrend}
checkForUpdates={checkForUpdates}
fetchItems={hasCurrentLink ? fetchLinkMentions : fetchTrends}
checkForUpdates={hasCurrentLink ? undefined : checkForUpdates}
checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes
useItemID
headerStart={<></>}
@ -261,6 +410,9 @@ function Trending({ columnMode, ...props }) {
// allowFilters
filterContext="public"
timelineStart={TimelineStart}
refresh={currentLink}
clearWhenRefresh
view={hasCurrentLink ? 'link-mentions' : undefined}
headerEnd={
<Menu2
portal

View file

@ -140,7 +140,7 @@
height: auto;
max-height: none;
position: fixed;
left: 0;
inset-inline-start: 0;
top: 0;
bottom: 0;
width: 50%;
@ -153,8 +153,9 @@
}
#why-container {
padding: 32px 32px 32px 8px;
margin-left: 50%;
padding: 32px;
padding-inline-start: 8px;
margin-inline-start: 50%;
/* overflow: auto;
mask-image: linear-gradient(to top, transparent 16px, black 64px); */

Some files were not shown because too many files have changed in this diff Show more