Porównaj commity

...

438 Commity

Autor SHA1 Wiadomość Data
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
Lim Chee Aun c005745ad0 Fix links layout in embed modal 2024-03-06 19:17:03 +08:00
Lim Chee Aun 0b81b5bfd2 Add menu item to copy handle 2024-03-06 16:51:13 +08:00
Lim Chee Aun b48d32e503 Fix spoiler not working for media 2024-03-06 14:26:01 +08:00
Lim Chee Aun ed309b289f Add categories 2024-03-06 14:25:46 +08:00
Lim Chee Aun ecc5fc5bbe Remove content-visibility, this crops some elements 2024-03-05 23:41:26 +08:00
Lim Chee Aun 7eb77f5d1b Larger separator even for mobile 2024-03-05 23:40:57 +08:00
Lim Chee Aun 3f4832965d Extracting stuff for now 2024-03-05 23:30:12 +08:00
Lim Chee Aun b7ed27ef70 Small catch-up adjustments 2024-03-05 20:56:37 +08:00
Lim Chee Aun c9a48cf482 New .plain6
I honestly need better naming sense
2024-03-05 19:11:50 +08:00
Lim Chee Aun c0ad216227 Merge sort order into sort buttons 2024-03-05 19:11:28 +08:00
Lim Chee Aun 8a9f1a3c25 Fix 2 history icons conflict 2024-03-05 16:23:16 +08:00
Lim Chee Aun 375c4b5d00 Upgrade vite 2024-03-05 16:22:55 +08:00
Lim Chee Aun f522d8e932 Basic j/k keyboard shortcuts for Catch-up 2024-03-05 15:05:26 +08:00
Lim Chee Aun bd46af6166 UI enhancements for Catch-up 2024-03-05 13:32:40 +08:00
Lim Chee Aun 29e9e15d3f Try split it out as another chunk 2024-03-05 00:51:53 +08:00
Lim Chee Aun 42dac0720f Revert "Conditional import polyfill"
This reverts commit 427207ae5a.
2024-03-04 23:41:21 +08:00
Lim Chee Aun d348c458b3 Blurred menu will be opt-in 2024-03-04 21:13:57 +08:00
Lim Chee Aun 427207ae5a Conditional import polyfill 2024-03-04 19:45:57 +08:00
Lim Chee Aun 531147cbc3 It's time for Intl.Segmenter
Remove runes2
2024-03-04 19:38:46 +08:00
Lim Chee Aun e0c2570875 Temporarily disable line to fix sub menu not opening 2024-03-04 17:29:28 +08:00
Lim Chee Aun 2b2f6c28a9 Time to re-organize this main menu
Will need to gather feedback
2024-03-04 16:41:06 +08:00
Lim Chee Aun 4a9cae9cb6 Experiment some Suspense
This splits code, lazy load the other less-critical components
2024-03-04 16:37:34 +08:00
Lim Chee Aun c578b41105 Only show setting if logged-in 2024-03-04 16:36:34 +08:00
Lim Chee Aun cfdbecc608 Better "back" buttons for Catch-up 2024-03-04 14:37:03 +08:00
Lim Chee Aun 7c81548320 Help section for Catch-up 2024-03-04 14:36:47 +08:00
Lim Chee Aun 8cab77415e Only show share and embed if public or unlisted
Also slight refactor
2024-03-04 09:56:38 +08:00
Lim Chee Aun 8b36cef510 Proper passing of props 2024-03-04 09:52:22 +08:00
Lim Chee Aun 4e67edac5e data-id was meant for debugging, removing it 2024-03-03 21:35:44 +08:00
Lim Chee Aun 0bf5ef52ac Only add more gap if there's enough space 2024-03-03 21:35:23 +08:00
Lim Chee Aun 7a7d51f56e Fix the post counts messed up in smaller viewports 2024-03-03 17:44:04 +08:00
Lim Chee Aun 48e1a0753a Make danger menu item more dangerous 2024-03-03 17:41:30 +08:00
Lim Chee Aun 195c2e2960 Turns out this was under the avatar, hmmmm 2024-03-03 17:37:34 +08:00
Lim Chee Aun 60c0d1cca0 Upgrade valtio 2024-03-03 17:31:37 +08:00
Lim Chee Aun 6292557bc9 Default modal to light, add solid class instead 2024-03-03 17:31:06 +08:00
Lim Chee Aun b79ce92aef Use acct instead of username 2024-03-03 17:16:58 +08:00
Lim Chee Aun 6bb6b9c350 Upgrade masto 2024-03-03 17:16:58 +08:00
Chee Aun 0b4c720153
Merge pull request #431 from cvennevik/perf-modal-backdrop-filter
(performance) Remove backdrop-filter blur and saturate effects from modals
2024-03-03 17:16:29 +08:00
Chee Aun 02d1339b29
Merge pull request #430 from cvennevik/perf-notification-icons
(performance) Remove backdrop-filter blur and saturate effect from .account-sub-icons
2024-03-03 17:16:20 +08:00
Lim Chee Aun 93c871353a Fix status actions close when focused 2024-03-03 11:01:11 +08:00
Lim Chee Aun 641d22a7cc Default density sort to desc 2024-03-03 09:48:53 +08:00
Lim Chee Aun 0fd378811f Fix range order 2024-03-02 21:53:03 +08:00
Lim Chee Aun afb1f6d520 Perf fixes + 3d posts viz 2024-03-02 21:25:54 +08:00
Lim Chee Aun fcb0074f49 Experimental Embed post 2024-03-02 18:55:05 +08:00
Lim Chee Aun 8108151fb6 Fix getComputedStyle running on undefined/null element 2024-03-02 18:54:27 +08:00
Lim Chee Aun d8b0adfe97 Prevent embeds from playing inline 2024-03-02 18:53:35 +08:00
Lim Chee Aun cef4e6373e Add 404 page 2024-03-02 13:53:53 +08:00
Lim Chee Aun 4d138f5773 Upgrade dependencies 2024-03-02 11:23:23 +08:00
Lim Chee Aun 0db10bf7d0 More adaptive copy 2024-03-02 10:08:10 +08:00
Lim Chee Aun 7ab6da5e9b Relayout the previous catchups list 2024-03-02 10:01:22 +08:00
Lim Chee Aun beed3ca18c Fix cloak mode showing ghost text 2024-03-02 10:01:04 +08:00
Lim Chee Aun abd5031602 "What is this" section for Catch-up 2024-03-02 10:00:45 +08:00
Lim Chee Aun 346dba9ed7 Sort by density 2024-03-01 16:03:45 +08:00
Lim Chee Aun 0ceb6ffd06 Tooltip for authors showing display name and username 2024-03-01 16:03:07 +08:00
Lim Chee Aun 488aece050 Better z-indices for the media 2024-03-01 16:02:27 +08:00
Lim Chee Aun ecde88d6a1 Fix weird jump when height of list changes 2024-03-01 16:02:08 +08:00
Lim Chee Aun 94dcd1606a Make toast stay longer, due to longer text 2024-03-01 13:20:34 +08:00
Lim Chee Aun b479fa1f35 Don't scroll vertical 2024-03-01 13:20:12 +08:00
Lim Chee Aun ab0472de02 Fix some links not opening browser's context menu 2024-03-01 10:29:38 +08:00
Lim Chee Aun 1bf8616957 Auto-scroll to selected author 2024-02-29 21:01:31 +08:00
Lim Chee Aun 631333ba9e Cache custom emojis 2024-02-29 18:18:40 +08:00
Lim Chee Aun 69d77c368e Experiment longer captions for no-content single-media post 2024-02-29 13:25:30 +08:00
Lim Chee Aun bb3621e424 Make loader abrupt if >= 3 replies 2024-02-29 13:19:41 +08:00
Lim Chee Aun e1447053b3 Upgrade dependencies 2024-02-29 10:12:05 +08:00
Lim Chee Aun aaf64bbc34 More cloak fixes 2024-02-28 15:34:11 +08:00
Lim Chee Aun 52b60fa38b Respect filters for reply hints 2024-02-28 15:04:01 +08:00
Lim Chee Aun 3acfc00ec0 Don't show toast when not on results page 2024-02-28 11:49:07 +08:00
Lim Chee Aun f8b5e9563c Fix trend links not respecting set instance 2024-02-28 11:27:48 +08:00
Lim Chee Aun 6f3f83a620 Catching up with fixes and enhancements 2024-02-28 11:01:09 +08:00
Lim Chee Aun 315ce98511 Fix cloak for catch-up 2024-02-27 23:29:54 +08:00
Lim Chee Aun 3cfc35898b Slight adjustments 2024-02-27 21:53:08 +08:00
Lim Chee Aun ffc216cfed Fix account info not re-rendering correctly when id changed 2024-02-27 21:24:38 +08:00
Lim Chee Aun 35e34c0bc6 Remove space 2024-02-27 21:23:46 +08:00
Lim Chee Aun b023a43fee Fix weird rendering on Safari 2024-02-27 18:02:12 +08:00
Lim Chee Aun 44f6d9cda0 Remove unused code 2024-02-27 18:02:00 +08:00
Lim Chee Aun c466e0c279 Broken image fallbacks 2024-02-27 18:01:47 +08:00
cvennevik fa99debabd (performance) Remove backdrop-filter blur and saturate effects from modals 2024-02-26 19:37:14 +01:00
cvennevik 58778aba45 (perf) Remove backdrop-filter blur effect from .account-sub-icons 2024-02-26 19:14:29 +01:00
Lim Chee Aun b913c8817d Fix wrong icon size 2024-02-26 21:44:45 +08:00
Lim Chee Aun ffb7ce1c63 Quick style adjusts 2024-02-26 21:13:17 +08:00
Lim Chee Aun 707b51a1a0 Don't trigger auto list if meta/ctrl+enter 2024-02-26 14:57:09 +08:00
Lim Chee Aun 201ca6ce4a Catch-up (beta) 2024-02-26 14:02:58 +08:00
Lim Chee Aun a419bb9b61 Fix small typo 2024-02-26 14:02:12 +08:00
Lim Chee Aun a8b5c8cd64 Experimental "cloud" shortcuts settings import/export 2024-02-26 14:00:53 +08:00
Lim Chee Aun a3236ea0f0 Report post/profile 2024-02-26 13:59:26 +08:00
Lim Chee Aun c595b0ee31 Fix toasts showing for unauthenticated interactions 2024-02-26 11:58:22 +08:00
Lim Chee Aun 89f34d7942 Use em, and hide if there's nothing in account "note" 2024-02-26 11:56:18 +08:00
Lim Chee Aun f23e4b0dd9 Just in case, probably not needed 2024-02-25 13:37:50 +08:00
Lim Chee Aun e7d2d088ba Super weird fix for clicks "leaked" to the container 2024-02-25 13:37:29 +08:00
Lim Chee Aun bf609b979e Upgrade masto 2024-02-24 14:00:28 +08:00
Lim Chee Aun 6a6162ec6e Use readOnly, respect CWs for statuses in notifications 2024-02-23 18:07:42 +08:00
Lim Chee Aun 03e5c3ff54 Disable text-rendering: optimizeSpeed
It causes text to jump. It has different kerning when optimized for speed
2024-02-23 18:05:39 +08:00
Lim Chee Aun d8e824b548 Upgrade dependencies 2024-02-23 18:03:26 +08:00
Lim Chee Aun e5d36b82bb Fix search suggestion sort 2024-02-23 18:00:30 +08:00
Lim Chee Aun b6721fc58f Change pin icon color
It gets easily confused with heart icon
2024-02-22 14:21:47 +08:00
Lim Chee Aun 246862e0a4 Upgrade other dependencies 2024-02-21 09:56:55 +08:00
Lim Chee Aun 65e048be17 Downgrade preact due to some weird bugs 2024-02-21 09:56:55 +08:00
Lim Chee Aun cd96ba0c59 Isolate bidi for name text 2024-02-21 09:56:55 +08:00
Lim Chee Aun 9803d18185 Speed up the fade 2024-02-21 09:56:55 +08:00
Chee Aun fefc121b11
Merge pull request #422 from Ganneff/Ganneff-add-fulda.social
Add another phanpy instance url
2024-02-19 17:59:22 +08:00
Joerg Jaspert 3d08349851
Add another phanpy instance url
Adding fulda.social to the list of self-hosted instances.
2024-02-18 21:48:23 +01:00
Lim Chee Aun 1478aca7a5 Need the stripes for PMs 2024-02-18 09:38:54 +08:00
Lim Chee Aun dab0d61ac8 Allow double-click to refresh on Notifications page 2024-02-17 16:50:13 +08:00
Lim Chee Aun 14b92f3f98 Switch to the list from joinmastodon.org/servers 2024-02-17 16:49:50 +08:00
Lim Chee Aun 49cdba2652 Upgrade dependencies again. Last Preact version was causing weird bugs. 2024-02-17 00:27:21 +08:00
Lim Chee Aun 2f94cb34f6 Fix post content not updating when changed 2024-02-16 17:36:46 +08:00
Lim Chee Aun b7a79c8fdd Better memo for Notification 2024-02-15 18:07:17 +08:00
Lim Chee Aun 2f0d04eca4 Update instances list, fix script bug 2024-02-15 17:53:35 +08:00
Lim Chee Aun daabd85273 Upgrade dependencies 2024-02-15 14:59:11 +08:00
Lim Chee Aun c84ad73d0d More memoization 2024-02-14 17:17:15 +08:00
Lim Chee Aun 3295b1ab96 Remove the need for setStates 2024-02-14 17:16:53 +08:00
Chee Aun 5d7b67a410
Merge pull request #417 from Fastidious/patch-1
Update README.md
2024-02-14 01:28:12 +08:00
Fastidious 4e85f92f4b
Update README.md
Changed URL of my self hosted instance.
2024-02-13 10:08:40 -05:00
Lim Chee Aun 9d80647b11 Upgrade dependencies 2024-02-13 22:05:26 +08:00
Lim Chee Aun 24a481b782 Back to end 2024-02-12 18:59:04 +08:00
Lim Chee Aun 97cce8a828 Slightly faster bg transition 2024-02-12 11:54:47 +08:00
Lim Chee Aun 3c31c56306 Fine-tuning status actions styles 2024-02-12 11:53:59 +08:00
Lim Chee Aun 92f4371041 More granular hover/focus state for status actions 2024-02-11 22:46:21 +08:00
Lim Chee Aun a9d0100087 Stripes if PM 2024-02-11 21:04:30 +08:00
Lim Chee Aun 3fbe11295f Don't use dvh for this 2024-02-10 22:22:25 +08:00
Lim Chee Aun 98f018913d Test change to :focus 2024-02-10 20:21:03 +08:00
Lim Chee Aun 60ca577f9b Slight adjustments to status actions 2024-02-10 12:01:51 +08:00
Lim Chee Aun 1d0d02f39b Different alignment for status action menu 2024-02-10 12:00:40 +08:00
Lim Chee Aun fbd448c152 Add one more smaller text size option 2024-02-09 20:07:16 +08:00
Lim Chee Aun 038b2b2e6b Upgrade vite and dependencies 2024-02-09 20:07:06 +08:00
Lim Chee Aun 169aa2d3d3 Fix boost icon color in new status menu 2024-02-08 01:12:02 +08:00
Lim Chee Aun 9a9667d824 Redesign the context menu 2024-02-06 17:34:26 +08:00
Lim Chee Aun afd9d2cf97 Slight style adjustments 2024-02-06 17:32:17 +08:00
Lim Chee Aun b9c287b29e Don't show icon, just show text for visibility.
Icon, in the end, ain't descriptive enough.
2024-02-06 17:30:58 +08:00
Lim Chee Aun 436277c6b4 Prevent re-render dangerouslySetInnerHTML 2024-02-06 17:30:10 +08:00
Lim Chee Aun 4f28d3cc6d Less bolder bold 2024-02-06 17:28:18 +08:00
Lim Chee Aun 46415b87a6 Show lists containing the account in the menu 2024-02-05 10:17:49 +08:00
Lim Chee Aun 913d923877 Make grouped subsequent hashtag pre-meta more seamless 2024-02-04 19:38:22 +08:00
Lim Chee Aun 36f38230c4 Attempt to shorten links if not shortened
This usually comes from non-Mastodon instances
2024-02-03 20:36:25 +08:00
Lim Chee Aun a66a4e238e More subtle style change to reply parent 2024-02-02 13:20:55 +08:00
Lim Chee Aun aa7fb4441f Subtle style change to reply parent 2024-02-02 12:58:35 +08:00
Lim Chee Aun f1dbb9ec42 Further delay filtered status peek, remove tooltip 2024-02-02 00:27:12 +08:00
Lim Chee Aun a59668ea9a Slight adjustment to carousel colors 2024-02-01 22:49:16 +08:00
Lim Chee Aun 6581bc2881 Prevent reply parent hint from being GC-ed 2024-01-31 13:45:34 +08:00
Lim Chee Aun 28bb66f185 Show total at end of list 2024-01-31 09:03:33 +08:00
Lim Chee Aun 46d7cba1ea Show join date if there's nothing to show 2024-01-30 22:46:18 +08:00
Lim Chee Aun ff35c458c3 Don't return 2024-01-30 18:57:28 +08:00
Lim Chee Aun 26d445af7d Fix reply parent hint not appearing
Also respect language
2024-01-30 17:43:44 +08:00
Lim Chee Aun 3470b9adec Fix forgot to opt-in new experiment 2024-01-30 15:22:01 +08:00
Lim Chee Aun f3d77dd04e Experimental reply parent hint 2024-01-30 14:34:54 +08:00
Lim Chee Aun 14f5c37721 Don't show comment hint for timeline item container 2024-01-30 14:28:28 +08:00
Lim Chee Aun 94c59c47d1 Upgrade dependencies 2024-01-29 21:11:19 +08:00
Lim Chee Aun a66307b757 Fixes + improvements to search UI 2024-01-29 21:11:08 +08:00
Lim Chee Aun 9792700f30 Fix wrong CSS
Add more checks
2024-01-29 01:38:53 +08:00
Lim Chee Aun 36e852bebb Fix weird overflow: clip bug on Chrome 2024-01-28 00:49:11 +08:00
Lim Chee Aun 6075542071 Exclude the JS-injected hashtag stuffing class 2024-01-26 16:09:21 +08:00
Lim Chee Aun 0386357688 Fix weird bug with wrong cache of icon 2024-01-26 00:28:03 +08:00
Lim Chee Aun 9cac63c37d Experimental more-harsh hashtag stuffing collapsing 2024-01-25 22:13:38 +08:00
Lim Chee Aun 5cfcfdc98b Squeeze all the micro-perf 2024-01-25 21:28:41 +08:00
Lim Chee Aun a2d995ec07 Support unofficial status.quote 2024-01-25 12:59:53 +08:00
Lim Chee Aun 4ca9a802e3 Remove console.log 2024-01-25 08:00:55 +08:00
Lim Chee Aun 990f2b2e29 Handle unknown audio attachments 2024-01-24 13:08:54 +08:00
Lim Chee Aun 725da37063 Slight adjustments to post actions bar 2024-01-21 13:10:57 +08:00
Lim Chee Aun 1b41d39032 Stretch svg dimensions 2024-01-20 10:26:01 +08:00
Lim Chee Aun 23dd7f5a7a Extract ICONS out 2024-01-20 10:25:47 +08:00
Lim Chee Aun 7d95c50c7a Remove width/height in svg 2024-01-20 01:45:54 +08:00
Lim Chee Aun a352f94c2c Use more beautiful quotes 2024-01-20 01:45:36 +08:00
Lim Chee Aun 38e2b176bc Make embeds larger 2024-01-19 20:31:05 +08:00
Lim Chee Aun 6b4c1c8505 Change menu alignment 2024-01-19 20:29:46 +08:00
Lim Chee Aun 46dfd9aab0 MVP-ish pin/unpin post 2024-01-18 19:05:12 +08:00
Lim Chee Aun 59d0138ca8 If there's selected text, don't show custom context menu 2024-01-17 13:42:46 +08:00
Lim Chee Aun 3fbd5b8622 s/allowNofitications/allowNotifications
Also very embarrassing
2024-01-17 11:32:16 +08:00
Lim Chee Aun b6c4045cb4 Escape HTML chars in composer highlights
This is very embarrassing, I know
2024-01-17 11:31:33 +08:00
Lim Chee Aun 37c784dad2 Make refresh button more prominent 2024-01-16 15:47:10 +08:00
Lim Chee Aun 04d431cf71 Add more conditions 2024-01-15 22:05:18 +08:00
Lim Chee Aun 97458b66eb Update languages list 2024-01-15 20:39:29 +08:00
Lim Chee Aun fadfc6052d Only show for coarse pointer 2024-01-15 00:31:42 +08:00
Lim Chee Aun 0ca92e7509 Fix icon alignment in shortcut settings 2024-01-14 23:04:14 +08:00
Lim Chee Aun b8484eff79 Differentiate menu open from right-click vs actions bar
Kinda hacky for now
2024-01-14 21:34:21 +08:00
Lim Chee Aun 1017d1d270 Style changes for focused more button 2024-01-14 21:33:52 +08:00
Lim Chee Aun 04179340f6 Further enhance actions bar
- Focus color when context menu is open
- Focus color for more button when context menu is open
- Reuse menu instead of creating another menu
- Show like toast when liked/unliked
2024-01-14 19:36:14 +08:00
Lim Chee Aun 9b0889fe23 Test show refresh button after a minute 2024-01-14 18:31:53 +08:00
Lim Chee Aun 79e87b7d89 A little transition when expanding replies 2024-01-14 18:29:11 +08:00
Lim Chee Aun 0ebc0fa64c First step in introducing actions bar 2024-01-14 00:32:08 +08:00
Lim Chee Aun 35974cc89c Show more consistent icon for "comment" 2024-01-14 00:30:12 +08:00
Lim Chee Aun 00675c827f Upgrade react-hotkeys-hook 2024-01-14 00:29:30 +08:00
Lim Chee Aun 2b3f65f28c Fix wrong account shown
Need the hostname to be more accurate
2024-01-12 14:47:59 +08:00
Lim Chee Aun 500f877d4b Fix error when r is undefined 2024-01-11 10:44:37 +08:00
Lim Chee Aun 4b9ff0ca5b Hide "more" icon for posts in notifications 2024-01-11 10:44:24 +08:00
Lim Chee Aun 07f927d4ff Add notice if there's only 1 shortcut 2024-01-10 14:48:29 +08:00
Lim Chee Aun 8c6563a671 More contextual copy 2024-01-10 14:48:08 +08:00
Lim Chee Aun ffabd6188d Truncate URLs 2024-01-10 01:48:20 +08:00
Lim Chee Aun d71b1a7e36 Test add "more" icon near timestamp 2024-01-10 01:47:50 +08:00
Lim Chee Aun c47687e2e4 Fix / and ? key shortcuts suddenly not working 2024-01-10 00:03:36 +08:00
Lim Chee Aun 5b0d6dd58b Upgrade dependencies 2024-01-09 23:47:21 +08:00
Lim Chee Aun ecd5c7b91e . (period) keyboard shortcut = load new posts 2024-01-09 23:47:21 +08:00
116 zmienionych plików z 16670 dodań i 5168 usunięć

Wyświetl plik

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

1
.gitignore vendored
Wyświetl plik

@ -25,6 +25,5 @@ dist-ssr
# Custom
.env.dev
src/data/instances-full.json
phanpy-dist.zip
phanpy-dist.tar.gz

Wyświetl plik

@ -103,8 +103,7 @@ Prerequisites: Node.js 18+
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview the production build
- `npm run fetch-instances` - Fetch instances list from [instances.social](https://instances.social/), save it to `src/data/instances.json`
- requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set
- `npm run fetch-instances` - Fetch instances list from [joinmastodon.org/servers](https://joinmastodon.org/servers), save it to `src/data/instances.json`
- `npm run sourcemap` - Run `source-map-explorer` on the production build
## Tech stack
@ -139,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
```
@ -180,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
@ -193,13 +199,18 @@ See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva
These are self-hosted by other wonderful folks.
- [ferengi.one](https://ferengi.one/) by [@david@collantes.social](https://collantes.social/@david)
- [ferengi.one](https://m.ferengi.one/) by [@david@collantes.social](https://collantes.social/@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)
- [phanpy.gotosocial.social](https://phanpy.gotosocial.social/) by [@admin@gotosocial.social](https://gotosocial.social/@admin)
- [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)
> Note: Add yours by creating a pull request.
@ -235,6 +246,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 +263,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

4419
package-lock.json wygenerowano

Plik diff jest za duży Load Diff

Wyświetl plik

@ -6,60 +6,64 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"fetch-instances": "env $(cat .env.dev | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
"fetch-instances": "env $(cat .env.local | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
"sourcemap": "npx source-map-explorer dist/assets/*.js"
},
"dependencies": {
"@formatjs/intl-localematcher": "~0.5.2",
"@formkit/auto-animate": "~0.8.1",
"@github/text-expander-element": "~2.6.1",
"@formatjs/intl-localematcher": "~0.5.4",
"@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",
"dayjs": "~1.11.11",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-equals": "~5.0.1",
"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.5.1",
"masto": "~6.7.7",
"moize": "~6.1.6",
"p-retry": "~6.2.0",
"p-throttle": "~6.1.0",
"preact": "~10.19.3",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.3",
"preact": "~10.22.0",
"punycode": "~2.3.1",
"react-hotkeys-hook": "~4.5.0",
"react-intersection-observer": "~9.10.2",
"react-quick-pinch-zoom": "~5.1.0",
"react-router-dom": "6.6.2",
"runes2": "~1.1.3",
"string-length": "5.0.1",
"swiped-events": "~1.1.9",
"string-length": "6.0.0",
"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.1",
"use-long-press": "~3.2.0",
"use-resize-observer": "~9.1.0",
"valtio": "1.9.0"
"valtio": "1.13.2"
},
"devDependencies": {
"@preact/preset-vite": "~2.7.0",
"@preact/preset-vite": "~2.8.2",
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
"postcss": "~8.4.32",
"postcss-dark-theme-class": "~1.1.0",
"postcss-preset-env": "~9.3.0",
"postcss": "~8.4.38",
"postcss-dark-theme-class": "~1.3.0",
"postcss-preset-env": "~9.5.14",
"twitter-text": "~3.1.0",
"vite": "~5.0.10",
"vite": "~5.2.12",
"vite-plugin-generate-file": "~0.1.1",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.17.4",
"vite-plugin-pwa": "~0.20.0",
"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": {

32
public/404.html 100644
Wyświetl plik

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>Page not found</title>
<meta name="color-scheme" content="dark light" />
<style>
body {
text-align: center;
font-family: ui-rounded, -apple-system, BlinkMacSystemFont, Segoe UI,
Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
}
h1 {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<h1>Page not found</h1>
<p><a href="/">Go home</a></p>
</body>
</html>

Wyświetl plik

@ -33,8 +33,9 @@ const imageRoute = new Route(
const isRemote = !sameOrigin;
const isImage = request.destination === 'image';
const isAvatar = request.url.includes('/avatars/');
const isCustomEmoji = request.url.includes('/custom/_emojis');
const isEmoji = request.url.includes('/emoji/');
return isRemote && isImage && (isAvatar || isEmoji);
return isRemote && isImage && (isAvatar || isCustomEmoji || isEmoji);
},
new CacheFirst({
cacheName: 'remote-images',
@ -61,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,
}),

Wyświetl plik

@ -1,34 +1,12 @@
import fs from 'fs';
const { INSTANCES_SOCIAL_SECRET_TOKEN } = process.env;
const params = new URLSearchParams({
count: 0,
min_users: 500,
sort_by: 'active_users',
sort_order: 'desc',
});
const url = `https://instances.social/api/1.0/instances/list?${params.toString()}`;
const results = await fetch(url, {
headers: {
Authorization: `Bearer ${INSTANCES_SOCIAL_SECRET_TOKEN}`,
},
});
const url = 'https://api.joinmastodon.org/servers';
const results = await fetch(url);
const json = await results.json();
// Filters
json.instances = json.instances.filter(
(instance) => Number(instance.connections) > 20,
);
const names = json.instances.map((instance) => instance.name);
const domains = json.map((instance) => instance.domain);
// Write to file
const path = './src/data/instances.json';
fs.writeFileSync(path, JSON.stringify(names, null, '\t'), 'utf8');
// Write everything to file, for debugging
const path2 = './src/data/instances-full.json';
fs.writeFileSync(path2, JSON.stringify(json, null, '\t'), 'utf8');
fs.writeFileSync(path, JSON.stringify(domains, null, '\t'), 'utf8');

Wyświetl plik

@ -34,6 +34,8 @@ a.mention span {
text-decoration-color: inherit;
text-decoration-thickness: 2px;
text-underline-offset: 2px;
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
}
/* a.mention:has(span).hashtag {
color: var(--link-light-color);
@ -103,6 +105,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
max-width: 100%;
background-color: var(--bg-color);
overflow-anchor: auto;
&.wide {
width: 60em;
}
}
.deck.contained {
overflow: auto;
@ -289,12 +295,47 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
video,
img,
audio {
min-height: 88px; /* for extreme dimensions */
min-height: var(--min-dimension); /* for extreme dimensions */
}
}
}
}
}
.deck-container-media-first {
.timeline {
> li:not(.timeline-item-carousel, .timeline-item-container) {
&:has(.status-media-first) {
@media (min-width: 40em) {
width: fit-content;
max-width: min(480px, 100%);
}
background-color: transparent !important;
border: 0 !important;
box-shadow: none !important;
margin-inline: auto !important;
&:not(:first-child) {
margin-block: 32px;
}
&:has(.skeleton) {
width: 100%;
}
}
&:has(.media[data-orientation='landscape']) {
max-width: 100%;
}
}
.status-link:has(.status-media-first):hover {
background-color: transparent;
}
}
}
.timeline.grow {
/* min-height: 100vh;
min-height: 100dvh; */
@ -645,6 +686,16 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
var(--bg-faded-color)
);
}
@keyframes summary-fade {
0% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.timeline.contextual > li .replies[open] > .replies-summary {
border-bottom-left-radius: 0;
@ -659,6 +710,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.replies-summary-chevron {
transform: rotate(180deg);
}
+ * {
animation: summary-fade 0.3s ease-out both;
}
}
.timeline.contextual > li .replies .replies-summary[hidden] {
display: none;
@ -901,7 +956,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
text-decoration-line: none;
color: inherit;
user-select: none;
transition: background-color 0.2s ease-out;
transition: background-color 0.1s ease-out;
-webkit-tap-highlight-color: transparent;
animation: appear 0.2s ease-out;
-webkit-touch-callout: none;
@ -914,9 +969,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
-webkit-touch-callout: none;
}
}
:is(.status-link, .status-focus):is(:focus, .is-active) {
background-color: var(--link-bg-hover-color);
outline-offset: -2px;
@media not (pointer: coarse) {
:is(.status-link, .status-focus):is(:focus, .is-active) {
background-color: var(--link-bg-hover-color);
outline-offset: -2px;
}
}
@media (hover: hover) {
.status-link:hover {
@ -933,7 +990,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
background: linear-gradient(
to bottom right,
var(--carousel-faded-color),
transparent 150%
transparent
);
position: relative;
container-type: inline-size;
@ -948,7 +1005,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
var(--carousel-faded-color),
transparent
),
linear-gradient(to top, var(--bg-color), transparent 64px);
linear-gradient(to top, var(--bg-color) 8px, transparent 64px);
background-repeat: no-repeat;
background-position: bottom center;
}
@ -1059,6 +1116,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.ui-state {
padding: 16px;
text-align: center;
.icon {
vertical-align: middle;
}
}
.status-carousel-link {
@ -1105,7 +1166,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
z-index: 1000;
display: flex;
background-color: var(--backdrop-color);
animation: appear 0.2s ease-out;
animation: appear 0.1s ease-out;
}
.deck-backdrop > a {
flex-grow: 1;
@ -1234,7 +1295,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
width: 70%;
flex-grow: 1;
background-color: var(--backdrop-solid-color);
animation: appear 0.3s var(--timing-function) both;
animation: appear 0.1s var(--timing-function) both;
transition: width 0.3s var(--timing-function);
&:only-child {
@ -1518,8 +1579,8 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
0 10px 36px -4px var(--button-bg-blur-color);
transition: all 0.3s ease-in-out;
}
.deck-container:has(header[hidden]) ~ #compose-button,
#compose-button[hidden] {
.deck-container:has(header[hidden]) ~ #compose-button:not(.loading),
#compose-button[hidden]:not(.loading) {
transform: translateY(200%);
pointer-events: none;
user-select: none;
@ -1548,6 +1609,48 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
bottom: calc(16px + env(safe-area-inset-bottom) + 52px);
}
}
#compose-button {
&.min {
outline: 2px solid var(--button-text-color);
z-index: 1001; /* Higher than modal */
&:after {
content: '';
display: block;
position: absolute;
top: 0;
right: 0;
width: 14px;
height: 14px;
border-radius: 50%;
background-color: var(--button-bg-color);
border: 2px solid var(--button-text-color);
box-shadow: 0 2px 8px var(--drop-shadow-color);
opacity: 0;
transition: opacity 0.2s ease-out 0.5s;
opacity: 1;
}
}
&.loading {
outline-color: var(--button-bg-blur-color);
&:before {
position: absolute;
inset: 0;
content: '';
border-radius: 50%;
animation: spin 5s linear infinite;
border: 2px dashed var(--button-text-color);
}
}
&.error {
&:after {
background-color: var(--red-color);
}
}
}
/* SHEET */
@ -1658,6 +1761,8 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
svg {
contain: none;
width: 100%;
height: 100%;
}
}
@ -1730,7 +1835,7 @@ body > .szh-menu-container {
max-width: 90vw;
/* overflow: hidden; */
}
.szh-menu[aria-label='Submenu'] {
.szh-menu[aria-label='Submenu'].menu-blur {
background-color: var(--bg-blur-color);
backdrop-filter: blur(4px);
box-shadow: 0 3px 24px -3px var(--drop-shadow-color);
@ -1762,6 +1867,7 @@ body > .szh-menu-container {
animation-duration: 0.3s;
animation-timing-function: ease-in-out;
width: auto;
min-width: min(12em, 90vw);
}
.szh-menu .footer {
margin: 8px 0 -8px;
@ -1782,7 +1888,7 @@ body > .szh-menu-container {
align-items: center;
line-height: 1.1;
padding: 8px 16px !important;
transition: all 0.1s ease-in-out;
/* transition: all 0.1s ease-in-out; */
text-decoration: none;
white-space: nowrap;
overflow: hidden;
@ -1829,6 +1935,7 @@ body > .szh-menu-container {
}
.szh-menu__divider {
background-color: var(--divider-color);
margin-block: 4px;
}
.szh-menu .szh-menu__item .menu-grow {
flex-grow: 1;
@ -1852,7 +1959,8 @@ body > .szh-menu-container {
/* two columns only */
grid-template-columns: repeat(2, 1fr);
}
.szh-menu .menu-horizontal:has(> .szh-menu__item:only-child) {
.szh-menu .menu-horizontal:has(> .szh-menu__item:only-child),
.szh-menu .menu-horizontal:has(> .szh-menu__submenu:only-child) {
grid-template-columns: 1fr;
}
.szh-menu .menu-horizontal > .szh-menu__item:not(:only-child):first-child,
@ -1895,7 +2003,15 @@ body > .szh-menu-container {
.szh-menu__item:not(.szh-menu__item--disabled):not(
.szh-menu__item--hover
).danger {
color: var(--red-color);
color: var(--red-text-color);
}
.szh-menu
.szh-menu__item.danger:not(.szh-menu__item--disabled).szh-menu__item--hover {
background-color: var(--red-text-color);
@media (prefers-color-scheme: dark) {
background-color: var(--red-color);
}
}
.szh-menu
.szh-menu__item:not(.szh-menu__item--disabled):not(
@ -1905,6 +2021,52 @@ body > .szh-menu-container {
opacity: 1;
}
.szh-menu {
.menu-control-group-horizontal {
display: grid;
/* auto columns */
grid-template-columns: repeat(auto-fit, minmax(44px, 1fr));
.szh-menu__item {
display: flex;
flex-direction: column;
padding: 8px !important;
gap: 2px;
.icon {
opacity: 1;
+ span {
font-size: 80%;
/* line-height: 1.2; */
width: 100%;
text-align: center;
opacity: 0.5;
text-overflow: clip;
mask-image: linear-gradient(to left, transparent, black 16px);
}
}
}
}
.menu-control-group-horizontal:first-child,
li[aria-hidden='true'] + .menu-control-group-horizontal {
margin-top: -8px;
margin-bottom: -4px;
.szh-menu__item {
padding-block: 12px !important;
}
> [class^='szh-menu']:first-child {
border-top-left-radius: 8px;
}
> [class^='szh-menu']:last-child {
border-top-right-radius: 8px;
}
}
}
.szh-menu
.szh-menu__item--type-checkbox:not(.szh-menu__item--disabled):not(
.szh-menu__item--hover
@ -1946,71 +2108,86 @@ body > .szh-menu-container {
text-shadow: none;
}
/* DONUT METER */
/* CHAR COUNTER */
meter.donut {
appearance: none;
}
meter.donut::-webkit-meter-inner-element,
meter.donut::-webkit-meter-bar,
meter.donut::-webkit-meter-optimum-value,
meter.donut::-webkit-meter-suboptimum-value,
meter.donut::-webkit-meter-even-less-good-value {
display: none;
}
meter.donut::-moz-meter-bar {
background: transparent;
}
meter.donut {
position: relative;
.char-counter {
--dimension: 24px;
--border-width: 2px;
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
width: var(--dimension);
height: var(--dimension);
border-radius: 50%;
--fill: calc(var(--percentage) * 1%);
--color: var(--link-color);
--middle-circle: radial-gradient(
circle at 50% 50%,
var(--bg-color) var(--middle-circle-radius),
transparent var(--middle-circle-radius)
);
background-image: var(--middle-circle),
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
transform: scale(0.7);
transition: transform 0.2s ease-in-out;
}
meter.donut.warning {
--color: var(--orange-color);
transform: scale(1);
}
meter.donut.danger {
--color: var(--red-color);
transform: scale(1);
}
meter.donut.explode {
background-image: none;
transform: scale(1);
}
meter.donut:is(.warning, .danger, .explode):after {
content: attr(data-left);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
color: var(--text-insignificant-color);
}
meter.donut:is(.danger, .explode):after {
color: var(--red-color);
}
meter.donut[hidden] {
min-width: var(--dimension);
min-height: var(--dimension);
position: relative;
display: inline-block;
visibility: hidden;
&[hidden] {
visibility: hidden;
}
* {
pointer-events: none;
}
meter {
appearance: none;
position: relative;
--border-width: 2px;
--middle-circle-radius: calc(var(--dimension) / 2 - var(--border-width));
width: var(--dimension);
height: var(--dimension);
border-radius: 50%;
--fill: calc(var(--percentage) * 1%);
--color: var(--link-color);
--middle-circle: radial-gradient(
circle at 50% 50%,
var(--bg-color) var(--middle-circle-radius),
transparent var(--middle-circle-radius)
);
background-image: var(--middle-circle),
conic-gradient(var(--color) var(--fill), var(--outline-color) 0);
transform: scale(0.7);
transition: transform 0.2s ease-in-out;
&::-webkit-meter-inner-element,
&::-webkit-meter-bar,
&::-webkit-meter-optimum-value,
&::-webkit-meter-suboptimum-value,
&::-webkit-meter-even-less-good-value {
display: none;
}
&::-moz-meter-bar {
background: transparent;
}
&.warning {
--color: var(--orange-color);
transform: scale(1);
}
&.danger {
--color: var(--red-color);
transform: scale(1);
}
&.explode {
background-image: none;
transform: scale(1);
}
&:is(.warning, .danger, .explode) + .counter {
opacity: 1;
color: var(--text-insignificant-color);
}
&:is(.danger, .explode) + .counter {
opacity: 1;
color: var(--red-color);
}
}
.counter {
line-height: 1;
opacity: 0;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
}
}
/* SHINY PILL */
@ -2039,7 +2216,7 @@ meter.donut[hidden] {
:root .toastify {
user-select: none;
padding: 8px 16px;
border-radius: 999px;
border-radius: 44px;
pointer-events: none;
color: var(--button-text-color);
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
@ -2048,6 +2225,8 @@ meter.donut[hidden] {
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
0 10px 36px -4px var(--button-bg-blur-color);
text-align: center;
width: fit-content;
max-width: calc(100vw - 32px);
}
.toastify-bottom {
margin-bottom: env(safe-area-inset-bottom);
@ -2208,10 +2387,10 @@ ul.link-list li a .icon {
filter: none !important;
}
.nav-menu-button .avatar {
transition: box-shadow 0.3s ease-out;
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color) !important;
}
.nav-menu-button:is(:hover, :focus, .active) .avatar {
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-light-color);
box-shadow: 0 0 0 2px var(--bg-color), 0 0 0 4px var(--link-color) !important;
}
.nav-menu-button.with-avatar .icon {
position: absolute;
@ -2595,6 +2774,10 @@ ul.link-list li a .icon {
box-shadow: 0px 1px var(--bg-blur-color);
transition: transform 0.4s var(--timing-function);
--back-transition: transform 0.4s ease-out;
&:is(:empty, :has(> a:empty)) {
display: none;
}
}
.timeline:not(.flat) > li > a {
border-radius: inherit;

Wyświetl plik

@ -14,7 +14,7 @@ import { subscribe } from 'valtio';
import BackgroundService from './components/background-service';
import ComposeButton from './components/compose-button';
import { ICONS } from './components/icon';
import { ICONS } from './components/ICONS';
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
import Loader from './components/loader';
import Modals from './components/modals';
@ -24,7 +24,9 @@ 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 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';
@ -51,7 +53,7 @@ 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 { getCurrentAccount, setCurrentAccountID } from './utils/store-utils';
import './utils/toast-alert';
window.__STATES__ = states;
@ -124,13 +126,13 @@ 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 {
ICONS[icon]?.();
}
});
}, 1);
}
}, 5000);
@ -323,11 +325,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');
@ -336,15 +338,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 {
@ -394,7 +396,7 @@ function PrimaryRoutes({ isLoggedIn, loading }) {
const location = useLocation();
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome)/.test(pathname);
return !/^\/(login|welcome)/i.test(pathname);
}, [location]);
return (
@ -456,7 +458,9 @@ function SecondaryRoutes({ isLoggedIn }) {
<Route index element={<Lists />} />
<Route path=":id" element={<List />} />
</Route>
<Route path="/ft" element={<FollowedHashtags />} />
<Route path="/fh" element={<FollowedHashtags />} />
<Route path="/ft" element={<Filters />} />
<Route path="/catchup" element={<Catchup />} />
</>
)}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 25 KiB

Wyświetl plik

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

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.9 KiB

Wyświetl plik

@ -8,27 +8,33 @@ body.cloak,
.name-text *,
.status .content-container,
.status .content-container *,
.status .content-compact,
.status .content-compact > *,
.account-container :is(header, main > *:not(.actions)),
.account-container :is(header, main > *:not(.actions)) *,
.header-double-lines,
.account-block {
.account-block,
.catchup-filters .filter-author *,
.post-peek-html *,
.post-peek-content > *,
.request-notifications-account * {
text-decoration-thickness: 1.1em;
text-decoration-line: line-through;
text-rendering: optimizeSpeed;
/* text-rendering: optimizeSpeed; */
filter: opacity(0.5);
}
.name-text *,
.status .content-container *,
.account-container :is(header, main > *:not(.actions)) * {
.account-container :is(header, main > *:not(.actions)) *,
.post-peek-content > * {
filter: none;
}
.status :is(img, video, audio),
.media-post .media,
.avatar,
.avatar *,
.emoji,
.header-banner {
.header-banner,
.post-peek-media {
filter: contrast(0) !important;
background-color: #000 !important;
}
@ -37,7 +43,17 @@ body.cloak,
/* SPECIAL CASES */
@supports (display: -webkit-box) {
body.cloak .card :is(.title, .meta) {
background-color: var(--text-color) !important;
:is(body.cloak, .cloak) .card :is(.title, .meta) {
background-color: currentColor !important;
}
}
body.cloak,
.cloak {
.media-container figcaption,
.media-container figcaption > *,
.catchup-filters .filter-author *,
.request-notifications-account * {
color: var(--text-color) !important;
}
}

Wyświetl plik

@ -0,0 +1,112 @@
export const ICONS = {
x: () => import('@iconify-icons/mingcute/close-line'),
heart: () => import('@iconify-icons/mingcute/heart-line'),
bookmark: () => import('@iconify-icons/mingcute/bookmark-line'),
'check-circle': () => import('@iconify-icons/mingcute/check-circle-line'),
'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-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
earth: () => import('@iconify-icons/mingcute/earth-line'),
lock: () => import('@iconify-icons/mingcute/lock-line'),
unlock: () => import('@iconify-icons/mingcute/unlock-line'),
'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'),
home: () => import('@iconify-icons/mingcute/home-3-line'),
notification: () => import('@iconify-icons/mingcute/notification-line'),
follow: () => import('@iconify-icons/mingcute/user-follow-line'),
'follow-add': () => import('@iconify-icons/mingcute/user-add-line'),
poll: [() => import('@iconify-icons/mingcute/chart-bar-line'), '90deg'],
pencil: () => import('@iconify-icons/mingcute/pencil-line'),
quill: () => import('@iconify-icons/mingcute/quill-pen-line'),
at: () => import('@iconify-icons/mingcute/at-line'),
attachment: () => import('@iconify-icons/mingcute/attachment-line'),
upload: () => import('@iconify-icons/mingcute/upload-3-line'),
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'],
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-down': () => import('@iconify-icons/mingcute/down-line'),
reply: [
() => import('@iconify-icons/mingcute/share-forward-line'),
'180deg',
'horizontal',
],
thread: () => import('@iconify-icons/mingcute/route-line'),
group: () => import('@iconify-icons/mingcute/group-line'),
bot: () => import('@iconify-icons/mingcute/android-2-line'),
menu: () => import('@iconify-icons/mingcute/rows-4-line'),
list: () => import('@iconify-icons/mingcute/list-check-line'),
search: () => import('@iconify-icons/mingcute/search-2-line'),
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
info: () => import('@iconify-icons/mingcute/information-line'),
shortcut: () => import('@iconify-icons/mingcute/lightning-line'),
user: () => import('@iconify-icons/mingcute/user-4-line'),
following: () => import('@iconify-icons/mingcute/walk-line'),
pin: () => import('@iconify-icons/mingcute/pin-line'),
unpin: [() => import('@iconify-icons/mingcute/pin-line'), '180deg'],
bus: () => import('@iconify-icons/mingcute/bus-2-line'),
link: () => import('@iconify-icons/mingcute/link-2-line'),
history: () => import('@iconify-icons/mingcute/history-line'),
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'),
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'),
block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
unblock: [
() => import('@iconify-icons/mingcute/forbid-circle-line'),
'180deg',
],
flag: () => import('@iconify-icons/mingcute/flag-1-line'),
time: () => import('@iconify-icons/mingcute/time-line'),
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'),
layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
announce: () => import('@iconify-icons/mingcute/announcement-line'),
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'),
'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'),
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'),
document: () => import('@iconify-icons/mingcute/document-line'),
'arrows-right': () => import('@iconify-icons/mingcute/arrows-right-line'),
code: () => import('@iconify-icons/mingcute/code-line'),
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
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'),
minimize: () => import('@iconify-icons/mingcute/arrows-down-line'),
};

Wyświetl plik

@ -33,7 +33,7 @@ function AccountBlock({
<span>
<b></b>
<br />
<span class="account-block-acct">@</span>
<span class="account-block-acct"></span>
</span>
</div>
);
@ -61,6 +61,8 @@ function AccountBlock({
note,
group,
followersCount,
createdAt,
locked,
} = account;
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (accountInstance) {
@ -85,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,26 +121,30 @@ function AccountBlock({
</>
)}{' '}
<span class="account-block-acct">
@{acct1}
{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">
@ -188,6 +194,21 @@ function AccountBlock({
/>
</span>
)}
{!bot &&
!group &&
!hasRelationship &&
!followersCount &&
!verifiedField &&
!!createdAt && (
<span class="created-at">
Joined{' '}
<time datetime={createdAt}>
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
</span>
)}
</div>
)}
</span>

Wyświetl plik

@ -9,11 +9,15 @@
--original-color: var(--link-color);
.note {
font-size: 95%;
font-size: 0.95em;
line-height: 1.4;
text-wrap: pretty;
margin-bottom: 16px;
&:empty {
display: none;
}
> *:first-child {
margin-top: 0;
padding-top: 0;
@ -777,3 +781,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: left;
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);
}
}
}

Wyświetl plik

@ -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';
@ -31,7 +35,9 @@ 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 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(new URL(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,10 +345,21 @@ 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"
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
style={{
'--header-color-1': headerCornerColors[0],
'--header-color-2': headerCornerColors[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,64 @@ 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 handle = `@${acct}`;
try {
navigator.clipboard.writeText(handle);
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 +672,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 +717,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 +829,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 +886,10 @@ const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({
info,
instance,
standalone,
authenticated,
onRelationshipChange = () => {},
onProfileUpdate = () => {},
}) {
if (!info) return null;
const {
@ -843,7 +926,7 @@ function RelatedActions({
useEffect(() => {
if (info) {
const currentAccount = store.session.get('currentAccount');
const currentAccount = getCurrentAccountID();
let currentID;
(async () => {
if (sameInstance && authenticated) {
@ -878,7 +961,7 @@ function RelatedActions({
accountID.current = currentID;
if (moved) return;
// if (moved) return;
setRelationshipUIState('loading');
@ -917,6 +1000,8 @@ 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 (
<>
@ -976,16 +1061,32 @@ function RelatedActions({
<Icon icon="more" size="l" alt="More" />
</button>
}
onMenuChange={(e) => {
if (following && e.open) {
// Fetch lists that have this account
(async () => {
try {
const lists = await currentMasto.v1.accounts
.$select(accountID.current)
.lists.list();
console.log('fetched account lists', lists);
setLists(lists);
} catch (e) {
console.error(e);
}
})();
}
}}
>
{currentAuthenticated && !isSelf && (
<>
<MenuItem
onClick={() => {
states.showCompose = {
showCompose({
draftStatus: {
status: `@${currentInfo?.acct || acct} `,
},
};
});
}}
>
<Icon icon="at" />
@ -999,16 +1100,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} disabled.`
: `Boosts from @${username} enabled.`,
);
} 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
@ -1017,12 +1184,46 @@ function RelatedActions({
}}
>
<Icon icon="list" />
<span>Add/remove from Lists</span>
{lists.length ? (
<>
<small class="menu-grow">
Add/Remove from Lists
<br />
<span class="more-insignificant">
{lists.map((list) => list.title).join(', ')}
</span>
</small>
<small class="more-insignificant">{lists.length}</small>
</>
) : (
<span>Add/Remove from Lists</span>
)}
</MenuItem>
)}
<MenuDivider />
</>
)}
<MenuItem
onClick={() => {
const handle = `@${currentInfo?.acct || acct}`;
try {
navigator.clipboard.writeText(handle);
showToast('Handle copied');
} catch (e) {
console.error(e);
showToast('Unable to copy handle');
}
}}
>
<Icon icon="copy" />
<small>
Copy handle
<br />
<span class="more-insignificant">
@{currentInfo?.acct || acct}
</span>
</small>
</MenuItem>
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<small class="menu-double-lines">{niceAccountURL(url)}</small>
@ -1093,7 +1294,8 @@ function RelatedActions({
<span>Unmute @{username}</span>
</MenuItem>
) : (
<SubMenu
<SubMenu2
menuClassName="menu-blur"
openTrigger="clickOnly"
direction="bottom"
overflow="auto"
@ -1146,7 +1348,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
@ -1208,17 +1447,61 @@ function RelatedActions({
</>
)}
</MenuConfirm>
{/* <MenuItem>
<Icon icon="flag" />
<span>Report @{username}</span>
</MenuItem> */}
<MenuItem
className="danger"
onClick={() => {
states.showReportModal = {
account: currentInfo || info,
};
}}
>
<Icon icon="flag" />
<span>Report @{username}</span>
</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 />
<MenuItem
onClick={async () => {
const relationships =
await currentMasto.v1.accounts.relationships.fetch({
id: [accountID.current],
});
const { note } = relationships[0] || {};
if (note) {
alert(note);
console.log(note);
}
}}
>
<Icon icon="pencil" />
<span>See note</span>
</MenuItem>
</>
)}
</Menu2>
{!relationship && relationshipUIState === 'loading' && (
<Loader abrupt />
)}
{!!relationship && (
{!!relationship && !moved && (
<MenuConfirm
confirm={following || requested}
confirmLabel={
@ -1294,7 +1577,6 @@ function RelatedActions({
</div>
{!!showTranslatedBio && (
<Modal
class="light"
onClose={() => {
setShowTranslatedBio(false);
}}
@ -1308,7 +1590,6 @@ function RelatedActions({
)}
{!!showAddRemoveLists && (
<Modal
class="light"
onClose={() => {
setShowAddRemoveLists(false);
}}
@ -1321,7 +1602,6 @@ function RelatedActions({
)}
{!!showPrivateNoteModal && (
<Modal
class="light"
onClose={() => {
setShowPrivateNoteModal(false);
}}
@ -1337,6 +1617,22 @@ function RelatedActions({
/>
</Modal>
)}
{!!showEditProfile && (
<Modal
onClose={() => {
setShowEditProfile(false);
}}
>
<EditProfileSheet
onClose={({ state, account } = {}) => {
setShowEditProfile(false);
if (state === 'success' && account) {
onProfileUpdate(account);
}
}}
/>
</Modal>
)}
</>
);
}
@ -1364,7 +1660,7 @@ function niceAccountURL(url) {
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
return (
<>
<span class="more-insignificant">{host}/</span>
<span class="more-insignificant">{punycode.toUnicode(host)}/</span>
<wbr />
<span>{path}</span>
</>
@ -1414,13 +1710,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) {
@ -1513,7 +1808,6 @@ function AddRemoveListsSheet({ accountID, onClose }) {
</main>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
@ -1626,4 +1920,213 @@ 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'}
/>
</label>
</p>
<p>
<label>
Bio
<textarea
defaultValue={note}
name="note"
maxLength={500}
rows="5"
disabled={uiState === 'loading'}
/>
</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}
/>
</td>
<td>
<input
type="text"
name={`fields_attributes[${i}][value]`}
defaultValue={value}
disabled={disabled}
maxLength={255}
onChange={(e) => setHasValue(!!e.currentTarget.value)}
/>
</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;

Wyświetl plik

@ -59,7 +59,11 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
return result.accounts[0];
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = new URL(account);
const acct = accountURL.pathname.replace(/^\//, '');
const { hostname, pathname } = accountURL;
const acct =
pathname.replace(/^\//, '').replace(/\/$/, '') +
'@' +
hostname;
const result = await masto.v2.search.fetch({
q: acct,
type: 'accounts',

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -95,6 +95,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 +111,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);
@ -294,19 +298,25 @@
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);
}
#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 +340,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;
@ -496,8 +521,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 +616,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(
to right,
transparent 75%,
var(--link-bg-color)
);
}
&.selected {
background-image: linear-gradient(
to right,
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;
left: 58px;
right: 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 +899,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(
to right,
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;
}
}
}
}

Plik diff jest za duży Load Diff

Wyświetl plik

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

Wyświetl plik

@ -22,6 +22,10 @@
iframe {
pointer-events: auto;
max-width: 100%;
max-height: 100%;
width: max(var(--width), 480px);
height: auto;
aspect-ratio: var(--aspect-ratio);
}
}
}

Wyświetl plik

@ -2,7 +2,7 @@ import './embed-modal.css';
import Icon from './icon';
function EmbedModal({ html, url, onClose = () => {} }) {
function EmbedModal({ html, url, width, height, onClose = () => {} }) {
return (
<div class="embed-modal-container">
<div class="top-controls">
@ -20,7 +20,15 @@ function EmbedModal({ html, url, onClose = () => {} }) {
</a>
)}
</div>
<div class="embed-content" dangerouslySetInnerHTML={{ __html: html }} />
<div
class="embed-content"
dangerouslySetInnerHTML={{ __html: html }}
style={{
'--width': width + 'px',
'--height': height + 'px',
'--aspect-ratio': `${width}/${height}`,
}}
/>
</div>
);
}

Wyświetl plik

@ -1,3 +1,7 @@
import { memo } from 'preact/compat';
import CustomEmoji from './custom-emoji';
function EmojiText({ text, emojis }) {
if (!text) return '';
if (!emojis?.length) return text;
@ -10,25 +14,16 @@ 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;
});
return elements;
}
export default EmojiText;
export default memo(
EmojiText,
(oldProps, newProps) =>
oldProps.text === newProps.text &&
oldProps.emojis?.length === newProps.emojis?.length,
);

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,6 +1,8 @@
import moize from 'moize';
import { useEffect, useRef, useState } from 'preact/hooks';
import { ICONS } from './ICONS';
const SIZES = {
s: 12,
m: 16,
@ -9,115 +11,13 @@ const SIZES = {
xxl: 32,
};
export const ICONS = {
x: () => import('@iconify-icons/mingcute/close-line'),
heart: () => import('@iconify-icons/mingcute/heart-line'),
bookmark: () => import('@iconify-icons/mingcute/bookmark-line'),
'check-circle': () => import('@iconify-icons/mingcute/check-circle-line'),
'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-up': () => import('@iconify-icons/mingcute/arrow-up-line'),
'arrow-down': () => import('@iconify-icons/mingcute/arrow-down-line'),
earth: () => import('@iconify-icons/mingcute/earth-line'),
lock: () => import('@iconify-icons/mingcute/lock-line'),
unlock: () => import('@iconify-icons/mingcute/unlock-line'),
'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'),
home: () => import('@iconify-icons/mingcute/home-3-line'),
notification: () => import('@iconify-icons/mingcute/notification-line'),
follow: () => import('@iconify-icons/mingcute/user-follow-line'),
'follow-add': () => import('@iconify-icons/mingcute/user-add-line'),
poll: [() => import('@iconify-icons/mingcute/chart-bar-line'), '90deg'],
pencil: () => import('@iconify-icons/mingcute/pencil-line'),
quill: () => import('@iconify-icons/mingcute/quill-pen-line'),
at: () => import('@iconify-icons/mingcute/at-line'),
attachment: () => import('@iconify-icons/mingcute/attachment-line'),
upload: () => import('@iconify-icons/mingcute/upload-3-line'),
gear: () => import('@iconify-icons/mingcute/settings-3-line'),
more: () => import('@iconify-icons/mingcute/more-3-line'),
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'],
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-down': () => import('@iconify-icons/mingcute/down-line'),
reply: [
() => import('@iconify-icons/mingcute/share-forward-line'),
'180deg',
'horizontal',
],
thread: () => import('@iconify-icons/mingcute/route-line'),
group: () => import('@iconify-icons/mingcute/group-line'),
bot: () => import('@iconify-icons/mingcute/android-2-line'),
menu: () => import('@iconify-icons/mingcute/rows-4-line'),
list: () => import('@iconify-icons/mingcute/list-check-line'),
search: () => import('@iconify-icons/mingcute/search-2-line'),
hashtag: () => import('@iconify-icons/mingcute/hashtag-line'),
info: () => import('@iconify-icons/mingcute/information-line'),
shortcut: () => import('@iconify-icons/mingcute/lightning-line'),
user: () => import('@iconify-icons/mingcute/user-4-line'),
following: () => import('@iconify-icons/mingcute/walk-line'),
pin: () => import('@iconify-icons/mingcute/pin-line'),
bus: () => import('@iconify-icons/mingcute/bus-2-line'),
link: () => import('@iconify-icons/mingcute/link-2-line'),
history: () => import('@iconify-icons/mingcute/history-line'),
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'),
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'),
block: () => import('@iconify-icons/mingcute/forbid-circle-line'),
unblock: [
() => import('@iconify-icons/mingcute/forbid-circle-line'),
'180deg',
],
flag: () => import('@iconify-icons/mingcute/flag-4-line'),
time: () => import('@iconify-icons/mingcute/time-line'),
refresh: () => import('@iconify-icons/mingcute/refresh-2-line'),
emoji2: () => import('@iconify-icons/mingcute/emoji-2-line'),
filter: () => import('@iconify-icons/mingcute/filter-2-line'),
chart: () => import('@iconify-icons/mingcute/chart-line-line'),
react: () => import('@iconify-icons/mingcute/react-line'),
layout4: () => import('@iconify-icons/mingcute/layout-4-line'),
layout5: () => import('@iconify-icons/mingcute/layout-5-line'),
announce: () => import('@iconify-icons/mingcute/announcement-line'),
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'),
'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'),
media: () => import('@iconify-icons/mingcute/photo-album-line'),
speak: () => import('@iconify-icons/mingcute/radar-line'),
building: () => import('@iconify-icons/mingcute/building-5-line'),
};
const ICONDATA = {};
// Memoize the dangerouslySetInnerHTML of the SVGs
const SVGICon = moize(
function ({ size, width, height, body, rotate, flip }) {
function ({ width, height, body, rotate, flip }) {
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${width} ${height}`}
dangerouslySetInnerHTML={{ __html: body }}
style={{
@ -131,6 +31,8 @@ const SVGICon = moize(
{
isShallowEqual: true,
maxSize: Object.keys(ICONS).length,
matchesArg: (cacheKeyArg, keyArg) =>
cacheKeyArg.icon === keyArg.icon && cacheKeyArg.body === keyArg.body,
},
);
@ -191,7 +93,7 @@ function Icon({
// }}
// />
<SVGICon
size={iconSize}
icon={icon}
width={iconData.width}
height={iconData.height}
body={iconData.body}

Wyświetl plik

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

Wyświetl plik

@ -17,7 +17,7 @@ export default memo(function KeyboardShortcutsHelp() {
}
useHotkeys(
'?, shift+?',
'?, shift+?, shift+slash',
(e) => {
console.log('help');
states.showKeyboardShortcutsHelp = true;
@ -32,7 +32,7 @@ export default memo(function KeyboardShortcutsHelp() {
return (
!!snapStates.showKeyboardShortcutsHelp && (
<Modal class="light" onClose={onClose}>
<Modal onClose={onClose}>
<div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
@ -71,6 +71,10 @@ export default memo(function KeyboardShortcutsHelp() {
</>
),
},
{
action: 'Load new posts',
keys: <kbd>.</kbd>,
},
{
action: 'Open post details',
keys: (

Wyświetl plik

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

Wyświetl plik

@ -15,7 +15,7 @@
text-shadow: 0 1px var(--bg-blur-color);
transition: opacity 0.3s ease-out;
&:not(#columns &) {
#trending-page &:not(#columns &) {
@media (min-width: 40em) {
width: 95vw;
max-width: calc(320px * 3.3);
@ -96,6 +96,7 @@
}
article {
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
@ -113,34 +114,34 @@
margin: 0 0 -16px;
padding: 0;
position: relative;
}
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
vertical-align: top;
mask-image: linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 14%,
hsla(0, 0%, 0%, 0.951) 26.2%,
hsla(0, 0%, 0%, 0.896) 36.8%,
hsla(0, 0%, 0%, 0.825) 45.9%,
hsla(0, 0%, 0%, 0.741) 53.7%,
hsla(0, 0%, 0%, 0.648) 60.4%,
hsla(0, 0%, 0%, 0.55) 66.2%,
hsla(0, 0%, 0%, 0.45) 71.2%,
hsla(0, 0%, 0%, 0.352) 75.6%,
hsla(0, 0%, 0%, 0.259) 79.6%,
hsla(0, 0%, 0%, 0.175) 83.4%,
hsla(0, 0%, 0%, 0.104) 87.2%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100%
);
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
vertical-align: top;
mask-image: linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 14%,
hsla(0, 0%, 0%, 0.951) 26.2%,
hsla(0, 0%, 0%, 0.896) 36.8%,
hsla(0, 0%, 0%, 0.825) 45.9%,
hsla(0, 0%, 0%, 0.741) 53.7%,
hsla(0, 0%, 0%, 0.648) 60.4%,
hsla(0, 0%, 0%, 0.55) 66.2%,
hsla(0, 0%, 0%, 0.45) 71.2%,
hsla(0, 0%, 0%, 0.352) 75.6%,
hsla(0, 0%, 0%, 0.259) 79.6%,
hsla(0, 0%, 0%, 0.175) 83.4%,
hsla(0, 0%, 0%, 0.104) 87.2%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100%
);
}
}
}
@ -187,5 +188,9 @@
overflow: hidden;
font-size: 90%;
}
hr {
margin: 4px 0;
}
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -9,12 +9,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
@ -54,6 +54,7 @@ const AltBadge = (props) => {
};
const MEDIA_CAPTION_LIMIT = 140;
const MEDIA_CAPTION_LIMIT_LONGER = 280;
export const isMediaCaptionLong = mem((caption) =>
caption?.length
? caption.length > MEDIA_CAPTION_LIMIT ||
@ -69,10 +70,11 @@ function Media({
showOriginal,
autoAnimate,
showCaption,
allowLongerCaption,
altIndex,
onClick = () => {},
}) {
const {
let {
blurhash,
description,
meta,
@ -82,15 +84,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;
@ -131,7 +145,8 @@ function Media({
enabled: pinchZoomEnabled,
draggableUnZoomed: false,
inertiaFriction: 0.9,
doubleTapZoomOutOnMaxScale: true,
tapZoomFactor: 2,
doubleTapToggleZoom: true,
containerProps: {
className: 'media-zoom',
style: {
@ -151,11 +166,18 @@ function Media({
[to],
);
const remoteMediaURLObj = remoteMediaURL ? getURLObj(remoteMediaURL) : null;
const isVideoMaybe =
type === 'unknown' &&
/\.(mp4|m4a|m4p|m4b|m4r|m4v|mov|webm)$/i.test(remoteMediaURL);
remoteMediaURLObj &&
/\.(mp4|m4r|m4v|mov|webm)$/i.test(remoteMediaURLObj.pathname);
const isAudioMaybe =
type === 'unknown' &&
remoteMediaURLObj &&
/\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(remoteMediaURLObj.pathname);
const isImage =
type === 'image' || (type === 'unknown' && previewUrl && !isVideoMaybe);
type === 'image' ||
(type === 'unknown' && previewUrl && !isVideoMaybe && !isAudioMaybe);
const parentRef = useRef();
const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false);
@ -191,8 +213,15 @@ function Media({
};
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =
let showInlineDesc =
!!showCaption && !showOriginal && !!description && !longDesc;
if (
allowLongerCaption &&
!showInlineDesc &&
description?.length <= MEDIA_CAPTION_LIMIT_LONGER
) {
showInlineDesc = true;
}
const Figure = !showInlineDesc
? Fragment
: (props) => {
@ -274,7 +303,11 @@ function Media({
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL && mediaURL !== remoteMediaURL) {
if (
src === mediaURL &&
remoteMediaURL &&
mediaURL !== remoteMediaURL
) {
e.target.src = remoteMediaURL;
}
}}
@ -305,6 +338,55 @@ 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 naturalAspectRatio = (
naturalWidth / naturalHeight
).toFixed(2);
const displayAspectRatio = (
clientWidth / clientHeight
).toFixed(2);
const similarThreshold = 0.05;
if (
naturalAspectRatio === displayAspectRatio ||
Math.abs(naturalAspectRatio - displayAspectRatio) <
similarThreshold
) {
$media.dataset.hasNaturalAspectRatio = true;
}
// $media.dataset.aspectRatios = `${naturalAspectRatio} ${displayAspectRatio}`;
}
}
}
}}
onError={(e) => {
const { src } = e.target;
@ -322,6 +404,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
@ -331,28 +414,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
@ -413,17 +511,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
@ -457,14 +560,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>
@ -476,7 +626,7 @@ function Media({
</Parent>
</Figure>
);
} else if (type === 'audio') {
} else if (type === 'audio' || isAudioMaybe) {
const formattedDuration = formatDuration(original.duration);
return (
<Figure>
@ -490,7 +640,7 @@ function Media({
style={!showOriginal && mediaStyles}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
<audio src={remoteUrl || url} preload="none" controls autoPlay />
) : previewUrl ? (
<img
src={previewUrl}
@ -499,6 +649,12 @@ function Media({
height={height}
data-orientation={orientation}
loading="lazy"
onError={(e) => {
try {
// Remove self if broken
e.target?.remove?.();
} catch (e) {}
}}
/>
) : null}
{!showOriginal && (
@ -517,4 +673,13 @@ function Media({
}
}
function getURLObj(url) {
try {
// Fake base URL if url doesn't have https:// prefix
return new URL(url, location.origin);
} catch (e) {
return null;
}
}
export default Media;

Wyświetl plik

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

Wyświetl plik

@ -3,11 +3,12 @@ import { FocusableItem } from '@szhsin/react-menu';
import Link from './link';
function MenuLink(props) {
const { className, disabled, ...restProps } = props;
return (
<FocusableItem>
<FocusableItem className={className} disabled={disabled}>
{({ ref, closeMenu }) => (
<Link
{...props}
{...restProps}
ref={ref}
onClick={({ detail }) =>
closeMenu(detail === 0 ? 'Enter' : undefined)

Wyświetl plik

@ -9,17 +9,57 @@
justify-content: center;
align-items: center;
background-color: var(--backdrop-color);
backdrop-filter: blur(24px);
animation: appear 0.5s var(--timing-function) both;
}
#modal-container > div .sheet {
transition: transform 0.3s var(--timing-function);
transform-origin: center bottom;
}
#modal-container > div:has(~ div) .sheet {
transform: scale(0.975);
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);
--right: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-right)
);
--bottom: max(
var(--compose-button-dimension-margin),
env(safe-area-inset-bottom)
);
--origin-right: calc(
100% - var(--compose-button-dimension-half) - var(--right)
);
--origin-bottom: calc(
100% - var(--compose-button-dimension-half) - var(--bottom)
);
transform-origin: var(--origin-right) var(--origin-bottom);
}
.sheet {
transition: transform 0.3s var(--timing-function);
transform-origin: 80% 80%;
}
&:has(~ div) .sheet {
transform: scale(0.975);
}
}
#modal-container > .light {
backdrop-filter: saturate(0.75);
@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
);
}
}

Wyświetl plik

@ -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,10 +81,22 @@ function Modal({ children, onClose, onClick, class: className }) {
onClose?.(e);
}
}}
tabIndex="-1"
tabIndex={minimized ? 0 : '-1'}
inert={minimized}
onFocus={(e) => {
if (e.target === e.currentTarget) {
modalRef.current?.querySelector?.('[tabindex="-1"]')?.focus?.();
try {
if (e.target === e.currentTarget) {
const focusElement =
modalRef.current?.querySelector('[tabindex="-1"]');
const isFocusable =
!!focusElement &&
getComputedStyle(focusElement)?.pointerEvents !== 'none';
if (focusElement && isFocusable) {
focusElement.focus();
}
}
} catch (err) {
console.error(err);
}
}}
>

Wyświetl plik

@ -1,3 +1,4 @@
import { useEffect } from 'preact/hooks';
import { useLocation, useNavigate } from 'react-router-dom';
import { subscribe, useSnapshot } from 'valtio';
@ -8,13 +9,14 @@ 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';
import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal';
import Modal from './modal';
import ReportModal from './report-modal';
import ShortcutsSettings from './shortcuts-settings';
subscribe(states, (changes) => {
@ -31,11 +33,18 @@ export default function Modals() {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
setTimeout(preload, 1000);
}, []);
return (
<>
{!!snapStates.showCompose && (
<Modal>
<Compose
<Modal
class={`solid ${snapStates.composerState.minimized ? 'min' : ''}`}
minimized={!!snapStates.composerState.minimized}
>
<ComposeSuspense
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
@ -108,7 +117,6 @@ export default function Modals() {
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClose={() => {
states.showAccount = false;
}}
@ -159,7 +167,6 @@ export default function Modals() {
)}
{!!snapStates.showShortcutsSettings && (
<Modal
class="light"
onClose={() => {
states.showShortcutsSettings = false;
}}
@ -171,7 +178,6 @@ export default function Modals() {
)}
{!!snapStates.showGenericAccounts && (
<Modal
class="light"
onClose={() => {
states.showGenericAccounts = false;
}}
@ -181,13 +187,14 @@ export default function Modals() {
excludeRelationshipAttrs={
snapStates.showGenericAccounts.excludeRelationshipAttrs
}
postID={snapStates.showGenericAccounts.postID}
onClose={() => (states.showGenericAccounts = false)}
blankCopy={snapStates.showGenericAccounts.blankCopy}
/>
</Modal>
)}
{!!snapStates.showMediaAlt && (
<Modal
class="light"
onClose={(e) => {
states.showMediaAlt = false;
}}
@ -203,6 +210,7 @@ export default function Modals() {
)}
{!!snapStates.showEmbedModal && (
<Modal
class="solid"
onClose={() => {
states.showEmbedModal = false;
}}
@ -210,12 +218,29 @@ export default function Modals() {
<EmbedModal
html={snapStates.showEmbedModal.html}
url={snapStates.showEmbedModal.url}
width={snapStates.showEmbedModal.width}
height={snapStates.showEmbedModal.height}
onClose={() => {
states.showEmbedModal = false;
}}
/>
</Modal>
)}
{!!snapStates.showReportModal && (
<Modal
onClose={() => {
states.showReportModal = false;
}}
>
<ReportModal
account={snapStates.showReportModal.account}
post={snapStates.showReportModal.post}
onClose={() => {
states.showReportModal = false;
}}
/>
</Modal>
)}
</>
);
}

Wyświetl plik

@ -2,6 +2,17 @@
color: inherit;
text-decoration: none;
display: inline;
unicode-bidi: isolate;
b {
font-weight: 500;
unicode-bidi: isolate;
}
i {
font-variant-numeric: slashed-zero;
font-feature-settings: 'ss01';
}
}
.name-text.show-acct {
display: inline-block;

Wyświetl plik

@ -20,9 +20,17 @@ 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];
const trimmedUsername = username.toLowerCase().trim();
@ -31,31 +39,39 @@ function NameText({
.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,
@ -72,7 +88,7 @@ function NameText({
<b>
<EmojiText text={displayName} emojis={emojis} />
</b>
{!showAcct && username && (
{!showAcct && !hideUsername && (
<>
{' '}
<i>@{username}</i>
@ -88,8 +104,9 @@ function NameText({
<>
<br />
<i>
@{acct1}
<span class="ib">{acct2}</span>
{acct2 ? '' : '@'}
{acct1}
{!!acct2 && <span class="ib">{acct2}</span>}
</i>
</>
)}
@ -97,4 +114,9 @@ function NameText({
);
}
export default memo(NameText);
export default memo(NameText, (oldProps, newProps) => {
// Only care about account.id, the other props usually don't change
const { account } = oldProps;
const { account: newAccount } = newProps;
return account?.acct === newAccount?.acct;
});

Wyświetl plik

@ -88,3 +88,7 @@
.sparkle-icon {
animation: sparkle-icon 0.3s ease-in-out infinite alternate;
}
.nav-submenu {
max-width: 14em;
}

Wyświetl plik

@ -1,33 +1,34 @@
import './nav-menu.css';
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { memo } from 'preact/compat';
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';
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
@ -83,6 +84,16 @@ 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 (
<>
<button
@ -90,9 +101,10 @@ 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();
setMenuState((state) => (!state ? 'open' : undefined));
}}
onContextMenu={(e) => {
@ -124,7 +136,10 @@ function NavMenu(props) {
zIndex: 10,
},
onClick: () => {
setMenuState(undefined);
if (Date.now() - buttonClickTS.current < 300) {
return;
}
// setMenuState(undefined);
},
}}
portal={{
@ -163,16 +178,22 @@ function NavMenu(props) {
<MenuLink to="/">
<Icon icon="home" size="l" /> <span>Home</span>
</MenuLink>
{authenticated && (
{authenticated ? (
<>
{showFollowing && (
<MenuLink to="/following">
<Icon icon="following" size="l" /> <span>Following</span>
</MenuLink>
)}
<MenuLink to="/mentions">
<Icon icon="at" size="l" /> <span>Mentions</span>
<MenuLink to="/catchup">
<Icon icon="history2" size="l" />
<span>Catch-up</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 && (
@ -182,44 +203,103 @@ function NavMenu(props) {
</sup>
)}
</MenuLink>
<MenuDivider />
<MenuLink to="/l">
<Icon icon="list" size="l" /> <span>Lists</span>
</MenuLink>
<MenuLink to="/ft">
<Icon icon="hashtag" size="l" /> <span>Followed Hashtags</span>
</MenuLink>
<MenuLink to="/b">
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink>
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink>
</>
)}
<MenuDivider />
<MenuLink to={`/search`}>
<Icon icon="search" size="l" /> <span>Search</span>
</MenuLink>
<MenuLink to={`/${instance}/p/l`}>
<Icon icon="building" size="l" /> <span>Local</span>
</MenuLink>
<MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span>
</MenuLink>
<MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span>
</MenuLink>
</section>
<section>
{authenticated ? (
<>
<MenuDivider />
{currentAccount?.info?.id && (
<MenuLink to={`/${instance}/a/${currentAccount.info.id}`}>
<Icon icon="user" size="l" /> <span>Profile</span>
</MenuLink>
)}
{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>
<SubMenu2
menuClassName="nav-submenu"
overflow="auto"
gap={-8}
label={
<>
<Icon icon="more" size="l" />
<span class="menu-grow">More</span>
<Icon icon="chevron-right" />
</>
}
>
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink>
<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 = {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
excludeRelationshipAttrs: ['muting'],
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
excludeRelationshipAttrs: ['blocking'],
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>{' '}
</SubMenu2>
<MenuDivider />
<MenuItem
onClick={() => {
states.showAccounts = true;
@ -227,31 +307,32 @@ function NavMenu(props) {
>
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
excludeRelationshipAttrs: ['muting'],
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
excludeRelationshipAttrs: ['blocking'],
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>
</>
) : (
<>
<MenuDivider />
<MenuLink to="/login">
<Icon icon="user" size="l" /> <span>Log in</span>
</MenuLink>
</>
)}
</section>
<section>
<MenuDivider />
<MenuLink to={`/search`}>
<Icon icon="search" size="l" /> <span>Search</span>
</MenuLink>
<MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span>
</MenuLink>
<MenuLink to={`/${instance}/p/l`}>
<Icon icon="building" size="l" /> <span>Local</span>
</MenuLink>
<MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span>
</MenuLink>
{authenticated ? (
<>
<MenuDivider className="divider-grow" />
<MenuItem
onClick={() => {
@ -280,9 +361,6 @@ function NavMenu(props) {
) : (
<>
<MenuDivider />
<MenuLink to="/login">
<Icon icon="user" size="l" /> <span>Log in</span>
</MenuLink>
<MenuItem
onClick={() => {
states.showSettings = true;
@ -298,4 +376,4 @@ function NavMenu(props) {
);
}
export default NavMenu;
export default memo(NavMenu);

Wyświetl plik

@ -144,7 +144,6 @@ export default memo(function NotificationService() {
const { id, account, notification, sameInstance } = showNotificationSheet;
return (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();

Wyświetl plik

@ -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,23 @@ function Notification({
isStatic,
disableContextMenu,
}) {
const { id, status, account, report, _accounts, _statuses } = notification;
const {
id,
status,
account,
report,
event,
moderation_warning,
_accounts,
_statuses,
} = 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 =
@ -128,13 +204,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 +252,7 @@ function Notification({
accounts: _accounts,
showReactions: type === 'favourite+reblog',
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
postID: statusKey(actualStatusID, instance),
};
};
@ -203,9 +297,11 @@ function Notification({
</b>{' '}
</>
) : (
<>
<NameText account={account} showAvatar />{' '}
</>
account && (
<>
<NameText account={account} showAvatar />{' '}
</>
)
)}
</>
)}
@ -224,6 +320,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 +374,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}`}
@ -292,7 +415,12 @@ function Notification({
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
>
<Status status={status} size="s" />
<Status
status={status}
size="s"
previewMode
allowContextMenu
/>
</TruncatedLink>
</li>
))}
@ -326,9 +454,19 @@ function Notification({
}
>
{isStatic ? (
<Status status={actualStatus} size="s" />
<Status
status={actualStatus}
size="s"
readOnly
allowContextMenu
/>
) : (
<Status statusID={actualStatusID} size="s" />
<Status
statusID={actualStatusID}
size="s"
readOnly
allowContextMenu
/>
)}
</TruncatedLink>
)}
@ -342,4 +480,6 @@ function TruncatedLink(props) {
return <Link {...props} data-read-more="Read more →" ref={ref} />;
}
export default memo(Notification);
export default memo(Notification, (oldProps, newProps) => {
return oldProps.notification?.id === newProps.notification?.id;
});

Wyświetl plik

@ -8,6 +8,7 @@ import dayjs from 'dayjs';
import dayjsTwitter from 'dayjs-twitter';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useEffect, useMemo, useReducer } from 'preact/hooks';
dayjs.extend(dayjsTwitter);
dayjs.extend(localizedFormat);
@ -17,23 +18,54 @@ const dtf = new Intl.DateTimeFormat();
export default function RelativeTime({ datetime, format }) {
if (!datetime) return null;
const date = dayjs(datetime);
let dateStr;
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()) {
dateStr = date.twitter();
} else {
dateStr = dtf.format(date.toDate());
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
const date = useMemo(() => dayjs(datetime), [datetime]);
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()) {
str = date.twitter();
} else {
str = dtf.format(date.toDate());
}
}
} else {
dateStr = date.fromNow();
}
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={date.toISOString()} title={date.format('LLLL')}>
<time datetime={dt} title={title}>
{dateStr}
</time>
);

Wyświetl plik

@ -0,0 +1,200 @@
.report-modal-container {
width: 100%;
max-height: 100%;
display: flex;
flex-direction: column;
max-width: 40em;
background-color: var(--bg-color);
box-shadow: 0 16px 32px -8px var(--drop-shadow-color);
overflow-y: auto;
animation: slide-up-smooth 0.3s ease-in-out;
position: relative;
@media (min-width: 40em) {
max-height: calc(100% - 32px);
}
h1 {
margin: 0;
padding: 0;
}
.top-controls {
position: sticky;
top: var(--sai-top, 0);
z-index: 1;
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;
pointer-events: auto;
align-items: center;
h1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
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; */
}
form {
/* display: flex; */
/* flex-direction: column; */
/* gap: 16px; */
text-wrap: pretty;
input {
margin-inline: 0;
}
}
.report-preview {
background-color: var(--bg-color);
border-radius: 8px;
border: 2px dashed var(--red-color);
box-shadow: inset 0 0 16px -4px var(--red-bg-color);
overflow: auto;
max-height: 33vh;
.status {
font-size: 90%;
user-select: none;
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
filter: grayscale(0.5);
}
.account-block {
margin: 16px;
user-select: none;
pointer-events: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
filter: grayscale(0.5);
}
}
.rubber-stamp {
pointer-events: none;
user-select: none;
position: absolute;
right: 32px;
margin-top: -48px;
animation: rubber-stamp 0.3s ease-in both;
position: absolute;
font-weight: bold;
color: var(--red-color);
text-transform: uppercase;
letter-spacing: -0.5px;
font-size: 2em;
line-height: 1;
padding: 0.1em;
border: 0.15em solid var(--red-color);
border-radius: 0.3em;
background-color: var(--bg-blur-color);
text-align: center;
/* Noise pattern - https://css-tricks.com/making-static-noise-from-a-weird-css-gradient-bug/ */
mask-image: repeating-conic-gradient(
#000 0 0.01%,
rgba(0, 0, 0, 0.45) 0 0.02%
);
small {
display: block;
font-size: 11px;
}
}
p {
margin-block: 0.5em;
}
section {
label {
display: flex;
gap: 8px;
align-items: center;
cursor: pointer;
margin-bottom: 8px;
&:has(:checked) {
.insignificant {
color: var(--text-color);
}
}
}
> label:last-child {
margin-bottom: 0;
}
}
.report-categories {
label {
align-items: flex-start;
}
.report-rules {
margin-left: 1.75em;
}
}
.report-comment {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 2em;
flex-wrap: wrap;
p {
margin: 0;
padding: 8px 0 0;
flex-shrink: 0;
label {
margin-bottom: 0;
}
}
textarea {
flex-grow: 1;
resize: vertical;
}
}
footer {
margin-top: 2em;
display: flex;
gap: 8px;
align-items: center;
button {
border-radius: 8px !important;
align-self: stretch;
}
}
}
@keyframes rubber-stamp {
0% {
transform: rotate(-20deg) scale(5);
opacity: 0;
}
100% {
transform: rotate(-20deg) scale(1);
opacity: 1;
}
}

Wyświetl plik

@ -0,0 +1,298 @@
import './report-modal.css';
import { Fragment } from 'preact';
import { useMemo, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import showToast from '../utils/show-toast';
import { getCurrentInstance } from '../utils/store-utils';
import AccountBlock from './account-block';
import Icon from './icon';
import Loader from './loader';
import Status from './status';
// NOTE: `dislike` hidden for now, it's actually not used for reporting
// Mastodon shows another screen for unfollowing, muting or blocking instead of reporting
const CATEGORIES = [, /*'dislike'*/ 'spam', 'legal', 'violation', 'other'];
// `violation` will be set if there are `rule_ids[]`
const CATEGORIES_INFO = {
// dislike: {
// label: 'Dislike',
// description: 'Not something you want to see',
// },
spam: {
label: 'Spam',
description: 'Malicious links, fake engagement, or repetitive replies',
},
legal: {
label: 'Illegal',
description: "Violates the law of your or the server's country",
},
violation: {
label: 'Server rule violation',
description: 'Breaks specific server rules',
stampLabel: 'Violation',
},
other: {
label: 'Other',
description: "Issue doesn't fit other categories",
excludeStamp: true,
},
};
function ReportModal({ account, post, onClose }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [username, domain] = account.acct.split('@');
const [rules, currentDomain] = useMemo(() => {
const { rules, domain } = getCurrentInstance();
return [rules || [], domain];
});
const [selectedCategory, setSelectedCategory] = useState(null);
const [showRules, setShowRules] = useState(false);
const rulesRef = useRef(null);
const [hasRules, setHasRules] = useState(false);
return (
<div class="report-modal-container">
<div class="top-controls">
<h1>{post ? 'Report Post' : `Report @${username}`}</h1>
<button
type="button"
class="plain4 small"
disabled={uiState === 'loading'}
onClick={() => onClose()}
>
<Icon icon="x" size="xl" />
</button>
</div>
<main>
<div class="report-preview">
{post ? (
<Status status={post} size="s" previewMode />
) : (
<AccountBlock
account={account}
avatarSize="xxl"
useAvatarStatic
showStats
showActivity
/>
)}
</div>
{!!selectedCategory &&
!CATEGORIES_INFO[selectedCategory].excludeStamp && (
<span
class="rubber-stamp"
key={selectedCategory}
aria-hidden="true"
>
{CATEGORIES_INFO[selectedCategory].stampLabel ||
CATEGORIES_INFO[selectedCategory].label}
<small>Pending review</small>
</span>
)}
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const entries = Object.fromEntries(formData.entries());
console.log('ENTRIES', entries);
let { category, comment, forward } = entries;
if (!comment) comment = undefined;
if (forward === 'on') forward = true;
const ruleIds =
category === 'violation'
? Object.entries(entries)
.filter(([key]) => key.startsWith('rule_ids'))
.map(([key, value]) => value)
: undefined;
const params = {
category,
comment,
forward,
ruleIds,
};
console.log('PARAMS', params);
setUIState('loading');
(async () => {
try {
await masto.v1.reports.create({
accountId: account.id,
statusIds: post?.id ? [post.id] : undefined,
category,
comment,
ruleIds,
forward,
});
setUIState('success');
showToast(post ? 'Post reported' : 'Profile reported');
onClose();
} catch (error) {
console.error(error);
setUIState('error');
showToast(
error?.message ||
(post
? 'Unable to report post'
: 'Unable to report profile'),
);
}
})();
}}
>
<p>
{post
? `What's the issue with this post?`
: `What's the issue with this profile?`}
</p>
<section class="report-categories">
{CATEGORIES.map((category) =>
category === 'violation' && !rules?.length ? null : (
<Fragment key={category}>
<label class="report-category">
<input
type="radio"
name="category"
value={category}
required
disabled={uiState === 'loading'}
onChange={(e) => {
setSelectedCategory(e.target.value);
setShowRules(e.target.value === 'violation');
}}
/>
<span>
{CATEGORIES_INFO[category].label} &nbsp;
<small class="ib insignificant">
{CATEGORIES_INFO[category].description}
</small>
</span>
</label>
{category === 'violation' && !!rules?.length && (
<div
class="shazam-container no-animation"
hidden={!showRules}
>
<div class="shazam-container-inner">
<div class="report-rules" ref={rulesRef}>
{rules.map((rule, i) => (
<label class="report-rule" key={rule.id}>
<input
type="checkbox"
name={`rule_ids[${i}]`}
value={rule.id}
required={showRules && !hasRules}
disabled={uiState === 'loading'}
onChange={(e) => {
const { checked } = e.target;
if (checked) {
setHasRules(true);
} else {
const checkedInputs =
rulesRef.current.querySelectorAll(
'input:checked',
);
if (!checkedInputs.length) {
setHasRules(false);
}
}
}}
/>
<span>{rule.text}</span>
</label>
))}
</div>
</div>
</div>
)}
</Fragment>
),
)}
</section>
<section class="report-comment">
<p>
<label for="report-comment">Additional info</label>
</p>
<textarea
maxlength="1000"
rows="1"
name="comment"
id="report-comment"
disabled={uiState === 'loading'}
/>
</section>
{!!domain && domain !== currentDomain && (
<section>
<p>
<label>
<input
type="checkbox"
switch
name="forward"
disabled={uiState === 'loading'}
/>{' '}
<span>
Forward to <i>{domain}</i>
</span>
</label>
</p>
</section>
)}
<footer>
<button type="submit" disabled={uiState === 'loading'}>
Send Report
</button>{' '}
<button
type="submit"
class="plain2"
disabled={uiState === 'loading'}
onClick={async () => {
try {
await masto.v1.accounts.$select(account.id).mute(); // Infinite duration
showToast(`Muted ${username}`);
} catch (e) {
console.error(e);
showToast(`Unable to mute ${username}`);
}
// onSubmit will still run
}}
>
Send Report <small class="ib">+ Mute profile</small>
</button>{' '}
<button
type="submit"
class="plain2"
disabled={uiState === 'loading'}
onClick={async () => {
try {
await masto.v1.accounts.$select(account.id).block();
showToast(`Blocked ${username}`);
} catch (e) {
console.error(e);
showToast(`Unable to block ${username}`);
}
// onSubmit will still run
}}
>
Send Report <small class="ib">+ Block profile</small>
</button>
<Loader hidden={uiState !== 'loading'} />
</footer>
</form>
</main>
</div>
);
}
export default ReportModal;

Wyświetl plik

@ -11,7 +11,7 @@ export default memo(function SearchCommand({ onClose = () => {} }) {
const searchFormRef = useRef(null);
useHotkeys(
'/',
['Slash', '/'],
(e) => {
setShowSearch(true);
setTimeout(() => {

Wyświetl plik

@ -73,6 +73,7 @@ const SearchForm = forwardRef((props, ref) => {
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellCheck="false"
onSearch={(e) => {
if (!e.target.value) {
setSearchParams({});
@ -84,6 +85,9 @@ const SearchForm = forwardRef((props, ref) => {
}}
onFocus={() => {
setSearchMenuOpen(true);
formRef.current
?.querySelector('.search-popover-item')
?.classList.add('focus');
}}
onBlur={() => {
setTimeout(() => {
@ -178,8 +182,33 @@ const SearchForm = forwardRef((props, ref) => {
}}
/>
<div class="search-popover" hidden={!searchMenuOpen || !query}>
{/* {!!query && (
<Link
to={`/search?q=${encodeURIComponent(query)}`}
class="search-popover-item focus"
onClick={(e) => {
props?.onSubmit?.(e);
}}
>
<Icon icon="search" />
<span>{query}</span>
</Link>
)} */}
{!!query &&
[
{
label: (
<>
{query}{' '}
<small class="insignificant">
accounts, hashtags &amp; posts
</small>
</>
),
to: `/search?q=${encodeURIComponent(query)}`,
top: !type && !/\s/.test(query),
hidden: !!type,
},
{
label: (
<>
@ -188,6 +217,9 @@ const SearchForm = forwardRef((props, ref) => {
),
to: `/search?q=${encodeURIComponent(query)}&type=statuses`,
hidden: /^https?:/.test(query),
top: /\s/.test(query),
icon: 'document',
queryType: 'statuses',
},
{
label: (
@ -200,6 +232,8 @@ const SearchForm = forwardRef((props, ref) => {
/^@/.test(query) || /^https?:/.test(query) || /\s/.test(query),
top: /^#/.test(query),
type: 'link',
icon: 'hashtag',
queryType: 'hashtags',
},
{
label: (
@ -219,24 +253,31 @@ const SearchForm = forwardRef((props, ref) => {
</>
),
to: `/search?q=${encodeURIComponent(query)}&type=accounts`,
icon: 'group',
queryType: 'accounts',
},
]
.sort((a, b) => {
if (type) {
if (a.queryType === type) return -1;
if (b.queryType === type) return 1;
}
if (a.top && !b.top) return -1;
if (!a.top && b.top) return 1;
return 0;
})
.map(({ label, to, hidden, type }) => (
.filter(({ hidden }) => !hidden)
.map(({ label, to, icon, type }, i) => (
<Link
to={to}
class="search-popover-item"
hidden={hidden}
class={`search-popover-item ${i === 0 ? 'focus' : ''}`}
// hidden={hidden}
onClick={(e) => {
props?.onSubmit?.(e);
}}
>
<Icon
icon={type === 'link' ? 'arrow-right' : 'search'}
icon={icon || (type === 'link' ? 'arrow-right' : 'search')}
class="more-insignificant"
/>
<span>{label}</span>{' '}

Wyświetl plik

@ -36,7 +36,7 @@
#shortcuts-settings-container .shortcuts-view-mode {
display: flex;
align-items: center;
align-items: stretch;
gap: 2px;
margin: 8px 0 0;
}
@ -52,6 +52,7 @@
gap: 8px;
flex-direction: column;
align-items: center;
justify-content: center;
}
#shortcuts-settings-container .shortcuts-view-mode label:first-child {
border-top-left-radius: 16px;
@ -152,6 +153,15 @@
}
#import-export-container section p {
margin: 8px 0;
&.field-button {
display: flex;
gap: 8px;
button {
flex-shrink: 0;
}
}
}
#import-export-container section details > summary {
cursor: pointer;
@ -181,3 +191,14 @@
font-size: 90%;
flex-shrink: 0;
}
#import-export-container {
footer {
font-size: 90%;
color: var(--text-insignificant-color);
.icon {
vertical-align: text-bottom;
}
}
}

Wyświetl plik

@ -14,9 +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';
@ -42,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',
@ -57,6 +60,7 @@ const TYPE_PARAMS = {
{
text: 'List ID',
name: 'id',
notRequired: true,
},
],
public: [
@ -121,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;
@ -149,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',
@ -170,7 +171,7 @@ export const SHORTCUTS_META = {
},
search: {
id: 'search',
title: ({ query }) => (query ? `"${query}"` : 'Search'),
title: ({ query }) => (query ? `${query}` : 'Search'),
path: ({ query }) =>
query
? `/search?q=${encodeURIComponent(query)}&type=statuses`
@ -279,92 +280,93 @@ function ShortcutsSettings({ onClose }) {
})}
</div>
{shortcuts.length > 0 ? (
<ol class="shortcuts-list" ref={shortcutsListParent}>
{shortcuts.filter(Boolean).map((shortcut, i) => {
// const key = i + Object.values(shortcut);
const key = Object.values(shortcut).join('-');
const { type } = shortcut;
if (!SHORTCUTS_META[type]) return null;
let { icon, title, subtitle, excludeViewMode } =
SHORTCUTS_META[type];
if (typeof title === 'function') {
title = title(shortcut, i);
}
if (typeof subtitle === 'function') {
subtitle = subtitle(shortcut, i);
}
if (typeof icon === 'function') {
icon = icon(shortcut, i);
}
if (typeof excludeViewMode === 'function') {
excludeViewMode = excludeViewMode(shortcut, i);
}
const excludedViewMode = excludeViewMode?.includes(
snapStates.settings.shortcutsViewMode,
);
return (
<li key={key}>
<Icon icon={icon} />
<span class="shortcut-text">
<AsyncText>{title}</AsyncText>
{subtitle && (
<>
{' '}
<small class="ib insignificant">{subtitle}</small>
</>
)}
{excludedViewMode && (
<span class="tag">
Not available in current view mode
</span>
)}
</span>
<span class="shortcut-actions">
<button
type="button"
class="plain small"
disabled={i === 0}
onClick={() => {
const shortcutsArr = Array.from(states.shortcuts);
if (i > 0) {
const temp = states.shortcuts[i - 1];
shortcutsArr[i - 1] = shortcut;
shortcutsArr[i] = temp;
states.shortcuts = shortcutsArr;
}
}}
>
<Icon icon="arrow-up" alt="Move up" />
</button>
<button
type="button"
class="plain small"
disabled={i === shortcuts.length - 1}
onClick={() => {
const shortcutsArr = Array.from(states.shortcuts);
if (i < states.shortcuts.length - 1) {
const temp = states.shortcuts[i + 1];
shortcutsArr[i + 1] = shortcut;
shortcutsArr[i] = temp;
states.shortcuts = shortcutsArr;
}
}}
>
<Icon icon="arrow-down" alt="Move down" />
</button>
<button
type="button"
class="plain small"
onClick={() => {
setShowForm({
shortcut,
shortcutIndex: i,
});
}}
>
<Icon icon="pencil" alt="Edit" />
</button>
{/* <button
<>
<ol class="shortcuts-list" ref={shortcutsListParent}>
{shortcuts.filter(Boolean).map((shortcut, i) => {
// const key = i + Object.values(shortcut);
const key = Object.values(shortcut).join('-');
const { type } = shortcut;
if (!SHORTCUTS_META[type]) return null;
let { icon, title, subtitle, excludeViewMode } =
SHORTCUTS_META[type];
if (typeof title === 'function') {
title = title(shortcut, i);
}
if (typeof subtitle === 'function') {
subtitle = subtitle(shortcut, i);
}
if (typeof icon === 'function') {
icon = icon(shortcut, i);
}
if (typeof excludeViewMode === 'function') {
excludeViewMode = excludeViewMode(shortcut, i);
}
const excludedViewMode = excludeViewMode?.includes(
snapStates.settings.shortcutsViewMode,
);
return (
<li key={key}>
<Icon icon={icon} />
<span class="shortcut-text">
<AsyncText>{title}</AsyncText>
{subtitle && (
<>
{' '}
<small class="ib insignificant">{subtitle}</small>
</>
)}
{excludedViewMode && (
<span class="tag">
Not available in current view mode
</span>
)}
</span>
<span class="shortcut-actions">
<button
type="button"
class="plain small"
disabled={i === 0}
onClick={() => {
const shortcutsArr = Array.from(states.shortcuts);
if (i > 0) {
const temp = states.shortcuts[i - 1];
shortcutsArr[i - 1] = shortcut;
shortcutsArr[i] = temp;
states.shortcuts = shortcutsArr;
}
}}
>
<Icon icon="arrow-up" alt="Move up" />
</button>
<button
type="button"
class="plain small"
disabled={i === shortcuts.length - 1}
onClick={() => {
const shortcutsArr = Array.from(states.shortcuts);
if (i < states.shortcuts.length - 1) {
const temp = states.shortcuts[i + 1];
shortcutsArr[i + 1] = shortcut;
shortcutsArr[i] = temp;
states.shortcuts = shortcutsArr;
}
}}
>
<Icon icon="arrow-down" alt="Move down" />
</button>
<button
type="button"
class="plain small"
onClick={() => {
setShowForm({
shortcut,
shortcutIndex: i,
});
}}
>
<Icon icon="pencil" alt="Edit" />
</button>
{/* <button
type="button"
class="plain small"
onClick={() => {
@ -373,14 +375,28 @@ function ShortcutsSettings({ onClose }) {
>
<Icon icon="x" alt="Remove" />
</button> */}
</span>
</li>
);
})}
</ol>
</span>
</li>
);
})}
</ol>
{shortcuts.length === 1 &&
snapStates.settings.shortcutsViewMode !== 'float-button' && (
<div class="ui-state insignificant">
<Icon icon="info" />{' '}
<small>
Add more than one shortcut/column to make this work.
</small>
</div>
)}
</>
) : (
<div class="ui-state insignificant">
<p>No shortcuts yet. Tap on the Add shortcut button.</p>
<p>
{snapStates.settings.shortcutsViewMode === 'multi-column'
? 'No columns yet. Tap on the Add column button.'
: 'No shortcuts yet. Tap on the Add shortcut button.'}
</p>
<p>
Not sure what to add?
<br />
@ -407,7 +423,9 @@ function ShortcutsSettings({ onClose }) {
)}
<p class="insignificant">
{shortcuts.length >= SHORTCUTS_LIMIT &&
`Max ${SHORTCUTS_LIMIT} shortcuts`}
(snapStates.settings.shortcutsViewMode === 'multi-column'
? `Max ${SHORTCUTS_LIMIT} columns`
: `Max ${SHORTCUTS_LIMIT} shortcuts`)}
</p>
<p
style={{
@ -428,13 +446,17 @@ function ShortcutsSettings({ onClose }) {
disabled={shortcuts.length >= SHORTCUTS_LIMIT}
onClick={() => setShowForm(true)}
>
<Icon icon="plus" /> <span>Add shortcut</span>
<Icon icon="plus" />{' '}
<span>
{snapStates.settings.shortcutsViewMode === 'multi-column'
? 'Add column…'
: 'Add shortcut…'}
</span>
</button>
</p>
</main>
{showForm && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowForm(false);
@ -458,7 +480,6 @@ function ShortcutsSettings({ onClose }) {
)}
{showImportExport && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowImportExport(false);
@ -475,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.',
};
@ -511,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 +633,7 @@ function ShortcutForm({
disabled={disabled || uiState === 'loading'}
defaultValue={editMode ? shortcut.id : undefined}
>
<option value=""></option>
{lists.map((list) => (
<option value={list.id}>{list.title}</option>
))}
@ -650,7 +661,7 @@ function ShortcutForm({
}
autocorrect="off"
autocapitalize="off"
spellcheck={false}
spellCheck={false}
pattern={pattern}
/>
{currentType === 'hashtag' &&
@ -700,6 +711,7 @@ function ShortcutForm({
}
function ImportExport({ shortcuts, onClose }) {
const { masto } = api();
const shortcutsStr = useMemo(() => {
if (!shortcuts) return '';
if (!shortcuts.filter(Boolean).length) return '';
@ -738,6 +750,8 @@ function ImportExport({ shortcuts, onClose }) {
}, [importShortcutStr]);
const hasCurrentSettings = states.shortcuts.length > 0;
const shortcutsImportFieldRef = useRef();
return (
<div id="import-export-container" class="sheet">
{!!onClose && (
@ -756,8 +770,9 @@ function ImportExport({ shortcuts, onClose }) {
<Icon icon="arrow-down-circle" size="l" class="insignificant" />{' '}
<span>Import</span>
</h3>
<p>
<p class="field-button">
<input
ref={shortcutsImportFieldRef}
type="text"
name="import"
placeholder="Paste shortcuts here"
@ -766,6 +781,53 @@ function ImportExport({ shortcuts, onClose }) {
setImportShortcutStr(e.target.value);
}}
/>
{states.settings.shortcutSettingsCloudImportExport && (
<button
type="button"
class="plain2 small"
disabled={importUIState === 'cloud-downloading'}
onClick={async () => {
setImportUIState('cloud-downloading');
const currentAccount = getCurrentAccountID();
showToast(
'Downloading saved shortcuts from instance server…',
);
try {
const relationships =
await masto.v1.accounts.relationships.fetch({
id: [currentAccount],
});
const relationship = relationships[0];
if (relationship) {
const { note = '' } = relationship;
if (
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/.test(
note,
)
) {
const settings = note.match(
/<phanpy-shortcuts-settings>(.*)<\/phanpy-shortcuts-settings>/,
)[1];
const { v, dt, data } = JSON.parse(settings);
shortcutsImportFieldRef.current.value = data;
shortcutsImportFieldRef.current.dispatchEvent(
new Event('input'),
);
}
}
setImportUIState('default');
} catch (e) {
console.error(e);
setImportUIState('error');
showToast('Unable to download shortcuts');
}
}}
title="Download shortcuts from instance server"
>
<Icon icon="cloud" />
<Icon icon="arrow-down" />
</button>
)}
</p>
{!!parsedImportShortcutStr &&
Array.isArray(parsedImportShortcutStr) && (
@ -975,8 +1037,64 @@ function ImportExport({ shortcuts, onClose }) {
<Icon icon="share" /> <span>Share</span>
</button>
)}{' '}
{states.settings.shortcutSettingsCloudImportExport && (
<button
type="button"
class="plain2"
disabled={importUIState === 'cloud-uploading'}
onClick={async () => {
setImportUIState('cloud-uploading');
const currentAccount = getCurrentAccountID();
try {
const relationships =
await masto.v1.accounts.relationships.fetch({
id: [currentAccount],
});
const relationship = relationships[0];
if (relationship) {
const { note = '' } = relationship;
// const newNote = `${note}\n\n\n$<phanpy-shortcuts-settings>{shortcutsStr}</phanpy-shortcuts-settings>`;
let newNote = '';
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>`,
);
} else {
newNote = `${note}\n\n\n<phanpy-shortcuts-settings>${settingsJSON}</phanpy-shortcuts-settings>`;
}
showToast('Saving shortcuts to instance server…');
await masto.v1.accounts
.$select(currentAccount)
.note.create({
comment: newNote,
});
setImportUIState('default');
showToast('Shortcuts saved');
}
} catch (e) {
console.error(e);
setImportUIState('error');
showToast('Unable to save shortcuts');
}
}}
title="Sync to instance server"
>
<Icon icon="cloud" />
<Icon icon="arrow-up" />
</button>
)}{' '}
{shortcutsStr.length > 0 && (
<small class="insignificant">
<small class="insignificant ib">
{shortcutsStr.length} characters
</small>
)}
@ -992,6 +1110,14 @@ function ImportExport({ shortcuts, onClose }) {
</details>
)}
</section>
{states.settings.shortcutSettingsCloudImportExport && (
<footer>
<p>
<Icon icon="cloud" /> Import/export settings from/to instance
server (Very experimental)
</p>
</footer>
)}
</main>
</div>
);

Wyświetl plik

@ -1,14 +1,15 @@
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';
@ -16,6 +17,7 @@ import Icon from './icon';
import Link from './link';
import Menu2 from './menu2';
import MenuLink from './menu-link';
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}

Plik diff jest za duży Load Diff

Plik diff jest za duży Load Diff

Wyświetl plik

@ -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?.();
}
},
}}
/>
);
}

Wyświetl plik

@ -1,4 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { memo } from 'preact/compat';
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';
@ -8,6 +15,7 @@ import FilterContext from '../utils/filter-context';
import { filteredItems, isFiltered } from '../utils/filters';
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';
@ -46,10 +54,11 @@ function Timeline({
view,
filterContext,
showFollowedTags,
showReplyParent,
}) {
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);
@ -57,6 +66,8 @@ function Timeline({
console.debug('RENDER Timeline', id, refresh);
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
const allowGrouping = view !== 'media';
const loadItems = useDebouncedCallback(
(firstLoad) => {
@ -84,7 +95,7 @@ function Timeline({
if (boostsCarousel) {
value = groupBoosts(value);
}
value = groupContext(value);
value = groupContext(value, instance);
}
if (pinnedPosts.length) {
value = pinnedPosts.concat(value);
@ -198,12 +209,23 @@ 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();
}
});
const showNewPostsIndicator =
items.length > 0 && uiState !== 'loading' && showNew;
const handleLoadNewPosts = useCallback(() => {
if (showNewPostsIndicator) loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}, [loadItems, showNewPostsIndicator]);
const dotRef = useHotkeys('.', handleLoadNewPosts);
// const {
// scrollDirection,
// nearReachStart,
@ -227,9 +249,9 @@ function Timeline({
({
scrollDirection,
nearReachStart,
nearReachEnd,
// nearReachEnd,
reachStart,
reachEnd,
// reachEnd,
}) => {
// setHiddenUI(scrollDirection === 'end' && !nearReachEnd);
if (headerRef.current) {
@ -342,12 +364,15 @@ 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"
>
@ -387,24 +412,15 @@ function Timeline({
{!!headerEnd && headerEnd}
</div>
</div>
{items.length > 0 &&
uiState !== 'loading' &&
// !hiddenUI &&
showNew && (
<button
class="updates-button shiny-pill"
type="button"
onClick={() => {
loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
{showNewPostsIndicator && (
<button
class="updates-button shiny-pill"
type="button"
onClick={handleLoadNewPosts}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{!!timelineStart && (
<div
@ -426,6 +442,8 @@ function Timeline({
key={status.id + status?._pinned + view}
view={view}
showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent}
mediaFirst={mediaFirst}
/>
))}
{showMore &&
@ -437,14 +455,14 @@ function Timeline({
height: '20vh',
}}
>
<Status skeleton />
<Status skeleton mediaFirst={mediaFirst} />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
<Status skeleton mediaFirst={mediaFirst} />
</li>
</>
))}
@ -484,13 +502,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">
@ -508,187 +527,215 @@ function Timeline({
);
}
function TimelineItem({
status,
instance,
useItemID,
// allowFilters,
filterContext,
view,
showFollowedTags,
}) {
const { id: statusID, reblog, items, type, _pinned } = status;
if (_pinned) useItemID = false;
const actualStatusID = reblog?.id || statusID;
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);
if (isCarousel) {
// Here, we don't hide filtered posts, but we sort them last
fItems.sort((a, b) => {
// if (a._filtered && !b._filtered) {
// return 1;
// }
// if (!a._filtered && b._filtered) {
// return -1;
// }
const aFiltered = isFiltered(a.filtered, filterContext);
const bFiltered = isFiltered(b.filtered, filterContext);
if (aFiltered && !bFiltered) {
return 1;
}
if (!aFiltered && bFiltered) {
return -1;
}
return 0;
const TimelineItem = memo(
({
status,
instance,
useItemID,
// allowFilters,
filterContext,
view,
showFollowedTags,
showReplyParent,
mediaFirst,
}) => {
console.debug('RENDER TimelineItem', status.id);
const { id: statusID, reblog, items, type, _pinned } = status;
if (_pinned) useItemID = false;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
if (items) {
const 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) {
// Here, we don't hide filtered posts, but we sort them last
fItems.sort((a, b) => {
// if (a._filtered && !b._filtered) {
// return 1;
// }
// if (!a._filtered && b._filtered) {
// return -1;
// }
const aFiltered = isFiltered(a.filtered, filterContext);
const bFiltered = isFiltered(b.filtered, filterContext);
if (aFiltered && !bFiltered) {
return 1;
}
if (!aFiltered && bFiltered) {
return -1;
}
return 0;
});
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 actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
if (_pinned) useItemID = false;
return (
<li key={statusID}>
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
enableCommentHint
// allowFilters={allowFilters}
mediaFirst={mediaFirst}
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
enableCommentHint
// allowFilters={allowFilters}
mediaFirst={mediaFirst}
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
);
}
const manyItems = fItems.length > 3;
return fItems.map((item, i) => {
const { id: statusID, _differentAuthor } = item;
const url = instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`;
const isMiddle = i > 0 && i < fItems.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(!_differentAuthor && isSpoiler && i > 0) ||
(manyItems &&
isMiddle &&
(type === 'thread' ||
(type === 'conversation' &&
!_differentAuthor &&
!fItems[i - 1]._differentAuthor &&
!fItems[i + 1]._differentAuthor)));
const isStart = i === 0;
const isEnd = i === fItems.length - 1;
return (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
isStart ? 'start' : isEnd ? 'end' : 'middle'
} ${_differentAuthor ? 'timeline-item-diff-author' : ''}`}
>
<Link class="status-link timeline-item" to={url}>
{showCompact ? (
<TimelineStatusCompact
status={item}
instance={instance}
filterContext={filterContext}
/>
) : useItemID ? (
<Status
statusID={statusID}
instance={instance}
enableCommentHint={isEnd}
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
enableCommentHint={isEnd}
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
});
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 actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
if (_pinned) useItemID = false;
return (
<li key={statusID}>
<Link class="status-carousel-link timeline-item-alt" to={url}>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
enableCommentHint
// allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
enableCommentHint
// allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
}
const itemKey = `timeline-${statusID + _pinned}`;
if (view === 'media') {
return useItemID ? (
<MediaPost
class="timeline-item"
parent="li"
key={itemKey}
statusID={statusID}
instance={instance}
// allowFilters={allowFilters}
/>
) : (
<MediaPost
class="timeline-item"
parent="li"
key={itemKey}
status={status}
instance={instance}
// allowFilters={allowFilters}
/>
);
}
const manyItems = fItems.length > 3;
return fItems.map((item, i) => {
const { id: statusID, _differentAuthor } = item;
const url = instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`;
const isMiddle = i > 0 && i < fItems.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(!_differentAuthor && isSpoiler && i > 0) ||
(manyItems &&
isMiddle &&
(type === 'thread' ||
(type === 'conversation' &&
!_differentAuthor &&
!fItems[i - 1]._differentAuthor &&
!fItems[i + 1]._differentAuthor)));
const isStart = i === 0;
const isEnd = i === fItems.length - 1;
return (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
isStart ? 'start' : isEnd ? 'end' : 'middle'
} ${_differentAuthor ? 'timeline-item-diff-author' : ''}`}
>
<Link class="status-link timeline-item" to={url}>
{showCompact ? (
<TimelineStatusCompact status={item} instance={instance} />
) : useItemID ? (
<Status
statusID={statusID}
instance={instance}
enableCommentHint={isEnd}
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
enableCommentHint={isEnd}
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
});
}
const itemKey = `timeline-${statusID + _pinned}`;
if (view === 'media') {
return useItemID ? (
<MediaPost
class="timeline-item"
parent="li"
key={itemKey}
statusID={statusID}
instance={instance}
// allowFilters={allowFilters}
/>
) : (
<MediaPost
class="timeline-item"
parent="li"
key={itemKey}
status={status}
instance={instance}
// allowFilters={allowFilters}
/>
return (
<li key={itemKey}>
<Link class="status-link timeline-item" to={url}>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
enableCommentHint
showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent}
// allowFilters={allowFilters}
mediaFirst={mediaFirst}
/>
) : (
<Status
status={status}
instance={instance}
enableCommentHint
showFollowedTags={showFollowedTags}
showReplyParent={showReplyParent}
// allowFilters={allowFilters}
mediaFirst={mediaFirst}
/>
)}
</Link>
</li>
);
}
return (
<li key={itemKey}>
<Link class="status-link timeline-item" to={url}>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
enableCommentHint
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
) : (
<Status
status={status}
instance={instance}
enableCommentHint
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
}
},
(oldProps, newProps) => {
const oldID = (oldProps.status?.id || '').toString();
const newID = (newProps.status?.id || '').toString();
return (
oldID === newID &&
oldProps.instance === newProps.instance &&
oldProps.view === newProps.view
);
},
);
function StatusCarousel({ title, class: className, children }) {
const carouselRef = useRef();
@ -777,11 +824,12 @@ function StatusCarousel({ title, class: className, children }) {
);
}
function TimelineStatusCompact({ status, instance }) {
function TimelineStatusCompact({ status, instance, filterContext }) {
const snapStates = useSnapshot(states);
const { id, visibility } = status;
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 ${
@ -801,14 +849,30 @@ function TimelineStatusCompact({ status, instance }) {
<Icon icon="thread" size="s" />
</div>
)}
<div class="content-compact" title={statusPeekText}>
{statusPeekText}
{status.sensitive && status.spoilerText && (
<div
class="content-compact"
title={statusPeekText}
lang={language}
dir="auto"
>
{!!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>

Wyświetl plik

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

Wyświetl plik

@ -5,7 +5,8 @@ import './app.css';
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}

Wyświetl plik

@ -1,4 +1,6 @@
{
"@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"
}

Wyświetl plik

@ -1,670 +1,384 @@
[
"mastodon.social",
"pravda.me",
"pawoo.net",
"mstdn.jp",
"mstdn.social",
"mastodon.online",
"mastodon.world",
"mas.to",
"mastodon.world",
"infosec.exchange",
"fedibird.com",
"fosstodon.org",
"hachyderm.io",
"troet.cafe",
"mastodon.uno",
"m.cmx.im",
"techhub.social",
"piaille.fr",
"planet.moe",
"mastodon.gamedev.place",
"mastodonapp.uk",
"universeodon.com",
"mastodon.uno",
"chaos.social",
"mastodon.art",
"mastodon.nl",
"mastodon.cloud",
"social.vivaldi.net",
"mastodon-japan.net",
"mstdn.ca",
"thu.closed.social",
"universeodon.com",
"mastodon.sdf.org",
"noagendasocial.com",
"kolektiva.social",
"alive.bar",
"c.im",
"o3o.ca",
"masto.ai",
"tech.lgbt",
"mstdn.ca",
"kolektiva.social",
"mastodon-japan.net",
"norden.social",
"o3o.ca",
"sfba.social",
"social.tchncs.de",
"meow.social",
"wxw.moe",
"aus.social",
"mastodon.scot",
"nrw.social",
"tech.lgbt",
"mastodon.scot",
"mstdn.party",
"det.social",
"aethy.com",
"occm.cc",
"newsie.social",
"aus.social",
"mathstodon.xyz",
"toot.community",
"ohai.social",
"mastodon.top",
"mastodon.ie",
"mathstodon.xyz",
"twingyeo.kr",
"mamot.fr",
"sueden.social",
"dice.camp",
"botsin.space",
"mastodon.nu",
"mastodon.ie",
"mastodon.top",
"mastodontech.de",
"loforo.com",
"mindly.social",
"ro-mastodon.puyo.jp",
"twit.social",
"mastodon.nu",
"masto.es",
"freemasonry.social",
"ioc.exchange",
"mindly.social",
"hessen.social",
"defcon.social",
"ruhr.social",
"mastodon.au",
"kind.social",
"mastodon.green",
"nerdculture.de",
"social.cologne",
"mastodon.nz",
"fediscience.org",
"mastouille.fr",
"social.anoxinon.de",
"muenchen.social",
"qoto.org",
"framapiaf.org",
"akamdon.com",
"indieweb.social",
"defcon.social",
"social.anoxinon.de",
"mastodon.green",
"mastouille.fr",
"social.linux.pizza",
"respublicae.eu",
"me.dm",
"eldritch.cafe",
"wandering.shop",
"mastodon.com.tr",
"g0v.social",
"mstdn.science",
"sigmoid.social",
"ecoevo.social",
"ruby.social",
"zirk.us",
"mastodon.xyz",
"mstdn.guru",
"social.librem.one",
"ravenation.club",
"ieji.de",
"social.cologne",
"indieweb.social",
"livellosegreto.it",
"mastodont.cat",
"cubhub.social",
"bildung.social",
"noc.social",
"kinky.business",
"mastodonczech.cz",
"kemonodon.club",
"toot.wales",
"med-mastodon.com",
"urbanists.social",
"octodon.social",
"kmy.blue",
"digitalcourage.social",
"masto.nu",
"mastodontti.fi",
"pouet.chapril.org",
"ruby.social",
"ieji.de",
"mastodon.nz",
"toot.io",
"climatejustice.social",
"mastodon.radio",
"woof.group",
"tkz.one",
"mastodont.cat",
"social.tchncs.de",
"mastodon.com.tr",
"noc.social",
"sciences.social",
"social.dev-wiki.de",
"econtwitter.net",
"toot.wales",
"masto.nu",
"phpc.social",
"metalhead.club",
"yiff.life",
"berlin.social",
"101010.pl",
"mstdn.plus",
"mastodon.fun",
"rubber.social",
"mastodon.iriseden.eu",
"mastodon.bida.im",
"mastodon.ml",
"social.lol",
"rollenspiel.social",
"mstdn.games",
"best-friends.chat",
"equestria.social",
"wien.rocks",
"androiddev.social",
"socel.net",
"paquita.masto.host",
"mastodon.me.uk",
"genomic.social",
"scholar.social",
"snabelen.no",
"social.dev-wiki.de",
"cyberplace.social",
"dresden.network",
"swiss.social",
"wobbl.xyz",
"hostux.social",
"ffxiv-mastodon.com",
"masto.pt",
"shakedown.social",
"uri.life",
"ursal.zone",
"pol.social",
"writing.exchange",
"typo.social",
"wetdry.world",
"mast.lat",
"todon.eu",
"floss.social",
"astrodon.social",
"tabletop.social",
"freiburg.social",
"mastodon-belgium.be",
"hci.social",
"qdon.space",
"openbiblio.social",
"gulp.cafe",
"mastodon.zaclys.com",
"tooot.im",
"stranger.social",
"theblower.au",
"furry.engineer",
"mstdn.io",
"toad.social",
"abdl.link",
"geekdom.social",
"journa.host",
"social.treehouse.systems",
"hcommons.social",
"mastoturk.org",
"glasgow.social",
"queer.party",
"social.coop",
"tooting.ch",
"peoplemaking.games",
"mastodonners.nl",
"pawb.fun",
"awscommunity.social",
"muenster.im",
"rivals.space",
"union.place",
"mastodontti.fi",
"climatejustice.social",
"urbanists.social",
"mstdn.plus",
"metalhead.club",
"ravenation.club",
"mastodon.ml",
"fairy.id",
"feuerwehr.social",
"dresden.network",
"stranger.social",
"mastodon.iriseden.eu",
"rollenspiel.social",
"pol.social",
"mstdn.business",
"mstdn.games",
"wien.rocks",
"h4.io",
"dju.social",
"toot.aquilenet.fr",
"vis.social",
"blorbo.social",
"otadon.com",
"imastodon.net",
"tilde.zone",
"vmst.io",
"lor.sh",
"mstdn.maud.io",
"ika.queloud.net",
"bonn.social",
"nofan.xyz",
"socel.net",
"mastodon.eus",
"wehavecookies.social",
"glasgow.social",
"mastodon.me.uk",
"uri.life",
"hostux.social",
"theblower.au",
"mastodon-uk.net",
"masto.pt",
"awscommunity.social",
"flipboard.social",
"mast.lat",
"freiburg.social",
"snabelen.no",
"mastodon.zaclys.com",
"muenster.im",
"mastodon-belgium.be",
"geekdom.social",
"hcommons.social",
"tooot.im",
"tooting.ch",
"rheinneckar.social",
"toot.cat",
"discuss.systems",
"types.pl",
"vocalodon.net",
"4bear.com",
"girlcock.club",
"sunny.garden",
"mapstodon.space",
"emacs.ch",
"medibubble.org",
"famichiki.jp",
"blog.famichiki.jp",
"bsd.network",
"fandom.ink",
"cybre.space",
"persiansmastodon.com",
"ludosphere.fr",
"oldbytes.space",
"toot.blue",
"witches.live",
"cupoftea.social",
"photog.social",
"historians.social",
"weirder.earth",
"graphics.social",
"layer8.space",
"mstdn.in.th",
"pokemon.mastportal.info",
"social.sciences.re",
"vt.social",
"iosdev.space",
"todon.nl",
"masto.bike",
"gruene.social",
"flipboard.social",
"bolha.us",
"douchi.space",
"literatur.social",
"freak.university",
"jorts.horse",
"neuromatch.social",
"merveilles.town",
"shelter.moe",
"retro.pizza",
"scicomm.xyz",
"mastorol.es",
"disabled.social",
"trpg-o.xyz",
"freeradical.zone",
"mastodon.gal",
"expressional.social",
"h5q.net",
"witter.cz",
"mastodonbooks.net",
"vkl.world",
"toad.social",
"lor.sh",
"peoplemaking.games",
"union.place",
"bark.lgbt",
"bonn.social",
"tilde.zone",
"vmst.io",
"mastodon.berlin",
"lile.cl",
"chitter.xyz",
"mona.do",
"mast.dragon-fly.club",
"spore.social",
"liker.social",
"furries.club",
"neurodifferent.me",
"mastodon.design",
"macaw.social",
"toot.lv",
"linuxrocks.online",
"bitbang.social",
"mstdn.beer",
"lgbtqia.space",
"transfur.social",
"climatejustice.rocks",
"glammr.us",
"mastodon.coffee",
"vtdon.com",
"gaypirates.club",
"oslo.town",
"mental.social",
"post.lurk.org",
"eupolicy.social",
"parfait.day",
"xoxo.zone",
"graz.social",
"emacs.ch",
"blorbo.social",
"furry.engineer",
"rivals.space",
"cupoftea.social",
"qdon.space",
"graphics.social",
"veganism.social",
"sciencemastodon.com",
"be-lieve.hostdon.ne.jp",
"kinkyelephant.com",
"twiukraine.com",
"ludosphere.fr",
"4bear.com",
"famichiki.jp",
"expressional.social",
"convo.casa",
"artisan.chat",
"pettingzoo.co",
"sociale.network",
"pkm.social",
"masto.nobigtech.es",
"urusai.social",
"freeatlantis.com",
"fairy.id",
"blimps.xyz",
"mastodon.la",
"musician.social",
"mustard.blog",
"m.otter.homes",
"mastodon.cat",
"dizl.de",
"mastodon.energy",
"sself.co",
"mastodon.arch-linux.cz",
"kirche.social",
"toot.cafe",
"mastodon.org.uk",
"mstdn.mx",
"federatedfandom.net",
"pnw.zone",
"xarxa.cloud",
"cr8r.gg",
"darmstadt.social",
"toot.funami.tech",
"awoo.space",
"mastodon.uy",
"plush.city",
"cztwitter.cz",
"thecanadian.social",
"thicc.horse",
"digipres.club",
"libretooth.gr",
"trpg.cloud",
"m.ai6yr.org",
"ramen-fsm.eu.org",
"jawns.club",
"mastodon.in.th",
"mao.mastodonhub.com",
"romancelandia.club",
"historians.social",
"mastorol.es",
"retro.pizza",
"shelter.moe",
"mast.dragon-fly.club",
"sakurajima.moe",
"mastodon.content.town",
"esperanto.masto.host",
"toot.bike",
"mastodon.arch-linux.cz",
"squawk.mytransponder.com",
"mastodon.gal",
"disabled.social",
"vkl.world",
"eupolicy.social",
"fandom.ink",
"toot.funami.tech",
"mastodonbooks.net",
"lgbtqia.space",
"witter.cz",
"planetearth.social",
"oslo.town",
"mastodon.com.pl",
"pawb.fun",
"darmstadt.social",
"masto.nobigtech.es",
"cr8r.gg",
"pnw.zone",
"hear-me.social",
"furries.club",
"gaygeek.social",
"birdon.social",
"mastodon.energy",
"mastodon-swiss.org",
"dizl.de",
"libretooth.gr",
"mustard.blog",
"machteburch.social",
"fulda.social",
"worldkey.io",
"nnia.space",
"plural.cafe",
"federated.press",
"ani.work",
"better.boston",
"vulpine.club",
"ichiji.social",
"freemasonry.social",
"iztasocial.site",
"functional.cafe",
"warhammer.social",
"mastodo.fi",
"mstdn.kemono-friends.info",
"mastodon.acm.org",
"dotnet.social",
"mas.town",
"liberdon.com",
"muri.network",
"babka.social",
"social.bau-ha.us",
"oc.todon.fr",
"nafo.uk",
"kirakiratter.com",
"sleeping.town",
"masto.nyc",
"mastodon.hams.social",
"donphan.social",
"en.osm.town",
"toot.portes-imaginaire.org",
"biplus.date",
"podcastindex.social",
"mastodon.mim-libre.fr",
"data-folks.masto.host",
"sukebe.hostdon.ne.jp",
"akademienl.social",
"gearheads.social",
"sauropods.win",
"archaeo.social",
"bear.community",
"swiss-talk.net",
"social.seattle.wa.us",
"assemblag.es",
"toot.kif.rocks",
"mograph.social",
"icosahedron.website",
"kurry.social",
"spacey.space",
"is.nota.live",
"cryptodon.lol",
"bbq.snoot.com",
"ai.wiki",
"photodn.net",
"im-in.space",
"opalstack.social",
"social.kyiv.dcomm.net.ua",
"wargamers.social",
"hometech.social",
"mastodon.mnetwork.co.kr",
"mstdn-bike.net",
"toot.si",
"gensokyo.town",
"social.politicaconciencia.org",
"tyrol.social",
"col.social",
"kopiti.am",
"norcal.social",
"toot.berlin",
"bookwor.ms",
"mastodon.uy",
"xarxa.cloud",
"corteximplant.com",
"mastodon.london",
"urusai.social",
"thecanadian.social",
"federated.press",
"kanoa.de",
"mstdn.dk",
"h-net.social",
"jasette.facil.services",
"sunbeam.city",
"eightpoint.app",
"mstdn.tokyocameraclub.com",
"dobbs.town",
"est.social",
"mastoot.fr",
"epicure.social",
"artsio.com",
"social.edu.nl",
"mstdn.id",
"mstdn.fr",
"mastodon.com.br",
"seocommunity.social",
"hello.2heng.xin",
"arsenalfc.social",
"computerfairi.es",
"mastodon.hypnoguys.com",
"beekeeping.ninja",
"mastodon.tetaneutral.net",
"kirishima.cloud",
"tablegame.mstdn.cloud",
"otogamer.me",
"eigadon.net",
"federate.social",
"qubit-social.xyz",
"pointless.chat",
"mastodon.mit.edu",
"tribe.net",
"osna.social",
"uwu.social",
"blacktwitter.io",
"toki.social",
"gameliberty.club",
"colearn.social",
"esq.social",
"blabber.lu-rp.net",
"elekk.xyz",
"xn--lofll-1sat.is",
"flower.afn.social",
"cloud-native.social",
"burma.social",
"loðfíll.is",
"guitar.rodeo",
"nederland.online",
"mastodon.gougere.fr",
"piano.masto.host",
"mastodon.education",
"moresci.sale",
"social.veraciousnetwork.com",
"hispagatos.space",
"apobangpo.space",
"mastodon.juggler.jp",
"mastodon.bayern",
"dingdash.com",
"mastodon.hk",
"outdoors.lgbt",
"irsoluciones.social",
"mastodon.vlaanderen",
"9kb.me",
"social.datalabour.com",
"mastodon.partipirate.org",
"oulipo.social",
"yakyudon.net",
"kfem.cat",
"lounge.town",
"eletusk.club",
"lgbt.io",
"lewacki.space",
"oransns.com",
"mastodon.triggerphra.se",
"arvr.social",
"digforfire.org",
"maly.io",
"good.news",
"lou.lt",
"anticapitalist.party",
"mastodon.cc",
"gardenstate.social",
"mastorock.com",
"birds.town",
"kpop.social",
"social.yesterweb.org",
"earthstream.social",
"gensokyo.social",
"anarchism.space",
"acg.mn",
"deadinsi.de",
"baraag.net",
"mastodon.elte.hu",
"mastodon.pirateparty.be",
"social.chinwag.org",
"mstdn.es",
"metalverse.social",
"mastodon-swiss.org",
"mastodon.com.py",
"mstdn.osaka",
"social.slat.org",
"opalstack.social",
"bahn.social",
"mograph.social",
"dmv.community",
"social.bau-ha.us",
"mastodon.free-solutions.org",
"macrofurs.social",
"aleph.land",
"mastodol.jp",
"poweredbygay.social",
"toots.social",
"gametoots.de",
"tenforward.social",
"masto.nyc",
"tyrol.social",
"burma.social",
"toot.kif.rocks",
"donphan.social",
"mast.hpc.social",
"musicians.today",
"drupal.community",
"hometech.social",
"norcal.social",
"social.politicaconciencia.org",
"social.seattle.wa.us",
"is.nota.live",
"genealysis.social",
"wargamers.social",
"guitar.rodeo",
"bookstodon.com",
"mstdn.dk",
"elizur.me",
"irsoluciones.social",
"h-net.social",
"mastoot.fr",
"qaf.men",
"est.social",
"kurry.social",
"mastodon.pnpde.social",
"ani.work",
"nederland.online",
"epicure.social",
"occitania.social",
"rcsocial.net",
"mastodon.cipherbliss.com",
"mastodon.be",
"otoya.space",
"vocalounge.cafe",
"anime.kona.moe",
"toot.thoughtworks.com",
"toot.pizza",
"mastodon.cisti.org",
"lgbt.io",
"mountains.social",
"persiansmastodon.com",
"seocommunity.social",
"cyberfurz.social",
"fedi.at",
"gamepad.club",
"augsburg.social",
"mastodon.education",
"toot.re",
"linux.social",
"neovibe.app",
"musician.social",
"esq.social",
"social.veraciousnetwork.com",
"datasci.social",
"tooters.org",
"ciberlandia.pt",
"cloud-native.social",
"social.silicon.moe",
"cosocial.ca",
"arvr.social",
"hispagatos.space",
"friendsofdesoto.social",
"musicworld.social",
"aut.social",
"masto.yttrx.com",
"yttrx.com",
"catdon.life",
"mastodon.wien",
"colorid.es",
"arsenalfc.social",
"allthingstech.social",
"mastodon.vlaanderen",
"mastodon.com.py",
"tooter.social",
"lounge.town",
"puntarella.party",
"earthstream.social",
"apobangpo.space",
"opencoaster.net",
"frikiverse.zone",
"airwaves.social",
"toot.garden",
"lewacki.space",
"gardenstate.social",
"theatl.social",
"maly.io",
"library.love",
"kfem.cat",
"ruhrpott.social",
"techtoots.com",
"furry.energy",
"mastodon.pirateparty.be",
"metalverse.social",
"indieauthors.social",
"tuiter.rocks",
"mastodon.africa",
"jvm.social",
"poweredbygay.social",
"fikaverse.club",
"gametoots.de",
"mastodon.cr",
"hoosier.social",
"khiar.net",
"seo.chat",
"drumstodon.net",
"e.fo",
"hub.mtf.party",
"toot.turbo.chat",
"raphus.social",
"toots.nu",
"k8s.social",
"mastodon.holeyfox.co",
"social.targaryen.house",
"fribygda.no",
"x0r.be",
"fpl.social",
"toot.pizza",
"mastodon.cipherbliss.com",
"burningboard.net",
"synapse.cafe",
"cultur.social",
"vermont.masto.host",
"mastodon.bot",
"bologna.one",
"pieville.net",
"moe.cat",
"toot.site",
"lilymagic.com",
"social.opendesktop.org",
"bgme.me",
"mastodon.li",
"truthsocial.co.in",
"mastodon.sg",
"tchafia.be",
"rail.chat",
"mastodon.hosnet.fr",
"leipzig.town",
"wayne.social",
"rheinhessen.social",
"rap.social",
"cwb.social",
"mastodon.bachgau.social",
"cville.online",
"bzh.social",
"mastodon.escepticos.es",
"zenzone.social",
"mastodon.ee",
"lsbt.me",
"neurodiversity-in.au",
"fairmove.net",
"stereodon.social",
"mast.moe",
"nojack.easydns.ca",
"mcr.wtf",
"mastodon.frl",
"mikumikudance.cloud",
"social.coletivos.org",
"social.caa-ins.org",
"m.rthome.me",
"mastodon.oeru.org",
"nasface.cz",
"ephemeral.glitch.social",
"www.mstddntfdn.online",
"mastodon.librelabucm.org",
"livester.net",
"fetswing.org",
"mastodon.cosmicanimal.jp",
"summoners-riftodon.jp",
"dev.brighteon.social",
"todon.ploud.fr",
"kinbaku.club",
"animalliberation.social",
"okla.social",
"camp.smolnet.org",
"ailbhean.co-shaoghal.net",
"clj.social",
"tu.social",
"nomanssky.social",
"mastodon.iow.social",
"frontrange.co",
"episcodon.net",
"devianze.city",
"paktodon.asia",
"travelpandas.fr",
"silversword.online",
"nwb.social",
"skastodon.com",
"kcmo.social",
"balkan.fedive.rs",
"openedtech.social",
"mastodon.ph",
"enshittification.social",
"spojnik.works",
"mastodon.conquestuniverse.com",
"nutmeg.social",
"social.sndevs.com",
"social.diva.exchange",
"growers.social",
"pdx.sh",
"nfld.me",
"cartersville.social",
"voi.social",
"mastodon.babb.no",
"kzoo.to",
"mastodon.vanlife.is",
"toot.works",
"sanjuans.life",
"dariox.club",
"xreality.social",
"social.ferrocarril.net",
"pool.social",
"polsci.social",
"mastodon.mg",
"23.illuminati.org",
"apotheke.social",
"jaxbeach.social",
"onmasto.com",
"mastodon.gza.jp",
"mastodon.firefly.land",
"ostatus.ikeji.ma",
"social.outsourcedmath.com",
"tkz.one",
"med-mammoth.com",
"bark.lgbt",
"moth.social",
"icosahedron.kal-tsit.halcy.de",
"id.cc",
"crypt.lol",
"freespeechextremist.com",
"cawfee.club",
"1234.as",
"fedi.absturztau.be",
"fsmi.social",
"go5.dev",
"poa.st",
"patriot.online",
"stereophonic.space",
"kazv.moe",
"seaofog.com",
"libranet.de",
"tea.codes",
"pixelfed.social",
"stop.voring.me",
"shitposter.club",
"squeet.me",
"shared.graphics",
"devs.live",
"pxlmo.com",
"pixel.tchncs.de",
"pythondevs.social",
"love.alicecomplex.com",
"mastodon.london",
"greenish.red",
"pixelfed.sdf.org",
"anar.chi.st",
"gram.social",
"friendica.eskimo.com",
"dudu.best",
"lolison.top",
"fedisnap.com",
"pix.diaspodon.fr",
"shpposter.club",
"pix.toot.wales",
"pleroma.noellabo.jp",
"fgc.network",
"pixey.org",
"fe.disroot.org",
"pixelfed.tokyo",
"mastodon.wien",
"448c.net",
"miraiverse.xyz",
"freeframe.masto.host",
"pixelfed.photos",
"varishangout.net",
"pixelfed.fr",
"friendica.vrije-mens.org",
"mastodon.tech",
"bae.st",
"brighteon.social",
"pixelfed.nz",
"hayu.sh",
"pixelfed.uno",
"pixelfed.au",
"miniwa.moe",
"bassam.social",
"genserver.social",
"spinster.xyz",
"pixelfed.de",
"metapixl.com",
"neenster.org",
"venera.social",
"outerheaven.club",
"gleasonator.com",
"pixelfed.fi",
"blob.cat",
"kids.0px.io",
"metu.life",
"cybre.club",
"snug.moe",
"eter9.com",
"infosec.town",
"lethallava.land",
"dvd.chat",
"poast.org"
"ceilidh.online",
"netsphere.one",
"biplus.social",
"bvb.social",
"ms.maritime.social",
"darticulate.com",
"persia.social",
"streamerchat.social",
"troet.fediverse.at",
"publishing.social",
"finsup.social",
"kjas.no",
"wxw.moe",
"learningdisability.social",
"mastodon.bida.im",
"computerfairi.es",
"tea.codes"
]

Wyświetl plik

@ -959,11 +959,6 @@
"Kabyle",
"Taqbaylit"
],
[
"kmr",
"Kurmanji (Kurdish)",
"Kurmancî"
],
[
"ldn",
"Láadan",

Wyświetl plik

@ -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;
@ -69,7 +86,7 @@
--outline-color: rgba(128, 128, 128, 0.2);
--outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(0, 0, 0, 0.05);
--backdrop-color: rgba(0, 0, 0, 0.1);
--backdrop-darker-color: rgba(0, 0, 0, 0.25);
--backdrop-solid-color: #eee;
--img-bg-color: rgba(128, 128, 128, 0.2);
@ -91,6 +108,8 @@
--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;
}
@media (min-resolution: 2dppx) {
@ -227,7 +246,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);
@ -267,6 +286,14 @@ button[hidden] {
:is(button, .button).plain5:not(:disabled, .disabled):is(:hover, :focus) {
text-decoration: underline;
}
:is(button, .button).plain6 {
background-color: var(--bg-blur-color);
color: var(--link-color);
border: 1px solid var(--link-color);
}
:is(button, .button).plain6:not(:disabled, .disabled):is(:hover, :focus) {
background-color: var(--link-bg-color);
}
:is(button, .button).light {
background-color: var(--bg-faded-color);
color: var(--text-color);
@ -320,6 +347,7 @@ button[hidden] {
}
input[type='text'],
input[type='search'],
textarea,
select {
color: var(--text-color);
@ -329,6 +357,7 @@ select {
border-radius: 4px;
}
input[type='text']:focus,
input[type='search']:focus,
textarea:focus,
select:focus {
border-color: var(--outline-color);
@ -344,7 +373,7 @@ 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%;
}
@ -518,3 +547,9 @@ kbd {
.shazam-container-horizontal[hidden] {
grid-template-columns: 0fr;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

Wyświetl plik

@ -2,6 +2,9 @@ import './index.css';
import './cloak-mode.css';
// Polyfill needed for Firefox < 122
// https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
// import '@formatjs/intl-segmenter/polyfill';
import { render } from 'preact';
import { HashRouter } from 'react-router-dom';

Wyświetl plik

@ -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';
@ -20,6 +21,7 @@ import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import { isMediaFirstInstance } from '../utils/store-utils';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -67,6 +69,8 @@ function AccountStatuses() {
searchOffsetRef.current = 0;
}, allSearchParams);
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
const sameCurrentInstance = useMemo(
() => instance === currentInstance,
[instance, currentInstance],
@ -150,7 +154,7 @@ function AccountStatuses() {
}
}
const results = [];
let results = [];
if (firstLoad) {
const { value } = await masto.v1.accounts
.$select(id)
@ -185,12 +189,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 +230,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,130 +273,159 @@ 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 || {};
const accountInfoMemo = useMemo(() => {
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
return (
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={fetchAccount}
authenticated={authenticated}
standalone
/>
);
}, [id, instance, authenticated, fetchAccount]);
const filterBarRef = useRef();
const TimelineStart = useMemo(() => {
const filtered =
!excludeReplies || excludeBoosts || tagged || media || !!month;
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
return (
<>
{accountInfoMemo}
<div
class="filter-bar"
ref={filterBarRef}
style={{
position: 'relative',
}}
>
{filtered ? (
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={fetchAccount}
authenticated={authenticated}
standalone
/>
{!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
@ -377,40 +434,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>
)}
</>
);
}, [
@ -418,6 +446,7 @@ function AccountStatuses() {
instance,
authenticated,
featuredTags,
fetchAccount,
searchEnabled,
...allSearchParams,
]);
@ -472,7 +501,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={[
@ -517,7 +546,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 && (

Wyświetl plik

@ -13,12 +13,13 @@ 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();
}
}}

1117
src/pages/catchup.css 100644

Plik diff jest za duży Load Diff

1920
src/pages/catchup.jsx 100644

Plik diff jest za duży Load Diff

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,587 @@
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
/>
<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;

Wyświetl plik

@ -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([]);
@ -45,19 +45,31 @@ function FollowedHashtags() {
</header>
<main>
{followedHashtags.length > 0 ? (
<ul class="link-list">
{followedHashtags.map((tag) => (
<li>
<Link
to={
instance ? `/${instance}/t/${tag.name}` : `/t/${tag.name}`
}
>
<Icon icon="hashtag" /> <span>{tag.name}</span>
</Link>
</li>
))}
</ul>
<>
<ul class="link-list">
{followedHashtags.map((tag) => (
<li>
<Link
to={
instance
? `/${instance}/t/${tag.name}`
: `/t/${tag.name}`
}
>
<Icon icon="hashtag" /> <span>{tag.name}</span>
</Link>
</li>
))}
</ul>
{followedHashtags.length > 1 && (
<footer class="ui-state">
<small class="insignificant">
{followedHashtags.length} hashtag
{followedHashtags.length === 1 ? '' : 's'}
</small>
</footer>
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />

Wyświetl plik

@ -6,6 +6,7 @@ import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import states from '../utils/states';
import { getStatus, saveStatus } from '../utils/states';
import supports from '../utils/supports';
import {
assignFollowedTags,
clearFollowedTagsState,
@ -23,11 +24,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 +72,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');
@ -129,6 +141,7 @@ function Following({ title, path, id, ...props }) {
// allowFilters
filterContext="home"
showFollowedTags
showReplyParent
/>
);
}

Wyświetl plik

@ -5,7 +5,7 @@ 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';
@ -18,6 +18,7 @@ import { filteredItems } from '../utils/filters';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import { isMediaFirstInstance } from '../utils/store-utils';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -55,6 +56,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 +76,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 +88,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 +112,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;
@ -155,7 +159,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"
@ -232,23 +236,27 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
<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 />
{!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 />
</>
)}
<FocusableItem className="menu-field" disabled={reachLimit}>
{({ ref }) => (
<form
@ -285,7 +293,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
required
autocorrect="off"
autocapitalize="off"
spellcheck={false}
spellCheck={false}
// no spaces, no hashtags
pattern="[^#][^\s#]+[^#]"
disabled={reachLimit}

Wyświetl plik

@ -84,7 +84,7 @@ function NotificationsLink() {
);
}
const NOTIFICATIONS_LIMIT = 30;
const NOTIFICATIONS_LIMIT = 80;
const NOTIFICATIONS_DISPLAY_LIMIT = 5;
function NotificationsMenu({ anchorRef, state, onClose }) {
const { masto, instance } = api();

Wyświetl plik

@ -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';
@ -12,10 +12,12 @@ 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 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) {
@ -104,11 +112,35 @@ function List(props) {
boostsCarousel={snapStates.settings.boostsCarousel}
// allowFilters
filterContext="home"
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
@ -142,7 +174,6 @@ function List(props) {
/>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
@ -166,7 +197,6 @@ function List(props) {
)}
{showManageMembersModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowManageMembersModal(false);

Wyświetl plik

@ -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');
@ -61,14 +59,15 @@ function Lists() {
</header>
<main>
{lists.length > 0 ? (
<ul class="link-list">
{lists.map((list) => (
<li>
<Link to={`/l/${list.id}`}>
<span>
<Icon icon="list" /> <span>{list.title}</span>
</span>
{/* <button
<>
<ul class="link-list">
{lists.map((list) => (
<li>
<Link to={`/l/${list.id}`}>
<span>
<Icon icon="list" /> <span>{list.title}</span>
</span>
{/* <button
type="button"
class="plain"
onClick={(e) => {
@ -81,10 +80,19 @@ function Lists() {
>
<Icon icon="pencil" />
</button> */}
</Link>
</li>
))}
</ul>
</Link>
</li>
))}
</ul>
{lists.length > 1 && (
<footer class="ui-state">
<small class="insignificant">
{lists.length} list
{lists.length === 1 ? '' : 's'}
</small>
</footer>
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader />
@ -98,7 +106,6 @@ function Lists() {
</div>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);

Wyświetl plik

@ -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';
@ -27,12 +28,14 @@ function Login() {
);
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 +93,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,7 +153,7 @@ function Login() {
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck={false}
spellCheck={false}
placeholder="instance domain"
onInput={(e) => {
setInstanceText(e.target.value);

Wyświetl plik

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

Wyświetl plik

@ -57,13 +57,14 @@
width: fit-content;
margin: -0.25em auto 0;
line-height: 1;
z-index: 1;
position: relative;
background-color: var(--bg-blur-color);
/* background-image: linear-gradient(
to bottom,
var(--bg-color),
var(--bg-blur-color)
); */
backdrop-filter: blur(16px) saturate(3);
padding: 2px 4px;
border-radius: 999px;
overflow: hidden;
@ -142,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);
@ -419,3 +421,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;
}
}
}

Wyświetl plik

@ -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,8 +14,10 @@ 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';
@ -22,15 +25,23 @@ import handleContentLinks from '../utils/handle-content-links';
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 LIMIT = 80;
const emptySearchParams = new URLSearchParams();
const scrollIntoViewOptions = {
block: 'center',
inline: 'center',
behavior: 'smooth',
};
function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications');
const { masto, instance } = api();
@ -61,6 +72,13 @@ function Notifications({ columnMode }) {
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;
@ -71,6 +89,32 @@ function Notifications({ columnMode }) {
});
});
// 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 = groupNotifications(notifications);
if (firstLoad) {
@ -129,6 +173,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 +220,10 @@ function Notifications({ columnMode }) {
setFollowRequests(requests);
})
.catch(() => {});
if (supportsFilteredNotifications) {
loadNotificationsPolicy();
}
}
const { done } = await fetchNotificationsPromise;
@ -161,6 +231,7 @@ function Notifications({ columnMode }) {
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
@ -209,7 +280,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 +290,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 +344,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' : ''}`}>
@ -285,6 +432,11 @@ function Notifications({ columnMode }) {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
onDblClick={(e) => {
if (!e.target.closest('a, button')) {
loadNotifications(true);
}
}}
class={uiState === 'loading' ? 'loading' : ''}
>
<div class="header-grid">
@ -296,7 +448,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' && (
@ -401,6 +563,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
@ -414,7 +646,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>
@ -509,6 +741,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>
);
}
@ -591,4 +926,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);

Wyświetl plik

@ -10,6 +10,7 @@ import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import supports from '../utils/supports';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -30,10 +31,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 +68,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;

Wyświetl plik

@ -44,6 +44,18 @@
}
}
#search-page h2 {
a {
.icon {
vertical-align: middle;
transition: transform 0.2s;
}
&:hover .icon {
transform: translateX(4px);
}
}
}
#search-page ul.accounts-list {
display: flex;
flex-wrap: wrap;

Wyświetl plik

@ -174,9 +174,10 @@ function Search({ columnMode, ...props }) {
}, [q, type, instance]);
useHotkeys(
'/',
['/', 'Slash'],
(e) => {
searchFormRef.current?.focus?.();
searchFormRef.current?.select?.();
},
{
preventDefault: true,
@ -253,7 +254,14 @@ function Search({ columnMode, ...props }) {
{(!type || type === 'accounts') && (
<>
{type !== 'accounts' && (
<h2 class="timeline-header">Accounts</h2>
<h2 class="timeline-header">
Accounts{' '}
<Link
to={`/search?q=${encodeURIComponent(q)}&type=accounts`}
>
<Icon icon="arrow-right" size="l" />
</Link>
</h2>
)}
{accountResults.length > 0 ? (
<>
@ -273,7 +281,9 @@ function Search({ columnMode, ...props }) {
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=accounts`}
to={`/search?q=${encodeURIComponent(
q,
)}&type=accounts`}
>
See more accounts <Icon icon="arrow-right" />
</Link>
@ -295,7 +305,14 @@ function Search({ columnMode, ...props }) {
{(!type || type === 'hashtags') && (
<>
{type !== 'hashtags' && (
<h2 class="timeline-header">Hashtags</h2>
<h2 class="timeline-header">
Hashtags{' '}
<Link
to={`/search?q=${encodeURIComponent(q)}&type=hashtags`}
>
<Icon icon="arrow-right" size="l" />
</Link>
</h2>
)}
{hashtagResults.length > 0 ? (
<>
@ -331,7 +348,9 @@ function Search({ columnMode, ...props }) {
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=hashtags`}
to={`/search?q=${encodeURIComponent(
q,
)}&type=hashtags`}
>
See more hashtags <Icon icon="arrow-right" />
</Link>
@ -353,7 +372,14 @@ function Search({ columnMode, ...props }) {
{(!type || type === 'statuses') && (
<>
{type !== 'statuses' && (
<h2 class="timeline-header">Posts</h2>
<h2 class="timeline-header">
Posts{' '}
<Link
to={`/search?q=${encodeURIComponent(q)}&type=statuses`}
>
<Icon icon="arrow-right" size="l" />
</Link>
</h2>
)}
{statusResults.length > 0 ? (
<>
@ -377,7 +403,9 @@ function Search({ columnMode, ...props }) {
<div class="ui-state">
<Link
class="plain button"
to={`/search?q=${q}&type=statuses`}
to={`/search?q=${encodeURIComponent(
q,
)}&type=statuses`}
>
See more posts <Icon icon="arrow-right" />
</Link>

Wyświetl plik

@ -23,11 +23,12 @@ import states from '../utils/states';
import store from '../utils/store';
const DEFAULT_TEXT_SIZE = 16;
const TEXT_SIZES = [15, 16, 17, 18, 19, 20];
const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20];
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,7 +434,38 @@ function Settings({ onClose }) {
</div>
</div>
</li>
{!!IMG_ALT_API_URL && (
{!!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>
<input
@ -464,6 +496,39 @@ function Settings({ onClose }) {
</div>
</li>
)}
{authenticated && (
<li>
<label>
<input
type="checkbox"
checked={
snapStates.settings.shortcutSettingsCloudImportExport
}
onChange={(e) => {
states.settings.shortcutSettingsCloudImportExport =
e.target.checked;
}}
/>{' '}
"Cloud" import/export for shortcuts settings{' '}
<Icon icon="cloud" class="more-insignificant" />
</label>
<div class="sub-section insignificant">
<small>
Very experimental.
<br />
Stored in your own profiles notes. Profile (private) notes
are mainly used for other profiles, and hidden for own
profile.
</small>
</div>
<div class="sub-section insignificant">
<small>
Note: This feature uses currently-logged-in instance server
API.
</small>
</div>
</li>
)}
<li>
<label>
<input
@ -643,7 +708,7 @@ function PushNotificationsSection({ onClose }) {
const { instance } = api();
const [uiState, setUIState] = useState('default');
const pushFormRef = useRef();
const [allowNofitications, setAllowNotifications] = useState(false);
const [allowNotifications, setAllowNotifications] = useState(false);
const [needRelogin, setNeedRelogin] = useState(false);
const previousPolicyRef = useRef();
useEffect(() => {
@ -657,9 +722,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) => {
@ -688,61 +754,68 @@ function PushNotificationsSection({ onClose }) {
<form
ref={pushFormRef}
onChange={() => {
const values = Object.fromEntries(new FormData(pushFormRef.current));
const allowNofitications = !!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, allowNofitications, params });
console.log('PN Form', {
values,
allowNotifications: allowNotifications,
params,
});
if (allowNofitications && 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>
@ -754,7 +827,7 @@ function PushNotificationsSection({ onClose }) {
type="checkbox"
disabled={isLoading || needRelogin}
name="policy-allow"
checked={allowNofitications}
checked={allowNotifications}
onChange={async (e) => {
const { checked } = e.target;
if (checked) {
@ -778,7 +851,7 @@ function PushNotificationsSection({ onClose }) {
Allow from{' '}
<select
name="policy"
disabled={isLoading || needRelogin || !allowNofitications}
disabled={isLoading || needRelogin || !allowNotifications}
>
{[
{
@ -803,7 +876,7 @@ function PushNotificationsSection({ onClose }) {
style={{
width: '100%',
}}
hidden={!allowNofitications}
hidden={!allowNotifications}
>
<div class="shazam-container-inner">
<div class="sub-section">

Wyświetl plik

@ -1,16 +1,26 @@
.status-deck header {
white-space: nowrap;
}
.status-deck header h1 {
min-width: 0;
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
align-self: stretch;
}
.status-deck header h1 .deck-back {
margin-left: -16px;
.status-deck {
header {
white-space: nowrap;
}
header h1 {
min-width: 0;
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
align-self: stretch;
}
header h1 .deck-back {
margin-left: -16px;
}
.button-refresh .icon {
animation: spin 1s linear;
}
.button-refresh:is(:hover, :focus) .icon {
transition: transform 1s linear;
transform: rotate(360deg);
}
}
.hero-heading {

Wyświetl plik

@ -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 ? (
@ -245,6 +257,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}, [id, uiState !== 'loading']);
const scrollOffsets = useRef();
const lastInitContextTS = useRef();
const initContext = ({ reloadHero } = {}) => {
console.debug('initContext', id);
setUIState('loading');
@ -432,12 +445,31 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}
})();
lastInitContextTS.current = Date.now();
return () => {
clearTimeout(heroTimer);
};
};
useEffect(initContext, [id, masto]);
const [showRefresh, setShowRefresh] = useState(false);
useEffect(() => {
let interval = setInterval(() => {
const now = Date.now();
if (
lastInitContextTS.current &&
now - lastInitContextTS.current >= 60_000
) {
setShowRefresh(true);
}
}, 60_000); // 1 minute
return () => {
clearInterval(interval);
};
}, []);
useLayoutEffect(() => {
if (!statuses.length) return;
console.debug('STATUSES', statuses);
@ -845,6 +877,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
enableTranslate
onMediaClick={handleMediaClick}
onStatusLinkClick={handleStatusLinkClick}
showActionsBar={!!descendant}
/>
)}
{ancestor && repliesCount > 1 && (
@ -885,7 +918,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-loading">
<Loader />
<Loader abrupt={heroStatus.repliesCount >= 3} />
</div>
)}
{uiState === 'error' &&
@ -951,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"
@ -1094,6 +1139,18 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
>
<Icon icon="layout4" size="l" />
</button>
{showRefresh && (
<button
type="button"
class="plain button-refresh"
onClick={() => {
states.reloadStatusPage++;
setShowRefresh(false);
}}
>
<Icon icon="refresh" size="l" />
</button>
)}
<Menu2
align="end"
portal={{
@ -1175,7 +1232,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
{postInstance ? (
<>
{' '}
(<b>{postInstance}</b>)
(<b>{punycode.toUnicode(postInstance)}</b>)
</>
) : (
''
@ -1400,6 +1457,7 @@ function SubComments({
size="s"
enableTranslate
onMediaClick={handleMediaClick}
showActionsBar
/>
{!r.replies?.length && r.repliesCount > 0 && (
<div class="replies-link">

Wyświetl plik

@ -1,8 +1,9 @@
import './trending.css';
import '../components/links-bar.css';
import { MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { useMemo, useRef, useState } from 'preact/hooks';
import punycode from 'punycode/';
import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -18,6 +19,7 @@ import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import supports from '../utils/supports';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -32,6 +34,17 @@ const fetchLinks = pmem(
},
);
function fetchTrends(masto) {
if (supports('@pixelfed/trending')) {
return masto.pixelfed.v2.discover.posts.trending.list({
range: 'daily',
});
}
return masto.v1.trends.statuses.list({
limit: LIMIT,
});
}
function Trending({ columnMode, ...props }) {
const snapStates = useSnapshot(states);
const params = columnMode ? {} : useParams();
@ -47,36 +60,39 @@ function Trending({ columnMode, ...props }) {
const [hashtags, setHashtags] = useState([]);
const [links, setLinks] = useState([]);
const trendIterator = useRef();
async function fetchTrend(firstLoad) {
if (firstLoad || !trendIterator.current) {
trendIterator.current = masto.v1.trends.statuses.list({
limit: LIMIT,
});
trendIterator.current = fetchTrends(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 iterator.next();
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);
// 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();
@ -161,9 +177,9 @@ function Trending({ columnMode, ...props }) {
url,
width,
} = link;
const domain = new URL(url).hostname
.replace(/^www\./, '')
.replace(/\/$/, '');
const domain = punycode.toUnicode(
new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
);
let accentColor;
if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash);
@ -217,13 +233,23 @@ function Trending({ columnMode, ...props }) {
)}
</div>
{!!title && (
<h1 class="title" lang={language} dir="auto">
<h1
class="title"
lang={language}
dir="auto"
title={title}
>
{title}
</h1>
)}
</header>
{!!description && (
<p class="description" lang={language} dir="auto">
<p
class="description"
lang={language}
dir="auto"
title={description}
>
{description}
</p>
)}

Wyświetl plik

@ -7,6 +7,7 @@ import {
getAccountByInstance,
getCurrentAccount,
saveAccount,
setCurrentAccountID,
} from './store-utils';
// Default *fallback* instance
@ -118,7 +119,7 @@ export async function initAccount(client, instance, accessToken, vapidKey) {
const mastoAccount = await masto.v1.accounts.verifyCredentials();
console.log('CURRENTACCOUNT SET', mastoAccount.id);
store.session.set('currentAccount', mastoAccount.id);
setCurrentAccountID(mastoAccount.id);
saveAccount({
info: mastoAccount,

Wyświetl plik

@ -9,20 +9,20 @@ import {
set,
} from 'idb-keyval';
const draftsStore = createStore('drafts-db', 'drafts-store');
// Add additonal `draftsStore` parameter to all methods
const drafts = {
set: (key, val) => set(key, val, draftsStore),
get: (key) => get(key, draftsStore),
getMany: (keys) => getMany(keys, draftsStore),
del: (key) => del(key, draftsStore),
delMany: (keys) => delMany(keys, draftsStore),
clear: () => clear(draftsStore),
keys: () => keys(draftsStore),
};
function initDB(dbName, storeName) {
const store = createStore(dbName, storeName);
return {
set: (key, val) => set(key, val, store),
get: (key) => get(key, store),
getMany: (keys) => getMany(keys, store),
del: (key) => del(key, store),
delMany: (keys) => delMany(keys, store),
clear: () => clear(store),
keys: () => keys(store),
};
}
export default {
drafts,
drafts: initDB('drafts-db', 'drafts-store'),
catchup: initDB('catchup-db', 'catchup-store'),
};

Wyświetl plik

@ -37,6 +37,7 @@ function _enhanceContent(content, opts = {}) {
links.forEach((link) => {
if (/^https?:\/\//i.test(link.textContent.trim())) {
link.classList.add('has-url-text');
shortenLink(link);
}
});
}
@ -241,6 +242,17 @@ function _enhanceContent(content, opts = {}) {
}
}
// ADD ASPECT RATIO TO ALL IMAGES
if (enhancedContent.includes('<img')) {
dom.querySelectorAll('img').forEach((img) => {
const width = img.getAttribute('width') || img.naturalWidth;
const height = img.getAttribute('height') || img.naturalHeight;
if (width && height) {
img.style.setProperty('--original-aspect-ratio', `${width}/${height}`);
}
});
}
if (postEnhanceDOM) {
queueMicrotask(() => postEnhanceDOM(dom));
// postEnhanceDOM(dom); // mutate dom
@ -287,6 +299,30 @@ const defaultRejectFilter = [
const defaultRejectFilterMap = Object.fromEntries(
defaultRejectFilter.map((nodeName) => [nodeName, true]),
);
const URL_PREFIX_REGEX = /^(https?:\/\/(www\.)?|xmpp:)/;
const URL_DISPLAY_LENGTH = 30;
// Similar to https://github.com/mastodon/mastodon/blob/1666b1955992e16f4605b414c6563ca25b3a3f18/app/lib/text_formatter.rb#L54-L69
function shortenLink(link) {
if (!link || link.querySelector?.('*')) {
return;
}
try {
const url = link.innerText.trim();
const prefix = (url.match(URL_PREFIX_REGEX) || [])[0] || '';
if (!prefix) return;
const displayURL = url.slice(
prefix.length,
prefix.length + URL_DISPLAY_LENGTH,
);
const suffix = url.slice(prefix.length + URL_DISPLAY_LENGTH);
const cutoff = url.slice(prefix.length).length > URL_DISPLAY_LENGTH;
link.innerHTML = `<span class="invisible">${prefix}</span><span class=${
cutoff ? 'ellipsis' : ''
}>${displayURL}</span><span class="invisible">${suffix}</span>`;
} catch (e) {}
}
function extractTextNodes(dom, opts = {}) {
const textNodes = [];
const rejectFilterMap = Object.assign(

Wyświetl plik

@ -1,5 +1,5 @@
import mem from './mem';
import store from './store';
import { getCurrentAccountID } from './store-utils';
function _isFiltered(filtered, filterContext) {
if (!filtered?.length) return false;
@ -43,7 +43,7 @@ export function filteredItem(item, filterContext, currentAccountID) {
export function filteredItems(items, filterContext) {
if (!items?.length) return [];
if (!filterContext) return items;
const currentAccountID = store.session.get('currentAccount');
const currentAccountID = getCurrentAccountID();
return items.filter((item) =>
filteredItem(item, filterContext, currentAccountID),
);

Wyświetl plik

@ -0,0 +1,14 @@
export default function formatDuration(time) {
if (!time) return;
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
let seconds = Math.round(time % 60);
if (hours === 0) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} else {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}`;
}
}

Wyświetl plik

@ -6,6 +6,7 @@ const statusPostRegexes = [
/\/notes\/([^\/]+)/i, // Misskey, Firefish
/^\/(?:notice|objects)\/([a-z0-9-]+)/i, // Pleroma
/\/@[^@\/]+@?[^\/]+?\/([^\/]+)/i, // Mastodon
/^\/p\/[^\/]+\/([^\/]+)/i, // Pixelfed
];
export function getInstanceStatusObject(url) {

Wyświetl plik

@ -1,14 +1,29 @@
import mem from './mem';
const div = document.createElement('div');
function getHTMLText(html) {
function getHTMLText(html, opts) {
if (!html) return '';
const { preProcess } = opts || {};
div.innerHTML = html
.replace(/<\/p>/g, '</p>\n\n')
.replace(/<\/li>/g, '</li>\n');
div.querySelectorAll('br').forEach((br) => {
br.replaceWith('\n');
});
preProcess?.(div);
// MASTODON-SPECIFIC classes
// Remove .invisible
div.querySelectorAll('.invisible').forEach((el) => {
el.remove();
});
// Add at end of .ellipsis
div.querySelectorAll('.ellipsis').forEach((el) => {
el.append('...');
});
return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim();
}

Wyświetl plik

@ -9,7 +9,7 @@ const notificationTypeKeys = {
poll: ['status'],
update: ['status'],
};
function fixNotifications(notifications) {
export function fixNotifications(notifications) {
return notifications.filter((notification) => {
const { type, id, createdAt } = notification;
if (!type) {
@ -63,11 +63,11 @@ function groupNotifications(notifications) {
mappedNotification.id += `-${id}`;
}
} else {
account._types = [type];
if (account) account._types = [type];
let n = (notificationsMap[key] = {
...notification,
type: virtualType,
_accounts: [account],
_accounts: account ? [account] : [],
});
cleanNotifications[j++] = n;
}

Wyświetl plik

@ -16,7 +16,9 @@ function handleContentLinks(opts) {
const textBeforeLinkIsAt = prevText?.endsWith('@');
const textStartsWithAt = target.innerText.startsWith('@');
if (
(target.classList.contains('u-url') && textStartsWithAt) ||
((target.classList.contains('u-url') ||
target.classList.contains('mention')) &&
textStartsWithAt) ||
(textBeforeLinkIsAt && !textStartsWithAt)
) {
const targetText = (
@ -24,12 +26,14 @@ function handleContentLinks(opts) {
).innerText.trim();
const username = targetText.replace(/^@/, '');
const url = target.getAttribute('href');
const mention = mentions.find(
(mention) =>
mention.username === username ||
mention.acct === username ||
mention.url === url,
);
// Only fallback to acct/username check if url doesn't match
const mention =
mentions.find((mention) => mention.url === url) ||
mentions.find(
(mention) =>
mention.acct === username || mention.username === username,
);
console.warn('MENTION', mention, url);
if (mention) {
e.preventDefault();
e.stopPropagation();

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