Compare commits

..

260 Commits

Author SHA1 Message Date
Shadowfacts 01cf597b5d Account for bidi text in combined display/username label 2024-10-22 17:51:58 -04:00
Shadowfacts 12bab71b17 Remove UITabBarController workaround on iOS 18.1 2024-10-14 18:24:47 -04:00
Shadowfacts f4b51c06c1 Raise min deployment target to iOS 16 2024-09-12 10:30:58 -04:00
Shadowfacts c99c397cf6 Bump build number and update changelog 2024-09-11 18:26:20 -04:00
Shadowfacts 814f64b3e2 Simplify add saved hashtag toolbar buttons
Closes #522
2024-09-10 10:20:27 -04:00
Shadowfacts 3a3af77907 Fix swipe action completion handler not being called 2024-09-10 10:18:08 -04:00
Shadowfacts 93e72e1cb6 Fix add saved hashtag search results selection not being cleared 2024-09-09 23:53:37 -04:00
Shadowfacts 522e7830e5 Fix scroll-to-top not working in in-app Safari
Closes #538
2024-09-09 19:42:55 -04:00
Shadowfacts 263210ac3c Fix gallery controls insets on iPhone 16
And change the default to the dynamic island metrics, so I hopefully
don't have to touch this every year
2024-09-09 19:39:30 -04:00
Shadowfacts 506d2ad8a9 Actually fix multi-column nav scrolling animations this time (hopefully)
Closes #539
2024-09-09 19:35:15 -04:00
Shadowfacts f9c0506590 Add tab-switching shortcuts to new tab bar
Closes #541
2024-09-09 19:18:12 -04:00
Shadowfacts 3f4917931b Poll own_votes is a nullable array of nullable ints, at least on pleroma
I do not understand why

Closes #540
2024-09-09 19:13:57 -04:00
Shadowfacts b7166771cf Include SVGs in repo 2024-08-31 11:42:48 -04:00
Shadowfacts 40230c5478 Add dark mode app icons, optimize pngs 2024-08-31 11:37:39 -04:00
Shadowfacts 68bd9e0bed Tweak mute button symbol animation 2024-08-31 11:20:09 -04:00
Shadowfacts 3e28c012d7 Shhh 2024-08-31 11:10:59 -04:00
Shadowfacts 57c023c973 Fix profile tab switching animation ending in bad state
Caused by fda0c18794, old/new.view is no
longer the same as .collectionView, so the transform wasn't being
properly reset.

Closes #536
2024-08-31 11:09:17 -04:00
Shadowfacts cc696e58fc Change how profile header collection view cell is sized
Fixes crash due to collection view layout loop in some circumstances

Closes #537
2024-08-31 10:49:21 -04:00
Shadowfacts 59af29ff64 Fix incorrect background color on feature flag prefs section 2024-08-27 20:42:51 -04:00
Shadowfacts 59fb69525b Custom emoji in push notifications, behind a feature flag 2024-08-27 20:41:39 -04:00
Shadowfacts 1bd4d144a3 Fix crash on launch if there are somehow duplicate saved hashtags 2024-08-27 12:42:41 -04:00
Shadowfacts b54d34ebfc Fix video controls overlay being positioned incorrectly on macOS with Reduce Motion enabled
Closes #535
2024-08-26 19:16:35 -04:00
Shadowfacts d1ffab3e42 Only hide gallery controls automatically while playing 2024-08-26 19:08:44 -04:00
Shadowfacts d873b157ee Fix video gallery controls not auto hiding
#535
2024-08-26 10:25:28 -04:00
Shadowfacts d7be2048af Whoops
Closes #533
Closes #534
2024-08-23 01:19:29 -04:00
Shadowfacts 3d1f506684 Actually show the error message when video loading fails
See #531
2024-08-22 14:54:16 -04:00
Shadowfacts cd8f0e7926 Use navigation sequencing for user activity handling 2024-08-22 14:49:27 -04:00
Shadowfacts 960ba84683 New way of sequencing navigation operations
Better fix for #484
2024-08-22 14:34:05 -04:00
Shadowfacts 2eead1f9de Revert "Fix crash when opening push notification while VC modally presented"
This reverts commit 0f2a85b108.

This fixes state restoration happening asynchronously and causing the
new tab bar animation to run.
2024-08-22 14:17:04 -04:00
Shadowfacts b663335c6d Use the image description from imported image when possible
Closes #523
2024-08-22 13:54:03 -04:00
Shadowfacts 9ce6bd566f Show errors when video loading fails
Closes #532
2024-08-22 13:33:02 -04:00
Shadowfacts 9547bd2913 Fix incorrect split nav layout when closing split with new sidebar 2024-08-22 12:08:43 -04:00
Shadowfacts 9b2e6140a3 Fix reselecting current sidebar item not popping to root on Catalyst and new sidebar
Closes #525
2024-08-22 11:39:39 -04:00
Shadowfacts 6de255681c Fix assorted warnings when building with Xcode 16 2024-08-22 11:08:27 -04:00
Shadowfacts 805e5eddd0 Bump build number and update changelog 2024-08-21 19:30:54 -04:00
Shadowfacts 4945a234e7 Fix new tab bar VC getting stuck in bad state after presenting Compose 2024-08-21 19:28:12 -04:00
Shadowfacts 230696f456 Bump build number and update changelog 2024-08-21 18:52:36 -04:00
Shadowfacts c113903980 Fix SplitNavigationController layout with new sidebar 2024-08-21 18:37:20 -04:00
Shadowfacts 0e95cd0adf Update AdaptableNavigationController when interface preference changes 2024-08-21 18:34:49 -04:00
Shadowfacts 494708a362 Fix compiling on visionOS 2024-08-21 18:27:30 -04:00
Shadowfacts 3a21983b98 Merge branch 'tabbarnav' into develop 2024-08-21 17:53:08 -04:00
Shadowfacts 1817247077 Add saved instances to new sidebar 2024-08-21 17:10:01 -04:00
Shadowfacts 0d9eed73dd Add saved/followed hashtags to new sidebar 2024-08-21 16:58:16 -04:00
Shadowfacts 59d43fd3f6 Open in New Window context menu actions for new sidebar 2024-08-21 16:50:30 -04:00
Shadowfacts d321c31776 Implement more protocols for AdaptableNavigationController 2024-08-21 16:36:13 -04:00
Shadowfacts ce10c7d6e2 Implement adding list using new sidebar 2024-08-21 16:19:51 -04:00
Shadowfacts 37b9673b12 Fix list timeline no content view being added repetedly on refresh 2024-08-21 16:17:57 -04:00
Shadowfacts 7c7af945e4 Show avatar in tab/side bar when using new API 2024-08-21 16:12:05 -04:00
Shadowfacts cb32c66a59 Support fast account switching with new sidebar 2024-08-21 14:48:47 -04:00
Shadowfacts 4249ab30ca Fix crash when hashtag search results include duplicate 2024-08-21 14:10:59 -04:00
Shadowfacts 67e9c1245e Size class switching fixes for new tab/side bar 2024-08-21 12:17:26 -04:00
Shadowfacts 3d9a1086b6 Remove dead code 2024-08-20 12:31:29 -04:00
Shadowfacts fda0c18794 Fix insets with new sidebar 2024-08-20 12:31:06 -04:00
Shadowfacts dffa5d8f75 Lists in new sidebar 2024-08-20 11:55:19 -04:00
Shadowfacts 9891b601a8 Initial tab bar/sidebar implementation 2024-08-19 19:10:31 -04:00
Shadowfacts a8f6aa6ed7 Use new UITabBarController API on iOS 18 2024-08-19 13:29:48 -04:00
Shadowfacts 348dcc558c Fix profile page switching on iOS 18 2024-08-19 11:34:17 -04:00
Shadowfacts 703f6f695b Update Sentry and swift-url 2024-08-19 11:33:07 -04:00
Shadowfacts fdbfe49a7c Improve tab switching animation in non-pure-black dark mode on iOS 18 2024-08-19 11:32:29 -04:00
Shadowfacts 3f0dd599b3 Fix compiling with Xcode 16 2024-08-19 11:31:10 -04:00
Shadowfacts 07b6bf33cb Bump build number and update changelog 2024-08-09 18:46:38 -07:00
Shadowfacts d0758dc73c Add more info for subscription to Tip Jar 2024-08-09 18:36:42 -07:00
Shadowfacts b85c0eb95d Bump build number and update changelog 2024-08-08 21:06:00 -07:00
Shadowfacts eea0ef258c Add pointless ToS nag before logging in
Thanks, App Review
2024-08-08 20:39:55 -07:00
Shadowfacts 18f6445a7c Bump build number and update changelog 2024-07-30 22:28:44 -07:00
Shadowfacts c5f42719a0 Fix Cmd+3 not properly selecting Explore tab
Having MainSidebarViewController.Item.explore and .tab(.explore) was a
mistake and made it easy to accidentally use the wrong one for the key
command, so use .tab(.explore) for everything.

Closes #519
2024-07-30 22:03:36 -07:00
Shadowfacts eb89aec00f Bump build number and update changelog 2024-07-24 21:09:42 -07:00
Shadowfacts 61576bce58 Fix Drafts button never turning into Post on Mac Catalyst
Closes #504
2024-07-24 20:57:23 -07:00
Shadowfacts f7d4737782 Add more details to notification loading crash 2024-07-24 20:48:01 -07:00
Shadowfacts 3dd0f3a154 Report DraftsPersistentContainer initializer errors to Sentry 2024-07-24 20:42:35 -07:00
Shadowfacts 145ffbfcf0 Fix crash when selection changes to nil in custom alert
Closes #517
2024-07-24 20:33:17 -07:00
Shadowfacts bcf2a2f026 Improve compose reply view avatar scrolling animation 2024-07-24 20:26:33 -07:00
Shadowfacts 1358152dec Fix discrepancy between SearchResultsViewController.Item == and hash 2024-07-22 22:19:31 -07:00
Shadowfacts 2e2279ba8c Bump build number and update changelog 2024-07-22 21:56:44 -07:00
Shadowfacts 60dadf599c Fix status meta indicators overlapping thread links
This isn't great, but there's nowhere else for either to go and the
difference between the large/default scales wasn't doing much before.

Closes #449
2024-07-22 21:48:43 -07:00
Shadowfacts 90537f9d12 Fix not being able to resolve remote Mastodon posts
Closes #515
2024-07-22 21:40:19 -07:00
Shadowfacts 8b0c2f80b6 Fix preserving conversation expand all not working for ancestors
Closes #516
2024-07-22 21:28:42 -07:00
Shadowfacts 42423f36db Fix Dynamic Type not applying to status content 2024-07-21 19:46:17 -07:00
Shadowfacts 176eb7c011 Undo overzealous Xcode rename 2024-07-21 18:41:03 -07:00
Shadowfacts da9ca78a8b Update card view less often
Speculative fix for #314
2024-07-21 18:40:58 -07:00
Shadowfacts b470ee6401 Fix status/mention push notifications not showing CW and fix sensitive attachments being included in push notifications
Closes #512
2024-07-21 18:13:48 -07:00
Shadowfacts fccd4e427c Fix profile moved header not being VO accessible
Closes #479
2024-07-21 14:03:18 -07:00
Shadowfacts f25031afd4 Fix profile moved view appearing behind avatar/header images 2024-07-21 13:46:07 -07:00
Shadowfacts ca65f84137 Lift pinned timelines modifiers up to CustomizeTimelinesList
.sheet on a Section with a header does not work, and produces warnings
about trying to present on a VC that already has a presentation. It
seems like the .sheet modifier is being applied to both the Section
content and header?

Fixes #514
2024-07-21 12:02:14 -07:00
Shadowfacts d4057adf4d Avoid updating AccountDisplayNameLabel when emojis pref hasn't changed
Oops
2024-07-21 10:36:24 -07:00
Shadowfacts 007937d2d7 Consolidate display/username labels on timeline statuses
Closes #513
2024-07-20 23:35:31 -07:00
Shadowfacts 5f040ed390 Workaround text view not being baseline-aligned with label
Closes #509
2024-07-20 11:08:59 -07:00
Shadowfacts 870d0c8404 Replay video from start when play is pressed at end
Closes #510
2024-07-20 10:33:08 -07:00
Shadowfacts 47b9ac890a Fix gallery controls visibility not transferring between pages
Closes #511
2024-07-20 10:27:49 -07:00
Shadowfacts 50b84350d9 Don't reload relationship every time profile page switched 2024-07-11 22:32:02 -07:00
Shadowfacts cdc64f1b2c Bump build number and update changelog 2024-07-10 20:49:50 -07:00
Shadowfacts 2913098e74 Fix badges not appearing on gifv attachments
Closes #507
2024-07-10 20:25:09 -07:00
Shadowfacts ce99352e90 Don't let let audio session tokens be double consumed 2024-07-09 23:27:18 -07:00
Shadowfacts 8322d3a36c Fix gifv playback not continuing when returning from background
Closes #506
2024-07-08 22:05:27 -07:00
Shadowfacts a818457f8c Fix gifv playing pausing audio from other apps
Closes #505
2024-07-08 22:05:27 -07:00
Shadowfacts 1f6644b703 Bump HTMLStreamer
Fixes crash when whitespace occurs at the end of <pre> content
2024-07-08 21:14:29 -07:00
Shadowfacts 412c5ee91d Fix multi column navigation not animating when scrolling back while replacing multiple columns 2024-07-07 10:01:33 -07:00
Shadowfacts dcc5f7f716 visionOS: Workaround for gallery not working at all 2024-07-06 17:36:45 -07:00
Shadowfacts 9fefc9e8f8 Precise video scrubbing for pointer/pencil 2024-07-06 17:26:33 -07:00
Shadowfacts d1af911241 Custom alert: show menu when long press moves onto menu button 2024-07-06 17:19:30 -07:00
Shadowfacts 5abd265195 Support haptic feedback on new Magic Keyboard 2024-07-06 17:10:29 -07:00
Shadowfacts 3cb0f46533 Add hover effects to poll view
Closes #503
2024-07-06 14:05:16 -07:00
Shadowfacts c367a2e9f1 Fix selecting poll option playing too much haptic feedback 2024-07-06 10:19:09 -07:00
Shadowfacts 3eceffbb6b Add visionOS app icon 2024-06-16 18:51:17 -07:00
Shadowfacts 7c3a00a40d Fix compiling for visionOS 2024-06-16 17:49:25 -07:00
Shadowfacts 45a90fb4a2 Fix compiling for Catalyst 2024-06-16 17:40:23 -07:00
Shadowfacts 8557e110a8 Bump build number and update changelog 2024-06-08 13:55:32 -07:00
Shadowfacts c2232a5e14 Don't fail decoding when one status fails to decode
Also remove old workaround for bad dates from #477

Closes #478
2024-06-08 13:29:56 -07:00
Shadowfacts e6d9a33dbf Actually don't purge old persistent history 2024-06-08 13:18:23 -07:00
Shadowfacts d8fccc8f1b Purge old persistent history after processing
Closes #480
2024-06-08 12:23:12 -07:00
Shadowfacts 6528070f1c Save persistent history tokens across launches
See #480
2024-06-08 12:18:22 -07:00
Shadowfacts 09c6a87e19 Fix switching sidebar sections with keyboard shortcuts not saving old section's navigation stack 2024-06-08 11:30:16 -07:00
Shadowfacts cd0d8fffcb Fix conversation thread links appearing above avatar when lifted by pointer 2024-06-08 11:28:19 -07:00
Shadowfacts 1b6f0c07fd Add pointer effect to search token suggestions 2024-06-08 11:26:10 -07:00
Shadowfacts 2f31b50a5b Fix search results always pushing new column in multi-column nav
Closes #498
2024-06-08 11:21:05 -07:00
Shadowfacts cee4e15b06 Fix not being able to select text by double clicking with cursor on iPad
Also fix not being able to single-tap data detector value to see menu

Closes #499
2024-06-08 11:01:07 -07:00
Shadowfacts 888f44366c Fix multi-column nav not animating scroll position when replacing subsequent columns
Closes #500
2024-06-08 10:32:32 -07:00
Shadowfacts c88076eec0 Use text view for profile field value view
Fixes #501
2024-06-08 10:23:24 -07:00
Shadowfacts afe47437e4 Disallow blocking your own domain 2024-06-02 11:41:50 -07:00
Shadowfacts 4dc484c3c2 Fix follow button never activating on Pixelfed
Caused by not being able to decode Relationship due to missing fields.
Also disable actions that are unsupported on Pixelfed.

Closes #481
2024-06-02 11:40:42 -07:00
Shadowfacts 0f2a85b108 Fix crash when opening push notification while VC modally presented
The dismissal of the modally presented VC turns the route change into an
asynchronous operation, even when not animated.

Closes #484
2024-06-02 11:25:49 -07:00
Shadowfacts 5e55ce75c2 Fix previous sidebar selection losing navigation stack in some circumstances 2024-06-02 10:33:25 -07:00
Shadowfacts eec2adbfd9 Set target content identifiers on scenes/activities 2024-06-02 10:10:16 -07:00
Shadowfacts a848f6e425 Fix error on Pixelfed/Firefish due to missing followers/following counts
Closes #483
2024-06-02 09:44:20 -07:00
Shadowfacts 44896d305e Add pointer interaction to profile followers/following buttons
Closes #497
2024-06-02 09:42:54 -07:00
Shadowfacts 6c70ed4b4e Fix crash in MultiColumnNavController due to closing already-removed VC
Not sure how this is possible, but there was a report of it

Closes #485
2024-06-02 09:41:22 -07:00
Shadowfacts e3c480131a Fix gallery dismiss transition from sheet-presented VC
Closes #490
2024-06-01 11:22:19 -07:00
Shadowfacts 575166f5b4 Fix Cmd+1/etc. resetting navigation stacks
Closes #491
2024-06-01 10:56:55 -07:00
Shadowfacts c60aa3e3f3 Fix close buttons unnecessarily being added to navigation column 2024-06-01 10:56:31 -07:00
Shadowfacts 75f0d12c82 Fix incorrect pointer actions on conversation main status
Closes #493
2024-06-01 10:47:56 -07:00
Shadowfacts 5cf2bc4fbf Fix profile header images being blurry
Due to the old method using ImageCache.avatars for the headers 🤦

Closes #494
2024-06-01 10:44:49 -07:00
Shadowfacts 908b499f8f Fix Remove Suggestion action missing from Suggested Accounts screen
Closes #495
2024-06-01 10:40:30 -07:00
Shadowfacts 67c7905acf Fix missing VC callbacks in removeViewAndController 2024-06-01 10:29:33 -07:00
Shadowfacts eacafe87b3 Fix logout from current resulting in black screen after switching to reused VC
Closes #489
2024-06-01 10:28:46 -07:00
Shadowfacts 2a53b24487 Merge branch 'public-beta' into develop 2024-05-29 22:42:43 -07:00
Shadowfacts 9df3c33c6c Bump build number and update changelog 2024-05-29 22:37:53 -07:00
Shadowfacts d4e82d6e7a Fix AVPlayer periodic time observers not being removed 2024-05-29 22:35:45 -07:00
Shadowfacts 06ba758309 Merge branch 'public-beta' into develop 2024-05-29 22:30:48 -07:00
Shadowfacts 2c56902389 Remove old account UI state when logging out 2024-05-29 22:23:09 -07:00
Shadowfacts cb3fd43dbd Fix video thubmnail being flipped in Compose
Closes #487
2024-05-29 22:03:53 -07:00
Shadowfacts 3d15759fb9 Don't constantly commit CA transactions when scrubbing video
Closes #488
2024-05-29 21:56:18 -07:00
Shadowfacts 5620b6ab78 Merge branch 'public-beta' into develop 2024-05-27 22:29:23 -07:00
Shadowfacts 09999175f7 Fix editing attachment descriptions not working on Pleroma 2024-05-27 22:29:11 -07:00
Shadowfacts f2a9f890ff Use development URLSession in more places 2024-05-27 22:14:28 -07:00
Shadowfacts 093994b474 More push subscription logging 2024-05-27 13:33:00 -07:00
Shadowfacts 3d0de5af04 Persist more state when switching accounts
Closes #486
2024-05-24 14:03:51 -04:00
Shadowfacts 966a906436 Fix AVPlayer periodic time observers not being removed 2024-05-23 14:29:56 -04:00
Shadowfacts 844d4056e3 Bump version and update changelog 2024-05-23 14:25:39 -04:00
Shadowfacts 00ef131bb6 Update HTMLStreamer 2024-05-23 14:12:35 -04:00
Shadowfacts d6be6f14dc Hide subscription section from tip jar when there are no products 2024-05-23 14:11:54 -04:00
Shadowfacts 2ccf028bc2 Bump build number and update changelog 2024-05-20 14:28:25 -04:00
Shadowfacts 3eeffada1f Add tip jar link to push notifications settings 2024-05-20 12:49:26 -04:00
Shadowfacts 0499255be7 Add tip jar subscription 2024-05-20 12:49:20 -04:00
Shadowfacts f909c1da10 Fix selecting follow request push notification
Closes #474
2024-05-19 15:14:03 -04:00
Shadowfacts 81543965ae Fix notification extension not building on visionOS 2024-05-19 15:00:47 -04:00
Shadowfacts 96d42756d5 Fix caption not displaying in gallery while image loading
Closes #476
2024-05-19 15:00:25 -04:00
Shadowfacts f6e57d664f Handle invalid date in Status created_at
Closes #477
2024-05-19 14:48:57 -04:00
Shadowfacts c33be1cbf3 Bump build number 2024-05-17 11:26:57 -04:00
Shadowfacts 6d99156bd9 Include badly formatted date in error message 2024-05-10 16:33:03 -04:00
Shadowfacts ca764811ed Bump build number and update changelog 2024-04-23 13:19:52 -04:00
Shadowfacts a589bb2863 Support emoji reaction push notifications on pleroma/akkoma 2024-04-18 13:17:55 -04:00
Shadowfacts 6f35fd2676 Show pleroma/akkoma emoji notifications
Closes #159
2024-04-18 12:59:44 -04:00
Shadowfacts e83cef1c8c Fix overzealously attempting to migrate local data to cloud store
Fix error when actually migrating due to not opening the store with
NSPersistentHistoryTrackingKey set to true.
2024-04-18 11:45:32 -04:00
Shadowfacts b89df3f27b Add instance announcements
Closes #356
2024-04-18 00:00:00 -04:00
Shadowfacts 4ecc16a93b Move FuzzyMatcher to TuskerComponents 2024-04-17 22:34:31 -04:00
Shadowfacts 8960873ff3 Remove redundant toastableViewController property 2024-04-17 22:34:31 -04:00
Shadowfacts 043a708515 Add more logging to onboarding VC 2024-04-17 17:04:32 -04:00
Shadowfacts c6b230414e Fix error decoding InstanceV2 response on certain instances 2024-04-17 10:18:01 -04:00
Shadowfacts f5e9f66f76 Fix app background colors not updating when preference changed
This only fully fixes it on iOS 17, but it seems to be the best we can do
2024-04-16 12:03:52 -04:00
Shadowfacts ee5f9a62ff Fix push subscription settings GroupBox background in dark mode
Closes #470
2024-04-16 11:37:36 -04:00
Shadowfacts a92cf8c812 Fix potential crash when hit testing StatusCollapseButton 2024-04-15 22:50:31 -04:00
Shadowfacts 756874949a Actually add notification extension privacy manifest to target 2024-04-15 22:42:18 -04:00
Shadowfacts 798e0c0cf1 Bump build number and update changelog 2024-04-15 22:40:54 -04:00
Shadowfacts 3f370945e6 Fix linker errors when building in release mode 2024-04-15 22:30:20 -04:00
Shadowfacts a759731eba Fix push notifications not working when account ID contains slashes 2024-04-15 22:19:24 -04:00
Shadowfacts 405d5def7c Disable non-stack navigation on Max iPhones 2024-04-15 11:33:52 -04:00
Shadowfacts 1f9806d02f Fix preferences post preview background on macOS 2024-04-15 11:04:33 -04:00
Shadowfacts c43c951b92 Enable iPad multi-column navigation by default 2024-04-15 11:00:36 -04:00
Shadowfacts 00c44c612f Fix feature flag preference decoding with old flags 2024-04-15 10:55:43 -04:00
Shadowfacts e5c4fceacd Add CustomCodablePreferenceKey 2024-04-15 10:50:08 -04:00
Shadowfacts 70227a7fa1 Add MigratablePreferenceKey protocol 2024-04-15 10:37:02 -04:00
Shadowfacts cb5488dcaa Reorganize preference keys to match Preferences 2024-04-15 09:50:49 -04:00
Shadowfacts 910e18fb5e Fix compiling for visionOS 2024-04-15 09:49:42 -04:00
Shadowfacts 66af946766 Use uniform deployment targets from project settings 2024-04-15 09:41:53 -04:00
Shadowfacts 6784ed7fdf Remove in-app Safari settings on macOS
Closes #469
2024-04-15 09:34:44 -04:00
Shadowfacts 66f0ba6891 Add icons for Preferences sections 2024-04-15 00:13:04 -04:00
Shadowfacts ee7bf5138c Tweak iCloud status appearance in advanced prefs 2024-04-15 00:13:04 -04:00
Shadowfacts c32181818a Use image for code formatting option 2024-04-15 00:13:04 -04:00
Shadowfacts 4665df228d More preferences reorganizing 2024-04-15 00:13:04 -04:00
Shadowfacts c7a56a9f61 Reorganize appearance prefs, add mock status preview 2024-04-14 14:11:43 -04:00
Shadowfacts 39251b9aa2 Fix TuskerTests not compiling 2024-04-14 13:37:10 -04:00
Shadowfacts db534e5993 Fix About screen link labels not being aligned 2024-04-13 23:19:28 -04:00
Shadowfacts e94bee4fc8 Fix a handful of strict concurrency warnings 2024-04-13 23:06:30 -04:00
Shadowfacts 216e58e5ec Merge branch 'prefs-refactor' into develop 2024-04-13 22:39:49 -04:00
Shadowfacts a4d13ad03b Only migrate changed preferences 2024-04-13 22:36:42 -04:00
Shadowfacts 05cfecb797 Fix push notifications on Pleroma/Akkoma and older Mastodon versions 2024-04-13 18:59:42 -04:00
Shadowfacts 132fcfa099 Refactor preferences 2024-04-13 18:44:43 -04:00
Shadowfacts 475b9911b1 Add privacy manifest to notification extension 2024-04-13 11:11:26 -04:00
Shadowfacts 7825ccbb3d Bump version and update changelog 2024-04-13 11:09:26 -04:00
Shadowfacts f87da10a29 Deep link to iOS Settings from Notifications prefs 2024-04-12 22:54:17 -04:00
Shadowfacts 1eec70449d Show notification when push notification banner tapped 2024-04-12 22:47:11 -04:00
Shadowfacts 19ca930ee8 Remove the need to register with the push proxy 2024-04-12 16:15:52 -04:00
Shadowfacts 2e31d34e9d Maybe fix continuation being reused 2024-04-11 22:30:43 -04:00
Shadowfacts 8a339ec171 Reregister client when adding push scope 2024-04-11 22:19:29 -04:00
Shadowfacts c7d79422bd Fix clean build failures 2024-04-11 21:48:41 -04:00
Shadowfacts baf96a8b06 Support settings -> app notification preferences link 2024-04-11 18:26:58 -04:00
Shadowfacts bc516a6326 Remove push proxy scheme build setting 2024-04-11 13:00:39 -04:00
Shadowfacts 1cd6af1236 Remove existing push subscriptions when unregistering from proxy 2024-04-11 12:58:43 -04:00
Shadowfacts 9f6910ba73 Implement communication notifications 2024-04-11 12:44:41 -04:00
Shadowfacts 9cf4975bfd Remove transaction ID from push notifications registration 2024-04-11 11:55:56 -04:00
Shadowfacts ee992bc0bf Improve per-instance push settings 2024-04-10 19:13:47 -04:00
Shadowfacts ff8a83ca2d Decrypt push notifications 2024-04-09 22:39:58 -04:00
Shadowfacts 4c957b86ae Fix push subscription policy/alerts not persisting 2024-04-09 21:07:14 -04:00
Shadowfacts ff11835333 Update oauth scopes when enabling push notifications
Closes #467
2024-04-09 19:05:31 -04:00
Shadowfacts 9353bbb56c Merge branch 'develop' into push-notifications 2024-04-09 18:43:53 -04:00
Shadowfacts edc887dd4c Rename PushManager properties 2024-04-09 12:38:24 -04:00
Shadowfacts 68dad77f81 Update Mastodon push subscriptions when endpoint changes 2024-04-09 12:38:24 -04:00
Shadowfacts 840b83012a Don't use Sentry in PushNotifications package 2024-04-09 11:56:22 -04:00
Shadowfacts e150856e91 Improve AsyncToggle behavior on failure 2024-04-09 11:49:55 -04:00
Shadowfacts 42a3f6c880 Use the right public key representation for push subscriptions 2024-04-09 11:48:53 -04:00
Shadowfacts 7a47b09b39 Remove push subscription when logging out of account 2024-04-08 22:50:39 -04:00
Shadowfacts 241e6f7e3a Notification type toggles 2024-04-08 22:32:46 -04:00
Shadowfacts f02afaac26 Move AsyncToggle to TuskerComponents 2024-04-08 22:32:46 -04:00
Shadowfacts bdd4a4d755 Scaffolding for push subscription alert types 2024-04-08 18:44:56 -04:00
Shadowfacts 94c1eb2c81 Create/remove instance push subscriptions 2024-04-08 12:25:39 -04:00
Shadowfacts b03991ae1d Move push notifications stuff to separate package 2024-04-08 10:48:28 -04:00
Shadowfacts f98589b419 Start account-specific push subscriptions 2024-04-07 23:14:12 -04:00
Shadowfacts 9fad2a882a More reliable registering/unregistering 2024-04-07 22:47:58 -04:00
Shadowfacts ec76754270 Bump build number and update changelog 2024-04-07 22:36:56 -04:00
Shadowfacts d0bb197e8c Correct button titles 2024-04-07 22:29:48 -04:00
Shadowfacts efd90bca3e Add Account Settings button to preferences 2024-04-07 22:28:30 -04:00
Shadowfacts 3efa017942 Push proxy registration 2024-04-07 14:04:42 -04:00
Shadowfacts c5226f6374 Add push scope 2024-04-06 11:04:03 -04:00
Shadowfacts 281585cdf0 Update release changelog 2024-04-04 23:13:02 -04:00
Shadowfacts 6d4ab4d54b Bump build number and update changelog 2024-04-04 18:32:13 -04:00
Shadowfacts 9e429463b2 Make the audio session work better
Closes #353
Closes #443
2024-04-04 17:31:16 -04:00
Shadowfacts 51db0066ac Bump build number and update changelog 2024-04-02 22:13:28 -04:00
Shadowfacts 9763edef47 Add See Results button to polls
Closes #445
2024-04-02 22:04:16 -04:00
Shadowfacts 442f57bfc4 Enable gallery interactive dismissal for statuses with >4 attachments
Closes #466
2024-04-02 21:21:39 -04:00
Shadowfacts ae7101bb30 Fix race between loading/animation when presenting gallery from attachment more view 2024-04-02 21:21:19 -04:00
Shadowfacts 490d48c635 Fix loading indicator never disappearing when presenting gallery from status with >4 attachments 2024-04-02 21:16:09 -04:00
Shadowfacts 69ee3bb4f0 Fix gallery content being incorrectly positioned on macOS when reduce motion is on
See #446
2024-04-02 21:01:54 -04:00
Shadowfacts 46b455c3d1 Fix crash when there are multiple follow notifications for the same account
Only reproducible on Pixelfed
2024-04-01 21:52:47 -04:00
Shadowfacts e522e30ce5 Bump build number and update changelog 2024-04-01 21:39:43 -04:00
Shadowfacts c73784aa81 Mark notifications on Mastodon web frontend as read once displayed
Fixes #357
2024-04-01 19:51:57 -04:00
Shadowfacts 7affa09e5e Remove timeline marker Sentry reporting
I'm 99% sure these timeouts are all due to bad network conditions
2024-04-01 19:43:14 -04:00
Shadowfacts 7435d02f6e Fiddle with how the timeline markers API is organized 2024-04-01 19:22:55 -04:00
Shadowfacts 2467297f04 Add preference for inverted alt text badge
Closes #423
2024-04-01 18:47:19 -04:00
Shadowfacts cf317e15e9 Designed for iPad: Possible fix for gallery content being positioned/sized incorrectly
See #446
2024-04-01 12:40:30 -04:00
Shadowfacts bcae60316b Fix changing list reply policy not reloading list timeline 2024-04-01 11:04:40 -04:00
Shadowfacts 1a2fa10708 Improve edit list account removal animation 2024-04-01 11:02:33 -04:00
Shadowfacts f79c2feea6 Fix edit list screen not updating after adding account 2024-04-01 11:02:15 -04:00
Shadowfacts 7ec87d7853 Add no content message to list timelines
Closes #215

Also fix interactive dismissal of edit screen not reloading list
2024-04-01 10:58:42 -04:00
Shadowfacts f5704e561b Support tapping selected sidebar item to scroll to top 2024-04-01 10:35:54 -04:00
Shadowfacts d6faf3a37b Fix fast account switching view not respecting safe area 2024-04-01 10:28:40 -04:00
Shadowfacts b0a6952643 Fix trending hashtags screen not clearing selection 2024-04-01 09:47:05 -04:00
Shadowfacts 06b58cfb9c Fix notifications screen not responding to tab bar/status bar scroll to top 2024-04-01 09:45:22 -04:00
Shadowfacts afcec24f86 Fix reference cycles in gallery 2024-03-31 23:29:28 -04:00
Shadowfacts 3f90a0df04 Fix gifvs preventing sleep 2024-03-31 23:20:55 -04:00
Shadowfacts 395ce6523d Fix follows account list using wrong separator insets 2024-03-31 23:14:54 -04:00
323 changed files with 11627 additions and 3283 deletions

View File

@ -0,0 +1,157 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="Tusker.svg"
inkscape:version="1.0beta2 (2b71d25, 2019-12-03)"
inkscape:export-ydpi="11.52"
inkscape:export-xdpi="11.52"
inkscape:export-filename="/Users/shadowfacts/Desktop/60x60@2x.png"
id="svg8"
version="1.1"
viewBox="0 0 264.58333 264.58333"
height="1000"
width="1000">
<defs
id="defs2">
<inkscape:path-effect
bendpath1-nodetypes="cc"
bendpath4="M 27.271345,85.808468 V 178.94843"
bendpath3="M 27.271345,178.94843 H 242.39013"
bendpath2="M 242.39013,85.808468 V 178.94843"
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
xx="true"
yy="true"
lpeversion="1"
is_visible="true"
id="path-effect1345"
effect="envelope" />
<inkscape:path-effect
allow_transforms="true"
css_properties=""
attributes=""
method="d"
linkeditem=""
lpeversion="1"
is_visible="true"
id="path-effect38"
effect="clone_original" />
<inkscape:path-effect
scale_y_rel="false"
prop_scale="1"
strokepath="M0,0 L1,0"
endpoint_spacing_variation="0;1"
endpoint_edge_variation="0;1"
startpoint_spacing_variation="0;1"
startpoint_edge_variation="0;1"
count="5"
lpeversion="1"
is_visible="true"
id="path-effect32"
effect="curvestitching" />
<filter
height="1.3500000000000001"
width="1.2"
id="filter1277"
inkscape:label="Drop Shadow"
style="color-interpolation-filters:sRGB;">
<feFlood
id="feFlood1267"
result="flood"
flood-color="rgb(0,0,0)"
flood-opacity="0.321569" />
<feComposite
id="feComposite1269"
result="composite1"
operator="in"
in2="SourceGraphic"
in="flood" />
<feGaussianBlur
id="feGaussianBlur1271"
result="blur"
stdDeviation="5"
in="composite1" />
<feOffset
id="feOffset1273"
result="offset"
dy="5"
dx="-2.5" />
<feComposite
id="feComposite1275"
result="composite2"
operator="over"
in2="offset"
in="SourceGraphic" />
</filter>
</defs>
<sodipodi:namedview
inkscape:window-maximized="0"
inkscape:window-y="23"
inkscape:window-x="1920"
inkscape:window-height="1395"
inkscape:window-width="1902"
units="px"
showgrid="false"
inkscape:document-rotation="0"
inkscape:current-layer="layer2"
inkscape:document-units="px"
inkscape:cy="496.39379"
inkscape:cx="442.66632"
inkscape:zoom="1.4142136"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 2"
id="layer2"
inkscape:groupmode="layer">
<rect
y="-0.14500916"
x="-0.14500916"
height="264.87335"
width="264.87335"
id="rect865"
style="fill:#75e04e;fill-opacity:1;stroke:#75e04e;stroke-width:0.239149;stroke-opacity:1" />
</g>
<g
style="display:none"
id="layer1"
inkscape:groupmode="layer"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
id="path28" />
</g>
<g
inkscape:label="Layer 1 copy"
inkscape:groupmode="layer"
id="g1343">
<path
id="path1341"
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.56578600000000001px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
sodipodi:docname="Tusker transparent.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-ydpi="98.304001"
inkscape:export-xdpi="98.304001"
inkscape:export-filename="../Desktop/1024x1024-dark@1x.png"
id="svg8"
version="1.1"
viewBox="0 0 264.58333 264.58333"
height="1000"
width="1000"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2">
<inkscape:path-effect
bendpath1-nodetypes="cc"
bendpath4="M 27.271345,85.808468 V 178.94843"
bendpath3="M 27.271345,178.94843 H 242.39013"
bendpath2="M 242.39013,85.808468 V 178.94843"
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
xx="true"
yy="true"
lpeversion="1"
is_visible="true"
id="path-effect1345"
effect="envelope" />
<inkscape:path-effect
allow_transforms="true"
css_properties=""
attributes=""
method="d"
linkeditem=""
lpeversion="1"
is_visible="true"
id="path-effect38"
effect="clone_original" />
<inkscape:path-effect
scale_y_rel="false"
prop_scale="1"
strokepath="M0,0 L1,0"
endpoint_spacing_variation="0;1"
endpoint_edge_variation="0;1"
startpoint_spacing_variation="0;1"
startpoint_edge_variation="0;1"
count="5"
lpeversion="1"
is_visible="true"
id="path-effect32"
effect="curvestitching" />
<filter
height="1.317445"
width="1.1258237"
id="filter1277"
inkscape:label="Drop Shadow"
style="color-interpolation-filters:sRGB;"
x="-0.068723437"
y="-0.1318855">
<feFlood
id="feFlood1267"
result="flood"
flood-color="rgb(0,0,0)"
flood-opacity="0.321569" />
<feComposite
id="feComposite1269"
result="composite1"
operator="in"
in2="SourceGraphic"
in="flood" />
<feGaussianBlur
id="feGaussianBlur1271"
result="blur"
stdDeviation="5"
in="composite1" />
<feOffset
id="feOffset1273"
result="offset"
dy="5"
dx="-2.5" />
<feComposite
id="feComposite1275"
result="composite2"
operator="over"
in2="offset"
in="SourceGraphic" />
</filter>
</defs>
<sodipodi:namedview
inkscape:window-maximized="0"
inkscape:window-y="25"
inkscape:window-x="1280"
inkscape:window-height="1387"
inkscape:window-width="1280"
units="px"
showgrid="false"
inkscape:document-rotation="0"
inkscape:current-layer="layer2"
inkscape:document-units="px"
inkscape:cy="404.46507"
inkscape:cx="442.29528"
inkscape:zoom="1.4142136"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 2"
id="layer2"
inkscape:groupmode="layer" />
<g
style="display:none"
id="layer1"
inkscape:groupmode="layer"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
id="path28" />
</g>
<g
inkscape:label="Layer 1 copy"
inkscape:groupmode="layer"
id="g1343">
<path
id="path1341"
style="fill:#75e04e;fill-opacity:1;stroke:#74e04d;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

162
Artwork/Tusker.svg Normal file
View File

@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
sodipodi:docname="Tusker.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
inkscape:export-ydpi="98.304001"
inkscape:export-xdpi="98.304001"
inkscape:export-filename="../Desktop/1024x1024@1x.png"
id="svg8"
version="1.1"
viewBox="0 0 264.58333 264.58333"
height="1000"
width="1000"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2">
<inkscape:path-effect
bendpath1-nodetypes="cc"
bendpath4="M 27.271345,85.808468 V 178.94843"
bendpath3="M 27.271345,178.94843 H 242.39013"
bendpath2="M 242.39013,85.808468 V 178.94843"
bendpath1="M 26.897168,85.995557 242.39013,85.808468"
xx="true"
yy="true"
lpeversion="1"
is_visible="true"
id="path-effect1345"
effect="envelope" />
<inkscape:path-effect
allow_transforms="true"
css_properties=""
attributes=""
method="d"
linkeditem=""
lpeversion="1"
is_visible="true"
id="path-effect38"
effect="clone_original" />
<inkscape:path-effect
scale_y_rel="false"
prop_scale="1"
strokepath="M0,0 L1,0"
endpoint_spacing_variation="0;1"
endpoint_edge_variation="0;1"
startpoint_spacing_variation="0;1"
startpoint_edge_variation="0;1"
count="5"
lpeversion="1"
is_visible="true"
id="path-effect32"
effect="curvestitching" />
<filter
height="1.317445"
width="1.1258237"
id="filter1277"
inkscape:label="Drop Shadow"
style="color-interpolation-filters:sRGB;"
x="-0.068723437"
y="-0.1318855">
<feFlood
id="feFlood1267"
result="flood"
flood-color="rgb(0,0,0)"
flood-opacity="0.321569" />
<feComposite
id="feComposite1269"
result="composite1"
operator="in"
in2="SourceGraphic"
in="flood" />
<feGaussianBlur
id="feGaussianBlur1271"
result="blur"
stdDeviation="5"
in="composite1" />
<feOffset
id="feOffset1273"
result="offset"
dy="5"
dx="-2.5" />
<feComposite
id="feComposite1275"
result="composite2"
operator="over"
in2="offset"
in="SourceGraphic" />
</filter>
</defs>
<sodipodi:namedview
inkscape:window-maximized="0"
inkscape:window-y="25"
inkscape:window-x="1280"
inkscape:window-height="1387"
inkscape:window-width="1280"
units="px"
showgrid="false"
inkscape:document-rotation="0"
inkscape:current-layer="layer2"
inkscape:document-units="px"
inkscape:cy="496.38895"
inkscape:cx="442.29528"
inkscape:zoom="1.4142136"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 2"
id="layer2"
inkscape:groupmode="layer">
<rect
y="-0.14500916"
x="-0.14500916"
height="264.87335"
width="264.87335"
id="rect865"
style="fill:#75e04e;fill-opacity:1;stroke:#75e04e;stroke-width:0.239149;stroke-opacity:1" />
</g>
<g
style="display:none"
id="layer1"
inkscape:groupmode="layer"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z"
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.565786px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
id="path28" />
</g>
<g
inkscape:label="Layer 1 copy"
inkscape:groupmode="layer"
id="g1343">
<path
id="path1341"
style="fill:#f9f7f3;fill-opacity:1;stroke:#d0c1a2;stroke-width:0.56578600000000001px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#filter1277)"
d="m 63.595661,96.781172 c 2.610557,8.549728 11.109144,14.261728 18.221441,19.677268 14.285995,10.87784 30.777538,19.16253 47.836068,24.76819 12.08516,3.97134 24.9714,5.89737 37.69211,5.9756 11.75058,0.0723 23.533,-2.04773 34.88282,-5.09124 8.49997,-2.27931 17.13306,-4.99674 24.52676,-9.76937 5.59427,-3.6111 11.35542,-7.93448 14.37737,-13.86775 0.73693,-1.44688 2.00968,-3.90176 0.67356,-4.82442 -4.90929,-3.39011 -10.18592,6.31619 -15.70026,8.59349 -8.68681,3.58745 -17.81526,6.26681 -27.07782,7.85916 -11.94219,2.05301 -24.25018,3.46797 -36.29097,2.10779 -12.7013,-1.4348 -25.14557,-5.50493 -36.82103,-10.70737 -8.48127,-3.77914 -16.22058,-9.14294 -23.66896,-14.68689 C 96.49438,102.53405 91.950513,96.75601 86.13513,92.560411 82.533585,89.96202 79.028923,86.323649 74.610572,85.8754 c -3.438589,-0.34885 -7.602338,0.653715 -9.831133,3.295317 -1.655568,1.962204 -1.933509,5.155042 -1.183778,7.610455 z m -36.276154,1.911751 c 1.000129,9.935377 9.068818,18.042637 15.683118,25.523487 13.285704,15.02628 29.55205,27.69127 47.022482,37.54435 12.376983,6.98044 26.073563,11.90937 39.996973,14.7475 13.12015,2.67439 26.76072,2.8433 40.12178,1.96426 10.02366,-0.65947 20.39718,-1.4876 29.64741,-5.40445 6.55654,-2.77625 13.10939,-6.72368 17.37506,-12.42454 1.08663,-1.45223 3.06381,-3.85048 1.78759,-5.13927 -4.73249,-4.77911 -12.53753,5.06785 -19.12154,6.4416 -10.27704,2.1443 -20.88256,2.99026 -31.37744,2.71972 -13.53101,-0.3488 -27.32398,-1.47627 -40.22043,-5.58617 -13.6039,-4.33535 -26.35283,-11.50217 -38.013078,-19.74231 -8.470214,-5.98579 -15.782756,-13.54635 -22.737346,-21.24101 -5.371021,-5.94258 -9.092383,-13.26181 -14.551151,-19.123884 -3.38068,-3.630455 -6.428954,-8.379273 -11.172348,-9.831658 -3.691559,-1.130322 -8.47165,-0.937751 -11.488322,1.47159 -2.240814,1.789682 -3.239987,5.227417 -2.952758,8.080785 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@ -1,3 +1,100 @@
## 2024.4
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.
Features/Improvements:
- Import image description when adding attachments from Photos if possible
- iPadOS 18: New floating sidebar/tab bar
Bugfixes:
- Fix crash when viewing profiles in certain circumstances
- Fix video controls in attachment gallery not auto-hiding
- Fix crash if hashtag search results includes duplicates
- Fix "no content" text not being removed from list timeline after refreshing
- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
- macOS: Fix reselecting current item not navigating back
## 2024.3
This update includes a number of bugfixes and performance improvements. See below for a list of fixes.
Bugfixes:
- Fix an issue displaying rich text in certain cases
- Fix crash when video attachment finishes playing
- Fix video attachment thumbnails being flipped on Compose screen
- Fix profile header images being blurry
- Fix crash when opening push notifications in certain circumstances
- Fix certain links in profile fields not being tappable
- Fix gifv playback pausing audio from other apps
- Fix gifv playback being paused when returning from background
- Fix badges on gifv attachments not appearing
- Fix excessive network traffic when opening profile pages
- Fix controls visibility not matching across attachment gallery pages
- Fix add hashtag/instance pinned timeline sheet in Customize Timelines dismissing instantly
- Fix Dynamic Type not applying to status content
- Fix mention/status push notifications not showing CW
- Fix sensitive attachment thumbnails being shown in push notifications
- Fix profile moved overlay visual and VoiceOver issues
- Fix opening Mastodon remote status links
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
- Pleroma/Akkoma: Fix editing attachment descriptions not working
- Pixelfed/Firefish: Fix error loading certain accounts
- Pixelfed: Fix error loading relationships and follow/block/etc. actions
- iPadOS: Fix pointer interactions throughout the app
- iPadOS: Fix multiple close buttons being added in multi-column interface
- iPadOS: Fix Cmd+1/etc. removing columns when returning to previous tab
- iPadOS: Fix multi-column interface not animating for some actions
- iPadOS: Fix selecting search results always adding new column
## 2024.2
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
Features/Improvements:
- Push notifications
- Add post preview to Appearance preferences
- Show instance announcements in Notifications tab
- Add subscription option to Tip Jar
- iPadOS: Multi-column navigation
- Pleroma/Akkoma: Emoji reaction notifications
Bugfixes:
- Fix fetching server info on some instances
- Fix attachment captions not displaying while loading in gallery
- macOS: Remove in-app Safari preferences
- Pleroma: Handle posts with missing creation date
## 2024.1
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.
Features/Improvements:
- Improve attachment gallery
- Improve animations
- Display video captions
- Support sharing/saving videos
- Resume music playback after playing videos
- Improve rich text display in posts
- Add See Results button to polls
- Add Share and Save to Photos menu items to post attachments
- Show verified links in account lists
- Display message on empty list timelines
- Add preference to indicate attachments lacking alt text
- Mark notifications as read on Mastodon web frontend once displayed
- iPadOS: Support tapping the selected sidebar item to scroll to top
Bugfixes:
- Fix issue changing scope after searching
- Fix crash when searching "from:me"
- Fix tapping Followers button on profile opening Following screen
- Fix crash when removing poll option on Compose screen
- Fix hang when sharing video/GIFV attachments
- Fix stretched Save to Photos icon when sharing attachments
- Fix GIFV playback preventing device sleep
- Fix Notifications tab not scrolling to top when tab bar item tapped
- Fix selection not clearing on Trending Hashtags
- Fix fast account switcher overlapping iPhone sensor housing in landscape
- Fix Edit List screen not updating when adding/removing accounts
- Fix changing list reply policy not refreshing timeline
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
- macOS: Fix attachment gallery displaying improperly when Reduce Motion is on
## 2023.8 ## 2023.8
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes. This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.

View File

@ -1,5 +1,181 @@
# Changelog # Changelog
## 2024.4 (136)
Features/Improvements:
- Import image description when adding attachments from Photos if possible
- Reorganize toolbar buttons when adding saved hashtag
- Show errors when loading video in attachment gallery fails
Bugfixes:
- Fix crash when viewing profiles in certain circumstances
- Fix profile tab switching animation getting stuck
- Fix video controls in attachment gallery not auto-hiding
- Pleroma: Fix error when loading polls in some circumstances
- iPadOS 18: Fix incorrect two-column layout when closing sidebar
- macOS: Fix video controls overlay being positioned incorrectly when Reduce Motion is on
- macOS: Fix reselecting current item not navigating back
## 2024.4 (135)
Features/Improvements:
- iOS 18: New floating sidebar/tab bar
Bugfixes:
- Fix crash when hashtag search results include duplicates
- Fix "no content" text not being removed from list timeline after refreshing
## 2024.3 (133)
- Add additional info to Tip Jar
## 2024.3 (132)
- Add ToS nag before signing in
## 2024.3 (131)
Bugfixes:
- Fix Cmd+3 not correctly switching to Explore tab
## 2024.3 (130)
Bugfixes:
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
- Fix crash when dragging between buttons in reblog confirmation alert
- Fix potential crash when displaying search results
- Mac: Fix Post button not displaying on Compose screen
## 2024.3 (129)
Bugfixes:
- Fix excessive network traffic on profile pages
- Fix attachment gallery controls visibility not being synced between pages
- Fix video attachments not restarting when play pressed while at ends
- Fix profile field text being misaligned
- Fix at sign in timeline statuses usernames sometimes clipping
- Fix add hashtag/instance to Pinned Timelines sheets dismissing immediately when opened
- Fix for display name being replaced with incorrect user in certain circumstances
- Fix profile moved overlay view appearing behind avatar/header
- Fix profile moved view accessibility with VoiceOver
- Fix mention/status push notifications not showing content warning
- Fix sensitive attachment thumbnails being shown in push notifications
- Fix Dynamic Type not applying to status content
- Fix expand all option in Conversation not transferring when opening ancestors
- Fix not being able to resolve remote Mastodon status links in Conversation screen
- Fix status indicator icons overlapping thread links when Dynamic Type is enabled
## 2024.3 (128)
Bugfixes:
- Fix selecting poll option playing too much haptic feedback
- Fix crash when displaying HTML in certain posts
- Fix gifv playback pausing audio from other apps
- Fix gifv playback not resuming after returning from background
- Fix attachment badges not appearing on gifvs
- iPadOS: Fix poll options not having pointer hover effects
- iPadOS: Fix haptic feedback not working on new Magic Keyboard
- iPadOS: Fix scrubbing video with pointer not letting you click to select position
- iPadOS: Fix multi-column navigation not animating when replacing multiple columns
## 2024.3 (127)
Bugfixes:
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
- Fix profile header images being blurry
- Fix dismissing gallery when presented from sheet
- Fix potential crash in multi-column interface
- Fix crash when opening push notification while sheet presented
- Fix being able to block your own domain
- Fix links in profile fields with other text not being interactable
- Fix excessive CPU use immediately after app launch
- Fix timeline failing to load when one status is malformed
- iPadOS: Fix pointer interactions on conversation main status action buttons
- iPadOS: Fix multiple close buttons being added in multi-column interface
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
- iPadOS: Fix profile followers/following buttons not having pointer effect
- iPadOS: Fix search token suggestions not having pointer effect
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
- iPadOS: Fix selecting search result always pushing new column rather than replacing
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
## 2024.3 (126)
Bugfixes:
- Fix an issue displaying post HTML in certain edge cases
- Fix crash when video attachment playback ends
- Fix excessive CPU usage when scrubbing video attachment
- Fix video attachment thubmnails being flipped on Compose screen
- Pleroma: Fix editing attachment descriptions not working
## 2024.2 (124)
Features/Improvements:
- Add subscription option to Tip Jar
Bugfixes:
- Fix attachment captions not displaying while loading in gallery
- Fix tapping follow request push notification not working
- Pleroma: Handle posts with missing creation dates
## 2024.2 (122)
Features/Improvements:
- Show instance announcements in Notifications
- Pleroma/Akkoma: Display emoji reactions in Notifications
- Pleroma/Akkoma: Add push notifications for emoji reactions
Bugfixes:
- Fix issue fetching server info on some instances
- Fix Preferences background color not updating after changing Pure Black Dark Mode
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
## 2024.2 (121)
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.
Features/Improvements:
- iPadOS: Enable multi-column navigation
- Add post preview to Appearance preferences
- Consolidate Media preferences section with Appearance
- Add icons to Preferences sections
Bugfixes:
- Fix push notifications not working on Pleroma/Akkoma and older Mastodon versions
- Fix push notifications not working with certain accounts
- Fix links on About screen not being aligned
- macOS: Remove non-functional in-app Safari preferences
## 2024.2 (120)
This build adds push notifications, which can be enabled in Preferences -> Notifications.
## 2024.1 (119)
Features/Improvements:
- Add Account Settings button to Preferences
## 2024.1 (118)
Bugfixes:
- Fix music not pausing/resuming when video playback starts
## 2024.1 (117)
Features/Improvements:
- Add See Results button to polls
Bugfixes:
- Fix race condition when presenting gallery for 4th of more than 4 attachments
- Fix gallery interactive dismissal not working for 4th or later attachments on posts with more than 4 attachments
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
- macOS: Fix gallery being positioned incorrectly when Reduce Motion is on
## 2024.1 (116)
Features/Improvements:
- Display message on empty list timelines
- Add preference to display badge for attachments that lack alt text
- Mark notifications as read on the Mastodon web frontend once displayed
- iPadOS: Support tapping the selected sidebar item to scroll to top
Bugfixes:
- Fix playing back GIFVs preventing the device sleeping
- Fix incorrect cell separator insets followers/following lists
- Fix memory leak in attachments gallery
- Fix notifications tab not scrolling to top when tab bar item tapped
- Fix Trending Hashtags screen not clearing selection
- Fix fast account switcher overlapping sensor housing on landscape iPhones
- Fix Edit List screen not updating when accounts are added/removed
- Fix changing List reply policy not refreshing list timeline
- macOS: Fix certain gallery attachments being incorrectly sized/positioned
## 2024.1 (115) ## 2024.1 (115)
Features/Improvements: Features/Improvements:
- Rewrite attachment gallery - Rewrite attachment gallery

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>TuskerInfo</key>
<dict>
<key>PushProxyHost</key>
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
<key>PushProxyScheme</key>
<string>$(TUSKER_PUSH_PROXY_SCHEME)</string>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,397 @@
//
// NotificationService.swift
// NotificationExtension
//
// Created by Shadowfacts on 4/9/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UserNotifications
import UserAccounts
import PushNotifications
import CryptoKit
import OSLog
import Pachyderm
import Intents
import HTMLStreamer
import WebURL
import UIKit
import TuskerPreferences
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
class NotificationService: UNNotificationServiceExtension {
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task<Void, Never>)?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
logger.error("Couldn't get mutable content")
contentHandler(request.content)
return
}
guard request.content.userInfo["v"] as? Int == 1,
let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding),
let account = UserAccountsManager.shared.getAccount(id: accountID),
let subscription = getSubscription(account: account),
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),
let salt = (request.content.userInfo["salt"] as? String).flatMap(decodeBase64URL(_:)),
let serverPublicKeyData = (request.content.userInfo["pk"] as? String).flatMap(decodeBase64URL(_:)) else {
logger.error("Missing info from push notification")
contentHandler(request.content)
return
}
guard let body = decryptNotification(subscription: subscription, serverPublicKeyData: serverPublicKeyData, salt: salt, encryptedBody: encryptedBody) else {
contentHandler(request.content)
return
}
let withoutPadding = body.dropFirst(2)
let notification: PushNotification
do {
notification = try JSONDecoder().decode(PushNotification.self, from: withoutPadding)
} catch {
logger.error("Unable to decode push payload: \(String(describing: error))")
contentHandler(request.content)
return
}
mutableContent.title = notification.title
mutableContent.body = notification.body
mutableContent.userInfo["notificationID"] = notification.notificationID
mutableContent.userInfo["accountID"] = accountID
mutableContent.targetContentIdentifier = accountID
let task = Task {
await updateNotificationContent(mutableContent, account: account, push: notification)
if !Task.isCancelled {
contentHandler(pendingRequest?.0 ?? mutableContent)
pendingRequest = nil
}
}
pendingRequest = (mutableContent, contentHandler, task)
}
override func serviceExtensionTimeWillExpire() {
if let pendingRequest {
logger.debug("Expiring with pending request")
pendingRequest.2.cancel()
pendingRequest.1(pendingRequest.0)
self.pendingRequest = nil
} else {
logger.debug("Expiring without pending request")
}
}
private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async {
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken)
let notification: Pachyderm.Notification
do {
notification = try await client.run(Client.getNotification(id: push.notificationID)).0
} catch {
logger.error("Error fetching notification: \(String(describing: error))")
return
}
let kindStr: String?
switch notification.kind {
case .reblog:
kindStr = "🔁 Reblogged"
case .favourite:
kindStr = "⭐️ Favorited"
case .follow:
kindStr = "👤 Followed by @\(notification.account.acct)"
case .followRequest:
kindStr = "👤 Asked to follow by @\(notification.account.acct)"
case .poll:
kindStr = "📊 Poll finished"
case .update:
kindStr = "✏️ Edited"
case .emojiReaction:
if let emoji = notification.emoji {
kindStr = "\(emoji) Reacted"
} else {
kindStr = nil
}
default:
kindStr = nil
}
let notificationContent: String?
if let status = notification.status {
if notification.kind == .mention || notification.kind == .status,
!status.spoilerText.isEmpty {
notificationContent = "⚠️ \(status.spoilerText)"
} else {
notificationContent = NotificationService.textConverter.convert(html: status.content)
}
} else if notification.kind == .follow || notification.kind == .followRequest {
notificationContent = nil
} else {
notificationContent = push.body
}
content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n")
let attachmentDataTask: Task<URL?, Never>?
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
// because we risk just fetching the same thing a bunch of times for many senders.
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
let status = notification.status,
!status.sensitive,
let attachment = status.attachments.first {
let url = attachment.previewURL ?? attachment.url
attachmentDataTask = Task {
do {
let data = try await URLSession.shared.data(from: url).0
let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension)
try data.write(to: localAttachmentURL)
return localAttachmentURL
} catch {
logger.error("Error setting notification attachments: \(String(describing: error))")
return nil
}
}
} else {
attachmentDataTask = nil
}
let conversationIdentifier: String?
if let status = notification.status {
if let context = status.pleromaExtras?.context {
conversationIdentifier = "context:\(context)"
} else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) {
conversationIdentifier = "status:\(status.id)"
} else {
conversationIdentifier = nil
}
} else {
conversationIdentifier = nil
}
let account: Account?
switch notification.kind {
case .mention, .status:
account = notification.status?.account
default:
account = notification.account
}
let sender: INPerson?
if let account {
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
let image: INImage?
if let avatar = account.avatar,
let (data, resp) = try? await URLSession.shared.data(from: avatar),
let code = (resp as? HTTPURLResponse)?.statusCode,
(200...299).contains(code) {
image = INImage(imageData: data)
} else {
image = nil
}
sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: account.displayName,
image: image,
contactIdentifier: nil,
customIdentifier: account.id
)
} else {
sender = nil
}
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: notificationContent,
speakableGroupName: nil,
conversationIdentifier: conversationIdentifier,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
do {
try await interaction.donate()
} catch {
logger.error("Error donating interaction: \(String(describing: error))")
return
}
let updatedContent: UNMutableNotificationContent
let contentProviding: any UNNotificationContentProviding
if #available(iOS 18.0, visionOS 2.0, *),
await Preferences.shared.hasFeatureFlag(.pushNotifCustomEmoji) {
let attributedString = NSMutableAttributedString(string: content.body)
for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() {
let emojiName = (content.body as NSString).substring(with: match.range(at: 1))
guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }),
let url = URL(emoji.url),
let (data, _) = try? await URLSession.shared.data(from: url),
let image = UIImage(data: data) else {
continue
}
let attachment = NSTextAttachment(image: image)
let attachmentStr = NSAttributedString(attachment: attachment)
attributedString.replaceCharacters(in: match.range, with: attachmentStr)
}
let attributedCtx = UNNotificationAttributedMessageContext(sendMessageIntent: intent, attributedContent: attributedString)
contentProviding = attributedCtx
} else {
contentProviding = intent
}
do {
let newContent = try content.updating(from: contentProviding)
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
pendingRequest?.0 = newMutableContent
updatedContent = newMutableContent
} else {
updatedContent = content
}
} catch {
logger.error("Error updating notification from intent: \(String(describing: error))")
updatedContent = content
}
if let localAttachmentURL = await attachmentDataTask?.value,
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
updatedContent.attachments = [
attachment
]
}
}
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
DispatchQueue.main.sync {
// this is necessary because of a swift bug: https://github.com/apple/swift/pull/72507
MainActor.runUnsafely {
PushManager.shared.pushSubscription(account: account)
}
}
}
private func decryptNotification(subscription: PushNotifications.PushSubscription, serverPublicKeyData: Data, salt: Data, encryptedBody: Data) -> Data? {
// See https://github.com/ClearlyClaire/webpush/blob/f14a4d52e201128b1b00245d11b6de80d6cfdcd9/lib/webpush/encryption.rb
var context = Data()
context.append(0)
let clientPublicKey = subscription.secretKey.publicKey.x963Representation
let clientPublicKeyLength = UInt16(clientPublicKey.count)
context.append(UInt8((clientPublicKeyLength >> 8) & 0xFF))
context.append(UInt8(clientPublicKeyLength & 0xFF))
context.append(clientPublicKey)
let serverPublicKeyLength = UInt16(serverPublicKeyData.count)
context.append(UInt8((serverPublicKeyLength >> 8) & 0xFF))
context.append(UInt8(serverPublicKeyLength & 0xFF))
context.append(serverPublicKeyData)
func info(encoding: String) -> Data {
var info = Data("Content-Encoding: \(encoding)\0P-256".utf8)
info.append(context)
return info
}
let sharedSecret: SharedSecret
do {
let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData)
sharedSecret = try subscription.secretKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
} catch {
logger.error("Error getting shared secret: \(String(describing: error))")
return nil
}
let sharedInfo = Data("Content-Encoding: auth\0".utf8)
let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: subscription.authSecret, sharedInfo: sharedInfo, outputByteCount: 32)
let contentEncryptionKeyInfo = info(encoding: "aesgcm")
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
let nonceInfo = info(encoding: "nonce")
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceAndEncryptedBody = nonce.withUnsafeBytes { noncePtr in
var data = Data(buffer: noncePtr.bindMemory(to: UInt8.self))
data.append(encryptedBody)
return data
}
do {
let sealedBox = try AES.GCM.SealedBox(combined: nonceAndEncryptedBody)
let decrypted = try AES.GCM.open(sealedBox, using: contentEncryptionKey)
return decrypted
} catch {
logger.error("Error decrypting push: \(String(describing: error))")
return nil
}
}
}
extension MainActor {
@_unavailableFromAsync
@available(macOS, obsoleted: 14.0)
@available(iOS, obsoleted: 17.0)
@available(watchOS, obsoleted: 10.0)
@available(tvOS, obsoleted: 17.0)
@available(visionOS 1.0, *)
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
return try MainActor.assumeIsolated(body)
}
dispatchPrecondition(condition: .onQueue(.main))
return try withoutActuallyEscaping(body) { fn in
try unsafeBitCast(fn, to: (() throws -> T).self)()
}
}
}
private func decodeBase64URL(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
if str.count % 4 != 0 {
str.append(String(repeating: "=", count: 4 - str.count % 4))
}
return Data(base64Encoded: str)
}
// copied from HTMLConverter.Callbacks, blergh
private struct HTMLCallbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? {
// Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip.
if let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
nil
}
}
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
guard name == "span" else {
return .default
}
let clazz = attributes.attributeValue(for: "class")
if clazz == "invisible" {
return .skip
} else if clazz == "ellipsis" {
return .append("")
} else {
return .default
}
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
</dict>
</array>
</dict>
</plist>

View File

@ -24,6 +24,8 @@
<dict> <dict>
<key>NSExtensionAttributes</key> <key>NSExtensionAttributes</key>
<dict> <dict>
<key>NSExtensionServiceRoleType</key>
<string>NSExtensionServiceRoleTypeViewer</string>
<key>NSExtensionActivationRule</key> <key>NSExtensionActivationRule</key>
<dict> <dict>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key> <key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.7 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "ComposeUI", name: "ComposeUI",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -26,9 +26,15 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "ComposeUI", name: "ComposeUI",
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]), dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget( .testTarget(
name: "ComposeUITests", name: "ComposeUITests",
dependencies: ["ComposeUI"]), dependencies: ["ComposeUI"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -156,7 +156,7 @@ class AttachmentRowController: ViewController {
Button(role: .destructive, action: controller.removeAttachment) { Button(role: .destructive, action: controller.removeAttachment) {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
} previewIfAvailable: { } preview: {
ControllerView(controller: { controller.thumbnailController }) ControllerView(controller: { controller.thumbnailController })
} }
@ -221,16 +221,3 @@ extension AttachmentRowController {
case allowEntry, recognizingText case allowEntry, recognizingText
} }
} }
private extension View {
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
@ViewBuilder
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
if #available(iOS 16.0, *) {
self.contextMenu(menuItems: menuItems, preview: preview)
} else {
self.contextMenu(menuItems: menuItems)
}
}
}

View File

@ -40,6 +40,7 @@ class AttachmentThumbnailController: ViewController {
case .video, .gifv: case .video, .gifv:
let asset = AVURLAsset(url: url) let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset) let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS) #if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)") #warning("Use async AVAssetImageGenerator.image(at:)")
#else #else
@ -91,6 +92,7 @@ class AttachmentThumbnailController: ViewController {
if type.conforms(to: .movie) { if type.conforms(to: .movie) {
let asset = AVURLAsset(url: url) let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset) let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS) #if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)") #warning("Use async AVAssetImageGenerator.image(at:)")
#else #else

View File

@ -214,44 +214,6 @@ fileprivate extension View {
self self
} }
} }
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
if #available(iOS 16.0, *) {
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
} else {
self.popover(isPresented: isPresented, content: content)
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func withSheetDetentsIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
self
}
}
}
@available(iOS 16.0, *)
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
@Binding var isPresented: Bool
@ViewBuilder let view: () -> V
@Environment(\.horizontalSizeClass) var sizeClass
func body(content: Content) -> some View {
if sizeClass == .compact {
content.sheet(isPresented: $isPresented, content: view)
} else {
content.popover(isPresented: $isPresented, content: view)
}
}
} }
@available(visionOS 1.0, *) @available(visionOS 1.0, *)

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import Combine import Combine
import TuskerComponents
class AutocompleteEmojisController: ViewController { class AutocompleteEmojisController: ViewController {
unowned let composeController: ComposeController unowned let composeController: ComposeController

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Combine import Combine
import Pachyderm import Pachyderm
import TuskerComponents
class AutocompleteHashtagsController: ViewController { class AutocompleteHashtagsController: ViewController {
unowned let composeController: ComposeController unowned let composeController: ComposeController

View File

@ -125,9 +125,7 @@ public final class ComposeController: ViewController {
self.toolbarController = ToolbarController(parent: self) self.toolbarController = ToolbarController(parent: self)
self.attachmentsListController = AttachmentsListController(parent: self) self.attachmentsListController = AttachmentsListController(parent: self)
if #available(iOS 16.0, *) {
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
}
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
} }
@ -324,16 +322,17 @@ public final class ComposeController: ViewController {
ControllerView(controller: { controller.toolbarController }) ControllerView(controller: { controller.toolbarController })
#endif #endif
} }
#if !os(visionOS)
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
.padding(.bottom, keyboardInset)
#endif
.transition(.move(edge: .bottom)) .transition(.move(edge: .bottom))
} }
} }
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton } ToolbarItem(placement: .cancellationAction) { cancelButton }
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { draftsButton }
ToolbarItem(placement: .confirmationAction) { postButton } ToolbarItem(placement: .confirmationAction) { postButton }
#else
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
#endif
#if os(visionOS) #if os(visionOS)
ToolbarItem(placement: .bottomOrnament) { ToolbarItem(placement: .bottomOrnament) {
ControllerView(controller: { controller.toolbarController }) ControllerView(controller: { controller.toolbarController })
@ -431,7 +430,7 @@ public final class ComposeController: ViewController {
} }
.listStyle(.plain) .listStyle(.plain)
#if !os(visionOS) #if !os(visionOS)
.scrollDismissesKeyboardInteractivelyIfAvailable() .scrollDismissesKeyboard(.interactively)
#endif #endif
.disabled(controller.isPosting) .disabled(controller.isPosting)
} }
@ -461,43 +460,26 @@ public final class ComposeController: ViewController {
} }
@ViewBuilder @ViewBuilder
private var postButton: some View { private var postOrDraftsButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts { if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
postButton
} else {
draftsButton
}
}
private var draftsButton: some View {
Button(action: controller.showDrafts) {
Text("Drafts")
}
}
private var postButton: some View {
Button(action: controller.postStatus) { Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit") Text(draft.editedStatusID == nil ? "Post" : "Edit")
} }
.keyboardShortcut(.return, modifiers: .command) .keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled) .disabled(!controller.postButtonEnabled)
} else {
Button(action: controller.showDrafts) {
Text("Drafts")
}
}
}
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private var keyboardInset: CGFloat {
if #unavailable(iOS 16.0),
UIDevice.current.userInterfaceIdiom == .pad,
keyboardReader.isVisible {
return ToolbarController.height
} else {
return 0
}
}
#endif
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
} }
} }
} }

View File

@ -51,14 +51,11 @@ class FocusedAttachmentController: ViewController {
.onAppear { .onAppear {
player.play() player.play()
} }
} else if #available(iOS 16.0, *) { } else {
ZoomableScrollView { ZoomableScrollView {
attachmentView attachmentView
.matchedGeometryDestination(id: attachment.id) .matchedGeometryDestination(id: attachment.id)
} }
} else {
attachmentView
.matchedGeometryDestination(id: attachment.id)
} }
Spacer(minLength: 0) Spacer(minLength: 0)

View File

@ -96,7 +96,7 @@ class PollController: ViewController {
.onMove(perform: controller.moveOptions) .onMove(perform: controller.moveOptions)
} }
.listStyle(.plain) .listStyle(.plain)
.scrollDisabledIfAvailable(true) .scrollDisabled(true)
.frame(height: 44 * CGFloat(poll.options.count)) .frame(height: 44 * CGFloat(poll.options.count))
Button(action: controller.addOption) { Button(action: controller.addOption) {

View File

@ -66,7 +66,7 @@ class ToolbarController: ViewController {
} }
}) })
} }
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0) .scrollDisabled(realWidth ?? 0 <= minWidth ?? 0)
.frame(height: ToolbarController.height) .frame(height: ToolbarController.height)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing]) .background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
@ -122,8 +122,7 @@ class ToolbarController: ViewController {
Spacer() Spacer()
if #available(iOS 16.0, *), if composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection) LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
} }
} }
@ -181,13 +180,8 @@ class ToolbarController: ViewController {
private var formatButtons: some View { private var formatButtons: some View {
ForEach(StatusFormat.allCases, id: \.rawValue) { format in ForEach(StatusFormat.allCases, id: \.rawValue) { format in
Button(action: controller.formatAction(format)) { Button(action: controller.formatAction(format)) {
if let imageName = format.imageName { Image(systemName: format.imageName)
Image(systemName: imageName)
.font(.system(size: imageSize)) .font(.system(size: imageSize))
} else if let (str, attrs) = format.title {
let container = try! AttributeContainer(attrs, including: \.uiKit)
Text(AttributedString(str, attributes: container))
}
} }
.accessibilityLabel(format.accessibilityLabel) .accessibilityLabel(format.accessibilityLabel)
.padding(5) .padding(5)

View File

@ -167,11 +167,23 @@ extension DraftAttachment: NSItemProviderReading {
type = .png type = .png
} }
// Read the caption from the image itself, if there is one.
let caption: String
if let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceTypeIdentifierHint: typeIdentifier as CFString] as CFDictionary),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any],
// This is the dictionary for TIFF properties, but it's present for other image types too
let tiffProperties = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any],
let imageDescription = tiffProperties[kCGImagePropertyTIFFImageDescription as String] as? String {
caption = imageDescription
} else {
caption = ""
}
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil) let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
attachment.id = UUID() attachment.id = UUID()
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type) attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
attachment.fileType = type.identifier attachment.fileType = type.identifier
attachment.attachmentDescription = "" attachment.attachmentDescription = caption
return attachment return attachment
} }

View File

@ -16,6 +16,8 @@ public class DraftsPersistentContainer: NSPersistentContainer {
public static let shared = DraftsPersistentContainer() public static let shared = DraftsPersistentContainer()
public static var captureError: ((any Error) -> Void)?
private static let managedObjectModel: NSManagedObjectModel = { private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")! let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)! return NSManagedObjectModel(contentsOf: url)!
@ -39,6 +41,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
loadPersistentStores { _, error in loadPersistentStores { _, error in
if let error { if let error {
DraftsPersistentContainer.captureError?(error)
fatalError("Loading persistent store: \(error)") fatalError("Loading persistent store: \(error)")
} }
} }

View File

@ -10,7 +10,6 @@
import UIKit import UIKit
import Combine import Combine
@available(iOS, obsoleted: 16.0)
class KeyboardReader: ObservableObject { class KeyboardReader: ObservableObject {
// @Published var isVisible = false // @Published var isVisible = false
@Published var keyboardHeight: CGFloat = 0 @Published var keyboardHeight: CGFloat = 0

View File

@ -23,7 +23,7 @@ enum StatusFormat: Int, CaseIterable {
} }
} }
var imageName: String? { var imageName: String {
switch self { switch self {
case .italics: case .italics:
return "italic" return "italic"
@ -31,16 +31,8 @@ enum StatusFormat: Int, CaseIterable {
return "bold" return "bold"
case .strikethrough: case .strikethrough:
return "strikethrough" return "strikethrough"
default: case .code:
return nil return "chevron.left.forwardslash.chevron.right"
}
}
var title: (String, [NSAttributedString.Key: Any])? {
if self == .code {
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
} else {
return nil
} }
} }

View File

@ -39,6 +39,7 @@ extension TextViewCaretScrolling {
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) { let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false) scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
scrollView.layoutIfNeeded()
} }
self.caretScrollPositionAnimator = animator self.caretScrollPositionAnimator = animator
animator.startAnimation() animator.startAnimation()

View File

@ -1,26 +0,0 @@
//
// View+ForwardsCompat.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
extension View {
#if os(visionOS)
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
self.scrollDisabled(disabled)
}
#else
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
if #available(iOS 16.0, *) {
self.scrollDisabled(disabled)
} else {
self
}
}
#endif
}

View File

@ -259,11 +259,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
if range.length > 0 { if range.length > 0 {
let formatMenu = suggestedActions[index] as! UIMenu let formatMenu = suggestedActions[index] as! UIMenu
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
var image: UIImage? return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
if let imageName = fmt.imageName {
image = UIImage(systemName: imageName)
}
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
self?.applyFormat(fmt) self?.applyFormat(fmt)
} }
}) })

View File

@ -76,13 +76,15 @@ struct ReplyStatusView: View {
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
offset = min(offset, maxOffset) offset = min(offset, maxOffset)
return AvatarImageView( return AvatarContainerRepresentable(offset: offset) {
AvatarImageView(
url: status.account.avatar, url: status.account.avatar,
size: 50, size: 50,
style: controller.config.avatarStyle, style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar fetchAvatar: controller.fetchAvatar
) )
.offset(x: 0, y: offset) }
.frame(width: 50, height: 50)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
@ -94,3 +96,39 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
value = nextValue() value = nextValue()
} }
} }
// This whole dance is necessary so that the offset can be animatable from
// UIKit animations, like TextViewCaretScrolling.
private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepresentable {
let offset: CGFloat
@ViewBuilder let content: Content
func makeUIViewController(context: Context) -> Controller {
Controller(host: UIHostingController(rootView: content))
}
func updateUIViewController(_ uiViewController: Controller, context: Context) {
uiViewController.host.rootView = content
uiViewController.host.view.transform = CGAffineTransform(translationX: 0, y: offset)
}
// This extra layer is necessary because applying a transform to the
// representable's VC's view doesn't seem to have an effect.
class Controller: UIViewController {
let host: UIHostingController<Content>
init(host: UIHostingController<Content>) {
self.host = host
super.init(nibName: nil, bundle: nil)
addChild(host)
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(host.view)
host.view.frame = view.bounds
host.didMove(toParent: self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
}

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.7 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "Duckable", name: "Duckable",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,7 +23,10 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "Duckable", name: "Duckable",
dependencies: []), dependencies: [],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget( // .testTarget(
// name: "DuckableTests", // name: "DuckableTests",
// dependencies: ["Duckable"]), // dependencies: ["Duckable"]),

View File

@ -33,11 +33,11 @@ public enum DuckAttemptAction {
extension UIViewController { extension UIViewController {
@available(iOS 16.0, *) @available(iOS 16.0, *)
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool { public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false, completion: (() -> Void)? = nil) -> Bool {
var cur: UIViewController? = self var cur: UIViewController? = self
while let vc = cur { while let vc = cur {
if let container = vc as? DuckableContainerViewController { if let container = vc as? DuckableContainerViewController {
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil) container._presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: completion)
return true return true
} else { } else {
cur = vc.parent cur = vc.parent

View File

@ -58,7 +58,7 @@ public class DuckableContainerViewController: UIViewController {
]) ])
} }
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) { func _presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
guard case .idle = state else { guard case .idle = state else {
if animated, if animated,
case .ducked(_, placeholder: let placeholder) = state { case .ducked(_, placeholder: let placeholder) = state {

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.10 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "GalleryVC", name: "GalleryVC",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, making them visible to other packages. // Products define the executables and libraries a package produces, making them visible to other packages.
@ -18,9 +18,15 @@ let package = Package(
// Targets are the basic building blocks of a package, defining a module or a test suite. // Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies. // Targets can depend on other targets in this package and products from dependencies.
.target( .target(
name: "GalleryVC"), name: "GalleryVC",
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget( .testTarget(
name: "GalleryVCTests", name: "GalleryVCTests",
dependencies: ["GalleryVC"]), dependencies: ["GalleryVC"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -17,7 +17,7 @@ public protocol GalleryContentViewController: UIViewController {
var bottomControlsAccessoryViewController: UIViewController? { get } var bottomControlsAccessoryViewController: UIViewController? { get }
var canAnimateFromSourceView: Bool { get } var canAnimateFromSourceView: Bool { get }
func setControlsVisible(_ visible: Bool, animated: Bool) func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool)
func galleryContentDidAppear() func galleryContentDidAppear()
func galleryContentWillDisappear() func galleryContentWillDisappear()
} }
@ -35,7 +35,7 @@ public extension GalleryContentViewController {
true true
} }
func setControlsVisible(_ visible: Bool, animated: Bool) { func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
} }
func galleryContentDidAppear() { func galleryContentDidAppear() {

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
@MainActor @MainActor
public protocol GalleryContentViewControllerContainer { public protocol GalleryContentViewControllerContainer: AnyObject {
var galleryControlsVisible: Bool { get } var galleryControlsVisible: Bool { get }
func setGalleryContentLoading(_ loading: Bool) func setGalleryContentLoading(_ loading: Bool)

View File

@ -52,12 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
appliedSourceToDestTransform = false appliedSourceToDestTransform = false
} }
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
// is in the window's root presentation.
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
// `to.view` is already in the view hierarchy at this point; and adding it to the
// container causees it to be removed when the transition completes.
if to.view.superview == nil {
to.view.frame = container.bounds
container.addSubview(to.view)
}
from.view.frame = container.bounds
container.addSubview(from.view)
let content = itemViewController.takeContent() let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true content.view.layer.masksToBounds = true
container.addSubview(to.view)
container.addSubview(from.view)
container.addSubview(content.view) container.addSubview(content.view)
content.view.frame = destFrameInContainer content.view.frame = destFrameInContainer
@ -96,7 +106,7 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
content.view.frame = sourceFrameInContainer content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0 content.view.layer.opacity = 0
itemViewController.setControlsVisible(false, animated: false) itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
} }
animator.addCompletion { _ in animator.addCompletion { _ in
@ -112,6 +122,8 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
return return
} }
toVC.view.frame = transitionContext.containerView.bounds
fromVC.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(toVC.view) transitionContext.containerView.addSubview(toVC.view)
transitionContext.containerView.addSubview(fromVC.view) transitionContext.containerView.addSubview(fromVC.view)

View File

@ -10,7 +10,7 @@ import UIKit
@MainActor @MainActor
class GalleryDismissInteraction: NSObject { class GalleryDismissInteraction: NSObject {
private let viewController: GalleryViewController private unowned let viewController: GalleryViewController
private var content: GalleryContentViewController? private var content: GalleryContentViewController?
private var origContentFrameInGallery: CGRect? private var origContentFrameInGallery: CGRect?
@ -42,7 +42,7 @@ class GalleryDismissInteraction: NSObject {
origControlsVisible = viewController.currentItemViewController.controlsVisible origControlsVisible = viewController.currentItemViewController.controlsVisible
if origControlsVisible! { if origControlsVisible! {
viewController.currentItemViewController.setControlsVisible(false, animated: true) viewController.currentItemViewController.setControlsVisible(false, animated: true, dueToUserInteraction: false)
} }
case .changed: case .changed:

View File

@ -43,6 +43,8 @@ class GalleryItemViewController: UIViewController {
private(set) var controlsVisible: Bool = true private(set) var controlsVisible: Bool = true
private(set) var scrollAndZoomEnabled = true private(set) var scrollAndZoomEnabled = true
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
override var prefersHomeIndicatorAutoHidden: Bool { override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible return !controlsVisible
} }
@ -79,10 +81,10 @@ class GalleryItemViewController: UIViewController {
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayVC.view) view.addSubview(overlayVC.view)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: content.view.leadingAnchor), overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: content.view.trailingAnchor), overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: content.view.topAnchor), overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: content.view.bottomAnchor), overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
]) ])
} }
@ -211,7 +213,7 @@ class GalleryItemViewController: UIViewController {
updateZoomScale(resetZoom: false) updateZoomScale(resetZoom: false)
// Ensure the transform is correct if the controls are hidden // Ensure the transform is correct if the controls are hidden
setControlsVisible(controlsVisible, animated: false) setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
updateTopControlsInsets() updateTopControlsInsets()
} }
@ -219,7 +221,15 @@ class GalleryItemViewController: UIViewController {
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
// When the scrollView size changes, make sure the zoom scale is up-to-date since it depends on the scrollView's bounds.
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
updateZoomScale(resetZoom: true)
}
centerContent() centerContent()
// Ensure the transform is correct if the controls are hidden and their size changed.
setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
@ -240,7 +250,7 @@ class GalleryItemViewController: UIViewController {
func addContent() { func addContent() {
content.loadViewIfNeeded() content.loadViewIfNeeded()
content.setControlsVisible(controlsVisible, animated: false) content.setControlsVisible(controlsVisible, animated: false, dueToUserInteraction: false)
content.view.translatesAutoresizingMaskIntoConstraints = false content.view.translatesAutoresizingMaskIntoConstraints = false
if content.parent != self { if content.parent != self {
@ -280,16 +290,18 @@ class GalleryItemViewController: UIViewController {
content.view.layoutIfNeeded() content.view.layoutIfNeeded()
} }
func setControlsVisible(_ visible: Bool, animated: Bool) { func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
controlsVisible = visible controlsVisible = visible
guard let topControlsView, guard let topControlsView,
let bottomControlsView else { let bottomControlsView else {
return return
} }
func updateControlsViews() { func updateControlsViews() {
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height) topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height) bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
content.setControlsVisible(visible, animated: animated) content.setControlsVisible(visible, animated: animated, dueToUserInteraction: dueToUserInteraction)
} }
if animated { if animated {
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters()) let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
@ -303,6 +315,8 @@ class GalleryItemViewController: UIViewController {
} }
func updateZoomScale(resetZoom: Bool) { func updateZoomScale(resetZoom: Bool) {
scrollView.contentSize = content.contentSize
guard scrollAndZoomEnabled else { guard scrollAndZoomEnabled else {
scrollView.maximumZoomScale = 1 scrollView.maximumZoomScale = 1
scrollView.minimumZoomScale = 1 scrollView.minimumZoomScale = 1
@ -364,9 +378,6 @@ class GalleryItemViewController: UIViewController {
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus 47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
50, // iPhone 12 mini, 13 mini 50, // iPhone 12 mini, 13 mini
] ]
let islandDeviceTopInsets: [CGFloat] = [
59, // iPhone 14 Pro, 14 Pro Max, 15 Pro, 15 Pro Max
]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) { if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
// the notch width is not the same for the iPhones 13, // the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges // but what we actually want is the same offset from the edges
@ -376,16 +387,18 @@ class GalleryItemViewController: UIViewController {
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2 let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
shareButtonLeadingConstraint.constant = offset shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset closeButtonTrailingConstraint.constant = offset
} else if islandDeviceTopInsets.contains(view.safeAreaInsets.top) { } else if view.safeAreaInsets.top == 0 {
shareButtonLeadingConstraint.constant = 24 // square corner devices
shareButtonTopConstraint.constant = 24
closeButtonTrailingConstraint.constant = 24
closeButtonTopConstraint.constant = 24
} else {
shareButtonLeadingConstraint.constant = 8 shareButtonLeadingConstraint.constant = 8
shareButtonTopConstraint.constant = 8 shareButtonTopConstraint.constant = 8
closeButtonTrailingConstraint.constant = 8 closeButtonTrailingConstraint.constant = 8
closeButtonTopConstraint.constant = 8 closeButtonTopConstraint.constant = 8
} else {
// dynamic island devices
shareButtonLeadingConstraint.constant = 24
shareButtonTopConstraint.constant = 24
closeButtonTrailingConstraint.constant = 24
closeButtonTopConstraint.constant = 24
} }
} }
@ -415,7 +428,7 @@ class GalleryItemViewController: UIViewController {
scrollView.zoomScale > scrollView.minimumZoomScale { scrollView.zoomScale > scrollView.minimumZoomScale {
animateZoomOut() animateZoomOut()
} else { } else {
setControlsVisible(!controlsVisible, animated: true) setControlsVisible(!controlsVisible, animated: true, dueToUserInteraction: true)
} }
} }
@ -517,7 +530,7 @@ extension GalleryItemViewController: GalleryContentViewControllerContainer {
} }
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) { func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
setControlsVisible(visible, animated: animated) setControlsVisible(visible, animated: animated, dueToUserInteraction: false)
} }
} }
@ -532,9 +545,9 @@ extension GalleryItemViewController: UIScrollViewDelegate {
func scrollViewDidZoom(_ scrollView: UIScrollView) { func scrollViewDidZoom(_ scrollView: UIScrollView) {
if scrollView.zoomScale <= scrollView.minimumZoomScale { if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true) setControlsVisible(true, animated: true, dueToUserInteraction: true)
} else { } else {
setControlsVisible(false, animated: true) setControlsVisible(false, animated: true, dueToUserInteraction: true)
} }
centerContent() centerContent()

View File

@ -75,7 +75,7 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
container.layoutIfNeeded() container.layoutIfNeeded()
// This needs to take place after the layout, so that the transform is correct. // This needs to take place after the layout, so that the transform is correct.
itemViewController.setControlsVisible(false, animated: false) itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
let duration = self.transitionDuration(using: transitionContext) let duration = self.transitionDuration(using: transitionContext)
// rougly equivalent to duration: 0.35, bounce: 0.3 // rougly equivalent to duration: 0.35, bounce: 0.3
@ -90,7 +90,7 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
content.view.frame = destFrameInContainer content.view.frame = destFrameInContainer
content.view.layer.opacity = 1 content.view.layer.opacity = 1
itemViewController.setControlsVisible(true, animated: false) itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
if let sourceToDestTransform { if let sourceToDestTransform {
self.sourceView.transform = sourceToDestTransform self.sourceView.transform = sourceToDestTransform
@ -109,8 +109,6 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
itemViewController.addContent() itemViewController.addContent()
transitionContext.completeTransition(true) transitionContext.completeTransition(true)
to.presentationAnimationCompleted()
} }
animator.startAnimation() animator.startAnimation()
@ -121,8 +119,9 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
return return
} }
transitionContext.containerView.addSubview(to.view)
to.view.alpha = 0 to.view.alpha = 0
to.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(to.view)
let duration = transitionDuration(using: transitionContext) let duration = transitionDuration(using: transitionContext)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut) let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
@ -131,8 +130,6 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
} }
animator.addCompletion { _ in animator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled) transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
to.presentationAnimationCompleted()
} }
animator.startAnimation() animator.startAnimation()
} }

View File

@ -68,6 +68,17 @@ public class GalleryViewController: UIPageViewController {
setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false) setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false)
} }
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if animated {
// Wait until the transition is no longer in-progress, otherwise things will just get deferred again.
DispatchQueue.main.async {
self.presentationAnimationCompleted()
}
}
}
public override func viewWillDisappear(_ animated: Bool) { public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
@ -114,6 +125,8 @@ extension GalleryViewController: UIPageViewControllerDataSource {
extension GalleryViewController: UIPageViewControllerDelegate { extension GalleryViewController: UIPageViewControllerDelegate {
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
currentItemViewController.content.galleryContentWillDisappear() currentItemViewController.content.galleryContentWillDisappear()
let new = pendingViewControllers[0] as! GalleryItemViewController
new.setControlsVisible(currentItemViewController.controlsVisible, animated: false, dueToUserInteraction: false)
} }
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
@ -141,14 +154,21 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
extension GalleryViewController: UIViewControllerTransitioningDelegate { extension GalleryViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
#if os(visionOS)
return nil
#else
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) { if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
return GalleryPresentationAnimationController(sourceView: sourceView) return GalleryPresentationAnimationController(sourceView: sourceView)
} else { } else {
return nil return nil
} }
#endif
} }
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
#if os(visionOS)
return nil
#else
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) { if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
let translation: CGPoint? let translation: CGPoint?
let velocity: CGPoint? let velocity: CGPoint?
@ -164,5 +184,6 @@ extension GalleryViewController: UIViewControllerTransitioningDelegate {
} else { } else {
return nil return nil
} }
#endif
} }
} }

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.7 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "InstanceFeatures", name: "InstanceFeatures",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,9 +23,15 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "InstanceFeatures", name: "InstanceFeatures",
dependencies: ["Pachyderm"]), dependencies: ["Pachyderm"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget( .testTarget(
name: "InstanceFeaturesTests", name: "InstanceFeaturesTests",
dependencies: ["InstanceFeatures"]), dependencies: ["InstanceFeatures"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -157,7 +157,7 @@ public final class InstanceFeatures: ObservableObject {
} }
public var needsEditAttachmentsInSeparateRequest: Bool { public var needsEditAttachmentsInSeparateRequest: Bool {
instanceType.isPleroma(.akkoma(nil)) instanceType.isPleroma
} }
public var composeDirectStatuses: Bool { public var composeDirectStatuses: Bool {
@ -184,6 +184,51 @@ public final class InstanceFeatures: ObservableObject {
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil)) hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
} }
public var pushNotificationTypeStatus: Bool {
hasMastodonVersion(3, 3, 0)
}
public var pushNotificationTypeFollowRequest: Bool {
hasMastodonVersion(3, 1, 0)
}
public var pushNotificationTypeUpdate: Bool {
hasMastodonVersion(3, 5, 0)
}
public var pushNotificationPolicy: Bool {
hasMastodonVersion(3, 5, 0)
}
public var pushNotificationPolicyMissingFromResponse: Bool {
switch instanceType {
case .mastodon(_, let version):
return version >= Version(3, 5, 0) && version < Version(4, 1, 0)
default:
return false
}
}
public var instanceAnnouncements: Bool {
hasMastodonVersion(3, 1, 0)
}
public var emojiReactionNotifications: Bool {
instanceType.isPleroma
}
public var muteNotifications: Bool {
!instanceType.isPixelfed
}
public var blockDomains: Bool {
!instanceType.isPixelfed
}
public var hideReblogs: Bool {
!instanceType.isPixelfed
}
public init() { public init() {
} }
@ -305,6 +350,14 @@ extension InstanceFeatures {
return false return false
} }
} }
var isPixelfed: Bool {
if case .pixelfed = self {
return true
} else {
return false
}
}
} }
@_spi(InstanceType) public enum MastodonType { @_spi(InstanceType) public enum MastodonType {

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.8 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "MatchedGeometryPresentation", name: "MatchedGeometryPresentation",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, making them visible to other packages. // Products define the executables and libraries a package produces, making them visible to other packages.
@ -18,7 +18,10 @@ let package = Package(
// Targets are the basic building blocks of a package, defining a module or a test suite. // Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies. // Targets can depend on other targets in this package and products from dependencies.
.target( .target(
name: "MatchedGeometryPresentation"), name: "MatchedGeometryPresentation",
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget( // .testTarget(
// name: "MatchedGeometryPresentationTests", // name: "MatchedGeometryPresentationTests",
// dependencies: ["MatchedGeometryPresentation"]), // dependencies: ["MatchedGeometryPresentation"]),

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.6 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "Pachyderm", name: "Pachyderm",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -16,7 +16,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
// Dependencies declare other packages that this package depends on. // Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/karwa/swift-url.git", branch: "main"), .package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"),
], ],
targets: [ targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets are the basic building blocks of a package. A target can define a module or a test suite.
@ -26,9 +26,15 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "WebURL", package: "swift-url"), .product(name: "WebURL", package: "swift-url"),
.product(name: "WebURLFoundationExtras", package: "swift-url"), .product(name: "WebURLFoundationExtras", package: "swift-url"),
],
swiftSettings: [
.swiftLanguageMode(.v5)
]), ]),
.testTarget( .testTarget(
name: "PachydermTests", name: "PachydermTests",
dependencies: ["Pachyderm"]), dependencies: ["Pachyderm"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -42,7 +42,7 @@ public struct Client: Sendable {
} else if let date = iso8601.date(from: str) { } else if let date = iso8601.date(from: str) {
return date return date
} else { } else {
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format")) throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
} }
}) })
@ -204,8 +204,8 @@ public struct Client: Sendable {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials") return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
} }
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> { public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites") var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
request.range = range request.range = range
return request return request
} }
@ -341,6 +341,10 @@ public struct Client: Sendable {
} }
// MARK: - Notifications // MARK: - Notifications
public static func getNotification(id: String) -> Request<Notification> {
return Request(method: .get, path: "/api/v1/notifications/\(id)")
}
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> { public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters: var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
"types" => allowedTypes.map { $0.rawValue } "types" => allowedTypes.map { $0.rawValue }
@ -452,14 +456,13 @@ public struct Client: Sendable {
} }
// MARK: - Timelines // MARK: - Timelines
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> { public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
return timeline.request(range: range) return timeline.request(range: range)
} }
// MARK: - Bookmarks // MARK: - Bookmarks
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> { public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks") var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
request.range = range request.range = range
return request return request
} }
@ -487,7 +490,7 @@ public struct Client: Sendable {
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters) return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
} }
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> { public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
var parameters: [Parameter] = [] var parameters: [Parameter] = []
if let limit { if let limit {
parameters.append("limit" => limit) parameters.append("limit" => limit)

View File

@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable, Sendable {
self.displayName = try container.decode(String.self, forKey: .displayName) self.displayName = try container.decode(String.self, forKey: .displayName)
self.locked = try container.decode(Bool.self, forKey: .locked) self.locked = try container.decode(Bool.self, forKey: .locked)
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.followersCount = try container.decode(Int.self, forKey: .followersCount) // some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
self.followingCount = try container.decode(Int.self, forKey: .followingCount) self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount) self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
self.note = try container.decode(String.self, forKey: .note) self.note = try container.decode(String.self, forKey: .note)
self.url = try container.decode(URL.self, forKey: .url) self.url = try container.decode(URL.self, forKey: .url)
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
return request return request
} }
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> { public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [ var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
"only_media" => onlyMedia, "only_media" => onlyMedia,
"pinned" => pinned, "pinned" => pinned,
"exclude_replies" => excludeReplies, "exclude_replies" => excludeReplies,

View File

@ -0,0 +1,99 @@
//
// Announcement.swift
// Pachyderm
//
// Created by Shadowfacts on 4/16/24.
//
import Foundation
import WebURL
public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
public let id: String
public let content: String
public let startsAt: Date?
public let endsAt: Date?
public let allDay: Bool
public let publishedAt: Date
public let updatedAt: Date
public let read: Bool?
public let mentions: [Account]
public let statuses: [Status]
public let tags: [Hashtag]
public let emojis: [Emoji]
public var reactions: [Reaction]
public static func all() -> Request<[Announcement]> {
return Request(method: .get, path: "/api/v1/announcements")
}
public static func dismiss(id: String) -> Request<Empty> {
return Request(method: .post, path: "/api/v1/announcements/\(id)/dismiss")
}
public static func react(id: String, name: String) -> Request<Empty> {
return Request(method: .put, path: "/api/v1/announcements/\(id)/reactions/\(name)")
}
public static func unreact(id: String, name: String) -> Request<Empty> {
return Request(method: .delete, path: "/api/v1/announcements/\(id)/reactions/\(name)")
}
enum CodingKeys: String, CodingKey {
case id
case content
case startsAt = "starts_at"
case endsAt = "ends_at"
case allDay = "all_day"
case publishedAt = "published_at"
case updatedAt = "updated_at"
case read
case mentions
case statuses
case tags
case emojis
case reactions
}
}
extension Announcement {
public struct Account: Decodable, Sendable, Hashable {
public let id: String
public let username: String
public let url: WebURL
public let acct: String
}
}
extension Announcement {
public struct Status: Decodable, Sendable, Hashable {
public let id: String
public let url: WebURL
}
}
extension Announcement {
public struct Reaction: Decodable, Sendable, Hashable {
public let name: String
public var count: Int
public var me: Bool?
public let url: URL?
public let staticURL: URL?
public init(name: String, count: Int, me: Bool?, url: URL?, staticURL: URL?) {
self.name = name
self.count = count
self.me = me
self.url = url
self.staticURL = staticURL
}
enum CodingKeys: String, CodingKey {
case name
case count
case me
case url
case staticURL = "static_url"
}
}
}

View File

@ -25,6 +25,17 @@ public struct Attachment: Codable, Sendable {
], nil)) ], nil))
} }
public init(id: String, kind: Attachment.Kind, url: URL, remoteURL: URL? = nil, previewURL: URL? = nil, meta: Attachment.Metadata? = nil, description: String? = nil, blurHash: String? = nil) {
self.id = id
self.kind = kind
self.url = url
self.remoteURL = remoteURL
self.previewURL = previewURL
self.meta = meta
self.description = description
self.blurHash = blurHash
}
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)

View File

@ -26,6 +26,38 @@ public struct Card: Codable, Sendable {
/// Only present when returned from the trending links endpoint /// Only present when returned from the trending links endpoint
public let history: [History]? public let history: [History]?
public init(
url: WebURL,
title: String,
description: String,
image: WebURL? = nil,
kind: Card.Kind,
authorName: String? = nil,
authorURL: WebURL? = nil,
providerName: String? = nil,
providerURL: WebURL? = nil,
html: String? = nil,
width: Int? = nil,
height: Int? = nil,
blurhash: String? = nil,
history: [History]? = nil
) {
self.url = url
self.title = title
self.description = description
self.image = image
self.kind = kind
self.authorName = authorName
self.authorURL = authorURL
self.providerName = providerName
self.providerURL = providerURL
self.html = html
self.width = width
self.height = height
self.blurhash = blurhash
self.history = history
}
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)

View File

@ -43,8 +43,13 @@ extension Emoji: CustomDebugStringConvertible {
} }
} }
extension Emoji: Equatable { extension Emoji: Equatable, Hashable {
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool { public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
} }
public func hash(into hasher: inout Hasher) {
hasher.combine(shortcode)
hasher.combine(url)
}
} }

View File

@ -53,7 +53,7 @@ extension InstanceV2 {
public struct Thumbnail: Decodable, Sendable { public struct Thumbnail: Decodable, Sendable {
public let url: String public let url: String
public let blurhash: String? public let blurhash: String?
public let versions: ThumbnailVersions public let versions: ThumbnailVersions?
} }
public struct ThumbnailVersions: Decodable, Sendable { public struct ThumbnailVersions: Decodable, Sendable {
@ -120,6 +120,6 @@ extension InstanceV2 {
extension InstanceV2 { extension InstanceV2 {
public struct Contact: Decodable, Sendable { public struct Contact: Decodable, Sendable {
public let email: String public let email: String
public let account: Account public let account: Account?
} }
} }

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
public struct Notification: Decodable, Sendable { public struct Notification: Decodable, Sendable {
public let id: String public let id: String
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
public let createdAt: Date public let createdAt: Date
public let account: Account public let account: Account
public let status: Status? public let status: Status?
// Only present for pleroma emoji reactions
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
public let emoji: String?
public let emojiURL: WebURL?
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
@ -27,6 +32,8 @@ public struct Notification: Decodable, Sendable {
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.account = try container.decode(Account.self, forKey: .account) self.account = try container.decode(Account.self, forKey: .account)
self.status = try container.decodeIfPresent(Status.self, forKey: .status) self.status = try container.decodeIfPresent(Status.self, forKey: .status)
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
} }
public static func dismiss(id notificationID: String) -> Request<Empty> { public static func dismiss(id notificationID: String) -> Request<Empty> {
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
case createdAt = "created_at" case createdAt = "created_at"
case account case account
case status case status
case emoji
case emojiURL = "emoji_url"
} }
} }
@ -52,6 +61,7 @@ extension Notification {
case poll case poll
case update case update
case status case status
case emojiReaction = "pleroma:emoji_reaction"
case unknown case unknown
} }
} }

View File

@ -16,7 +16,7 @@ public struct Poll: Codable, Sendable {
public let votesCount: Int public let votesCount: Int
public let votersCount: Int? public let votersCount: Int?
public let voted: Bool? public let voted: Bool?
public let ownVotes: [Int]? public let ownVotes: [Int?]?
public let options: [Option] public let options: [Option]
public let emojis: [Emoji] public let emojis: [Emoji]
@ -33,7 +33,7 @@ public struct Poll: Codable, Sendable {
self.votesCount = try container.decode(Int.self, forKey: .votesCount) self.votesCount = try container.decode(Int.self, forKey: .votesCount)
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount) self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted) self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
self.ownVotes = try container.decodeIfPresent([Int].self, forKey: .ownVotes) self.ownVotes = try container.decodeIfPresent([Int?].self, forKey: .ownVotes)
self.options = try container.decode([Poll.Option].self, forKey: .options) self.options = try container.decode([Poll.Option].self, forKey: .options)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? [] self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
} }

View File

@ -0,0 +1,46 @@
//
// PushNotification.swift
// Pachyderm
//
// Created by Shadowfacts on 4/9/24.
//
import Foundation
import WebURL
public struct PushNotification: Decodable {
public let accessToken: String
public let preferredLocale: String
public let notificationID: String
public let notificationType: Notification.Kind
public let icon: WebURL
public let title: String
public let body: String
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.accessToken = try container.decode(String.self, forKey: .accessToken)
self.preferredLocale = try container.decode(String.self, forKey: .preferredLocale)
// this should be a string, but mastodon encodes it as a json number
if let s = try? container.decode(String.self, forKey: .notificationID) {
self.notificationID = s
} else {
let i = try container.decode(Int.self, forKey: .notificationID)
self.notificationID = i.description
}
self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType)
self.icon = try container.decode(WebURL.self, forKey: .icon)
self.title = try container.decode(String.self, forKey: .title)
self.body = try container.decode(String.self, forKey: .body)
}
private enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case preferredLocale = "preferred_locale"
case notificationID = "notification_id"
case notificationType = "notification_type"
case icon
case title
case body
}
}

View File

@ -9,16 +9,144 @@
import Foundation import Foundation
public struct PushSubscription: Decodable, Sendable { public struct PushSubscription: Decodable, Sendable {
public let id: String public var id: String
public let endpoint: URL public var endpoint: URL
public let serverKey: String public var serverKey: String
// TODO: WTF is this? public var alerts: Alerts
// public let alerts public var policy: Policy
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// id is documented as being a string, but mastodon returns a json number
if let s = try? container.decode(String.self, forKey: .id) {
self.id = s
} else {
let i = try container.decode(Int.self, forKey: .id)
self.id = i.description
}
self.endpoint = try container.decode(URL.self, forKey: .endpoint)
self.serverKey = try container.decode(String.self, forKey: .serverKey)
self.alerts = try container.decode(PushSubscription.Alerts.self, forKey: .alerts)
// added in mastodon 4.1.0
self.policy = try container.decodeIfPresent(PushSubscription.Policy.self, forKey: .policy) ?? .all
}
public static func create(endpoint: URL, publicKey: Data, authSecret: Data, alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
return Request(method: .post, path: "/api/v1/push/subscription", body: ParametersBody([
"subscription[endpoint]" => endpoint.absoluteString,
"subscription[keys][p256dh]" => publicKey.base64EncodedString(),
"subscription[keys][auth]" => authSecret.base64EncodedString(),
"data[alerts][mention]" => alerts.mention,
"data[alerts][status]" => alerts.status,
"data[alerts][reblog]" => alerts.reblog,
"data[alerts][follow]" => alerts.follow,
"data[alerts][follow_request]" => alerts.followRequest,
"data[alerts][favourite]" => alerts.favourite,
"data[alerts][poll]" => alerts.poll,
"data[alerts][update]" => alerts.update,
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
"data[policy]" => policy.rawValue,
]))
}
public static func update(alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
return Request(method: .put, path: "/api/v1/push/subscription", body: ParametersBody([
"data[alerts][mention]" => alerts.mention,
"data[alerts][status]" => alerts.status,
"data[alerts][reblog]" => alerts.reblog,
"data[alerts][follow]" => alerts.follow,
"data[alerts][follow_request]" => alerts.followRequest,
"data[alerts][favourite]" => alerts.favourite,
"data[alerts][poll]" => alerts.poll,
"data[alerts][update]" => alerts.update,
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
"data[policy]" => policy.rawValue,
]))
}
public static func delete() -> Request<Empty> {
return Request(method: .delete, path: "/api/v1/push/subscription")
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case endpoint case endpoint
case serverKey = "server_key" case serverKey = "server_key"
// case alerts case alerts
case policy
}
}
extension PushSubscription {
public struct Alerts: Decodable, Sendable {
public let mention: Bool
public let status: Bool
public let reblog: Bool
public let follow: Bool
public let followRequest: Bool
public let favourite: Bool
public let poll: Bool
public let update: Bool
public let emojiReaction: Bool
public init(
mention: Bool,
status: Bool,
reblog: Bool,
follow: Bool,
followRequest: Bool,
favourite: Bool,
poll: Bool,
update: Bool,
emojiReaction: Bool
) {
self.mention = mention
self.status = status
self.reblog = reblog
self.follow = follow
self.followRequest = followRequest
self.favourite = favourite
self.poll = poll
self.update = update
self.emojiReaction = emojiReaction
}
public init(from decoder: any Decoder) throws {
let container: KeyedDecodingContainer<PushSubscription.Alerts.CodingKeys> = try decoder.container(keyedBy: PushSubscription.Alerts.CodingKeys.self)
self.mention = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.mention)
// status added in mastodon 3.3.0
self.status = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.status) ?? false
self.reblog = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.reblog)
self.follow = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.follow)
// follow_request added in 3.1.0
self.followRequest = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.followRequest) ?? false
self.favourite = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.favourite)
self.poll = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.poll)
// update added in mastodon 3.5.0
self.update = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.update) ?? false
// pleroma/akkoma only
self.emojiReaction = try container.decodeIfPresent(Bool.self, forKey: .emojiReaction) ?? false
}
private enum CodingKeys: String, CodingKey {
case mention
case status
case reblog
case follow
case followRequest = "follow_request"
case favourite
case poll
case update
case emojiReaction = "pleroma:emoji_reaction"
}
}
}
extension PushSubscription {
public enum Policy: String, Decodable, Sendable {
case all
case followed
case followers
case none
} }
} }

View File

@ -27,10 +27,13 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
self.followedBy = try container.decode(Bool.self, forKey: .followedBy) self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
self.blocking = try container.decode(Bool.self, forKey: .blocking) self.blocking = try container.decode(Bool.self, forKey: .blocking)
self.muting = try container.decode(Bool.self, forKey: .muting) self.muting = try container.decode(Bool.self, forKey: .muting)
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications) // not supported on pixelfed
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
self.followRequested = try container.decode(Bool.self, forKey: .followRequested) self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking) // not supported on pixelfed
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs) self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false
// not supported on pixelfed
self.showingReblogs = try container.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? true
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
} }

View File

@ -12,6 +12,7 @@ public enum Scope: String, Sendable {
case read case read
case write case write
case follow case follow
case push
} }
extension Array where Element == Scope { extension Array where Element == Scope {

View File

@ -10,7 +10,7 @@ import Foundation
public struct SearchResults: Decodable, Sendable { public struct SearchResults: Decodable, Sendable {
public let accounts: [Account] public let accounts: [Account]
public let statuses: [Status] public let statuses: [TryDecode<Status>]
public let hashtags: [Hashtag] public let hashtags: [Hashtag]
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {

View File

@ -46,6 +46,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
public let localOnly: Bool? public let localOnly: Bool?
public let editedAt: Date? public let editedAt: Date?
public let pleromaExtras: PleromaExtras?
public var applicationName: String? { application?.name } public var applicationName: String? { application?.name }
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
@ -98,6 +100,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.card = try container.decodeIfPresent(Card.self, forKey: .card) self.card = try container.decodeIfPresent(Card.self, forKey: .card)
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll) self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt) self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
self.pleromaExtras = try container.decodeIfPresent(PleromaExtras.self, forKey: .pleromaExtras)
} }
public static func getContext(_ statusID: String) -> Request<ConversationContext> { public static func getContext(_ statusID: String) -> Request<ConversationContext> {
@ -120,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return request return request
} }
public static func getReactions(_ statusID: String, emoji: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reactions/\(emoji)")
request.range = range
return request
}
public static func delete(_ statusID: String) -> Request<Empty> { public static func delete(_ statusID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)") return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
} }
@ -212,7 +222,15 @@ public final class Status: StatusProtocol, Decodable, Sendable {
case poll case poll
case localOnly = "local_only" case localOnly = "local_only"
case editedAt = "edited_at" case editedAt = "edited_at"
case pleromaExtras = "pleroma"
} }
} }
extension Status: Identifiable {} extension Status: Identifiable {}
extension Status {
public struct PleromaExtras: Decodable, Sendable {
public let context: String?
}
}

View File

@ -32,8 +32,8 @@ extension Timeline {
} }
} }
func request(range: RequestRange) -> Request<[Status]> { func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint) var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
if case .public(true) = self { if case .public(true) = self {
request.queryParameters.append("local" => true) request.queryParameters.append("local" => true)
} }

View File

@ -7,26 +7,53 @@
import Foundation import Foundation
public struct TimelineMarkers: Decodable, Sendable { public struct TimelineMarkers {
public let home: Marker? private init() {}
public let notifications: Marker?
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> { public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue)) Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
} }
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> { public static func update<T: TimelineMarkerType>(timeline: T, lastReadID: String) -> Request<Empty> {
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([ Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
"\(timeline.rawValue)[last_read_id]" => lastReadID, "\(T.name)[last_read_id]" => lastReadID
])) ]))
} }
public enum Timeline: String {
case home
case notifications
} }
public struct Marker: Decodable, Sendable { public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
let payload: Payload
public var lastReadID: String {
payload.payload.lastReadID
}
public var version: Int {
payload.payload.version
}
public var updatedAt: Date {
payload.payload.updatedAt
}
public init(from decoder: any Decoder) throws {
self.payload = try Payload(from: decoder)
}
}
public protocol TimelineMarkerTypePayload: Decodable, Sendable {
var payload: MarkerPayload { get }
}
public struct HomeMarkerPayload: TimelineMarkerTypePayload {
public var home: MarkerPayload
public var payload: MarkerPayload { home }
}
public struct NotificationsMarkerPayload: TimelineMarkerTypePayload {
public var notifications: MarkerPayload
public var payload: MarkerPayload { notifications }
}
public struct MarkerPayload: Decodable, Sendable {
public let lastReadID: String public let lastReadID: String
public let version: Int public let version: Int
public let updatedAt: Date public let updatedAt: Date
@ -37,4 +64,26 @@ public struct TimelineMarkers: Decodable, Sendable {
case updatedAt = "updated_at" case updatedAt = "updated_at"
} }
} }
public protocol TimelineMarkerType {
static var name: String { get }
associatedtype Payload: TimelineMarkerTypePayload
}
extension TimelineMarkerType where Self == HomeMarker {
public static var home: Self { .init() }
}
extension TimelineMarkerType where Self == NotificationsMarker {
public static var notifications: Self { .init() }
}
public struct HomeMarker: TimelineMarkerType {
public typealias Payload = HomeMarkerPayload
public static var name: String { "home" }
}
public struct NotificationsMarker: TimelineMarkerType {
public typealias Payload = NotificationsMarkerPayload
public static var name: String { "notifications" }
} }

View File

@ -7,17 +7,18 @@
// //
import Foundation import Foundation
import WebURL
public struct NotificationGroup: Identifiable, Hashable, Sendable { public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification] public private(set) var notifications: [Notification]
public let id: String public let id: String
public let kind: Notification.Kind public let kind: Kind
public init?(notifications: [Notification]) { public init?(notifications: [Notification], kind: Kind) {
guard !notifications.isEmpty else { return nil } guard !notifications.isEmpty else { return nil }
self.notifications = notifications self.notifications = notifications
self.id = notifications.first!.id self.id = notifications.first!.id
self.kind = notifications.first!.kind self.kind = kind
} }
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool { public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
@ -44,30 +45,61 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
notifications.append(contentsOf: group.notifications) notifications.append(contentsOf: group.notifications)
} }
private static func groupKind(for notification: Notification) -> Kind {
switch notification.kind {
case .mention:
return .mention
case .reblog:
return .reblog
case .favourite:
return .favourite
case .follow:
return .follow
case .followRequest:
return .followRequest
case .poll:
return .poll
case .update:
return .update
case .status:
return .status
case .emojiReaction:
if let emoji = notification.emoji {
return .emojiReaction(emoji, notification.emojiURL)
} else {
return .unknown
}
case .unknown:
return .unknown
}
}
@MainActor @MainActor
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [NotificationGroup]() var groups = [NotificationGroup]()
for notification in notifications { for notification in notifications {
let groupKind = groupKind(for: notification)
if allowedTypes.contains(notification.kind) { if allowedTypes.contains(notification.kind) {
if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) { if let lastGroup = groups.last, canMerge(notification: notification, kind: groupKind, into: lastGroup) {
groups[groups.count - 1].append(notification) groups[groups.count - 1].append(notification)
continue continue
} else if groups.count >= 2 { } else if groups.count >= 2 {
let secondToLastGroup = groups[groups.count - 2] let secondToLastGroup = groups[groups.count - 2]
if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) { if allowedTypes.contains(notification.kind), canMerge(notification: notification, kind: groupKind, into: secondToLastGroup) {
groups[groups.count - 2].append(notification) groups[groups.count - 2].append(notification)
continue continue
} }
} }
} }
groups.append(NotificationGroup(notifications: [notification])!) groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
} }
return groups return groups
} }
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool { private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id return kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
} }
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
@ -82,21 +114,21 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
var second = second var second = second
merged.reserveCapacity(second.count) merged.reserveCapacity(second.count)
while let firstGroupFromSecond = second.first, while let firstGroupFromSecond = second.first,
allowedTypes.contains(firstGroupFromSecond.kind) { allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
second.removeFirst() second.removeFirst()
guard let lastGroup = merged.last, guard let lastGroup = merged.last,
allowedTypes.contains(lastGroup.kind) else { allowedTypes.contains(lastGroup.kind.notificationKind) else {
merged.append(firstGroupFromSecond) merged.append(firstGroupFromSecond)
break break
} }
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) { if canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: lastGroup) {
merged[merged.count - 1].append(group: firstGroupFromSecond) merged[merged.count - 1].append(group: firstGroupFromSecond)
} else if merged.count >= 2 { } else if merged.count >= 2 {
let secondToLastGroup = merged[merged.count - 2] let secondToLastGroup = merged[merged.count - 2]
if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) { if allowedTypes.contains(secondToLastGroup.kind.notificationKind), canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: secondToLastGroup) {
merged[merged.count - 2].append(group: firstGroupFromSecond) merged[merged.count - 2].append(group: firstGroupFromSecond)
} else { } else {
merged.append(firstGroupFromSecond) merged.append(firstGroupFromSecond)
@ -109,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
return merged return merged
} }
public enum Kind: Sendable, Equatable {
case mention
case reblog
case favourite
case follow
case followRequest
case poll
case update
case status
case emojiReaction(String, WebURL?)
case unknown
var notificationKind: Notification.Kind {
switch self {
case .mention:
.mention
case .reblog:
.reblog
case .favourite:
.favourite
case .follow:
.follow
case .followRequest:
.followRequest
case .poll:
.poll
case .update:
.update
case .status:
.status
case .emojiReaction(_, _):
.emojiReaction
case .unknown:
.unknown
}
}
}
} }

View File

@ -0,0 +1,32 @@
//
// TryDecode.swift
// Pachyderm
//
// Created by Shadowfacts on 6/8/24.
//
import Foundation
public enum TryDecode<T: Decodable>: Decodable {
case error(String)
case value(T)
public init(from decoder: any Decoder) throws {
do {
self = .value(try T(from: decoder))
} catch {
self = .error(error.localizedDescription)
}
}
public var value: T? {
if case .value(let value) = self {
value
} else {
nil
}
}
}
extension TryDecode: Sendable where T: Sendable {
}

8
Packages/PushNotifications/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PushNotifications"
BuildableName = "PushNotifications"
BlueprintName = "PushNotifications"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PushNotifications"
BuildableName = "PushNotifications"
BlueprintName = "PushNotifications"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,39 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "PushNotifications",
platforms: [
.iOS(.v16),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "PushNotifications",
targets: ["PushNotifications"]),
],
dependencies: [
.package(path: "../UserAccounts"),
.package(path: "../Pachyderm"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "PushNotifications",
dependencies: ["UserAccounts", "Pachyderm"],
swiftSettings: [
.swiftLanguageMode(.v5)
]
),
.testTarget(
name: "PushNotificationsTests",
dependencies: ["PushNotifications"],
swiftSettings: [
.swiftLanguageMode(.v5)
]
),
]
)

View File

@ -0,0 +1,47 @@
//
// DisabledPushManager.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import Foundation
import UserAccounts
class DisabledPushManager: _PushManager {
var enabled: Bool {
false
}
var subscriptions: [PushSubscription] {
[]
}
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
throw Disabled()
}
func removeSubscription(account: UserAccountInfo) {
}
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
}
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
nil
}
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
}
func didRegisterForRemoteNotifications(deviceToken: Data) {
}
func didFailToRegisterForRemoteNotifications(error: any Error) {
}
struct Disabled: LocalizedError {
var errorDescription: String? {
"Push notifications disabled"
}
}
}

View File

@ -0,0 +1,55 @@
//
// PushManager.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import Foundation
import OSLog
import Pachyderm
import UserAccounts
public struct PushManager {
@MainActor
public static let shared = createPushManager()
public static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager")
@MainActor
public static var captureError: ((any Error) -> Void)?
private init() {}
@MainActor
private static func createPushManager() -> any _PushManager {
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
let host = info["PushProxyHost"] as? String,
!host.isEmpty else {
logger.debug("Missing proxy info, push disabled")
return DisabledPushManager()
}
var endpoint = URLComponents()
endpoint.scheme = "https"
endpoint.host = host
let url = endpoint.url!
logger.debug("Push notifications enabled with proxy \(url.absoluteString, privacy: .public)")
return PushManagerImpl(endpoint: url)
}
}
@MainActor
public protocol _PushManager {
var enabled: Bool { get }
var subscriptions: [PushSubscription] { get }
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription
func removeSubscription(account: UserAccountInfo)
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy)
func pushSubscription(account: UserAccountInfo) -> PushSubscription?
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async
func didRegisterForRemoteNotifications(deviceToken: Data)
func didFailToRegisterForRemoteNotifications(error: any Error)
}

View File

@ -0,0 +1,203 @@
//
// PushManagerImpl.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import UIKit
import UserAccounts
import CryptoKit
class PushManagerImpl: _PushManager {
private let endpoint: URL
var enabled: Bool {
true
}
private var apnsEnvironment: String {
#if DEBUG
"development"
#else
"production"
#endif
}
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
public private(set) var subscriptions: [PushSubscription] {
get {
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
return array.compactMap(PushSubscription.init(defaultsDict:))
} else {
return []
}
}
set {
defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions")
}
}
init(endpoint: URL) {
self.endpoint = endpoint
}
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
if let existing = pushSubscription(account: account) {
return existing
}
let key = P256.KeyAgreement.PrivateKey()
var authSecret = Data(count: 16)
let res = authSecret.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
}
guard res == errSecSuccess else {
throw CreateSubscriptionError.generatingAuthSecret(res)
}
let token = try await getDeviceToken()
let subscription = PushSubscription(
accountID: account.id,
endpoint: endpointURL(deviceToken: token, accountID: account.id),
secretKey: key,
authSecret: authSecret,
alerts: [],
policy: .all
)
subscriptions.append(subscription)
return subscription
}
private func endpointURL(deviceToken: Data, accountID: String) -> URL {
var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)"
return endpoint.url!
}
func removeSubscription(account: UserAccountInfo) {
subscriptions.removeAll { $0.accountID == account.id }
}
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
guard let index = subscriptions.firstIndex(where: { $0.accountID == account.id }) else {
return
}
var copy = subscriptions[index]
copy.alerts = alerts
copy.policy = policy
subscriptions[index] = copy
}
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
subscriptions.first { $0.accountID == account.id }
}
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
let subscriptions = self.subscriptions
guard !subscriptions.isEmpty else {
return
}
do {
let token = try await getDeviceToken()
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
guard newEndpoint != $0.endpoint else {
PushManager.logger.debug("Skipping update of push subscription with endpoint \($0.endpoint, privacy: .public)")
return $0
}
var copy = $0
copy.endpoint = newEndpoint
if await updateSubscription(copy) {
return copy
} else {
return $0
}
}.reduce(into: [], { partialResult, el in
partialResult.append(el)
})
} catch {
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
PushManager.captureError?(error)
}
}
private func getDeviceToken() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
remoteNotificationsRegistrationContinuation = continuation
UIApplication.shared.registerForRemoteNotifications()
}
}
func didRegisterForRemoteNotifications(deviceToken: Data) {
remoteNotificationsRegistrationContinuation?.resume(returning: deviceToken)
remoteNotificationsRegistrationContinuation = nil
}
func didFailToRegisterForRemoteNotifications(error: any Error) {
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
remoteNotificationsRegistrationContinuation = nil
}
}
enum PushRegistrationError: LocalizedError {
case alreadyRegistering
case registeringForRemoteNotifications(any Error)
var errorDescription: String? {
switch self {
case .alreadyRegistering:
"Already registering"
case .registeringForRemoteNotifications(let error):
"Remote notifications: \(error.localizedDescription)"
}
}
}
enum CreateSubscriptionError: LocalizedError {
case generatingAuthSecret(OSStatus)
var errorDescription: String? {
switch self {
case .generatingAuthSecret(let code):
"Generating auth secret: \(code)"
}
}
}
private extension Data {
func hexEncodedString() -> String {
String(unsafeUninitializedCapacity: count * 2) { buffer in
let chars = Array("0123456789ABCDEF".utf8)
for (i, x) in enumerated() {
let (upper, lower) = x.quotientAndRemainder(dividingBy: 16)
buffer[i * 2] = chars[Int(upper)]
buffer[i * 2 + 1] = chars[Int(lower)]
}
return count * 2
}
}
}
private struct AsyncSequenceAdaptor<S: Sequence>: AsyncSequence {
typealias Element = S.Element
let base: S
init(wrapping base: S) {
self.base = base
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(base: base.makeIterator())
}
struct AsyncIterator: AsyncIteratorProtocol {
var base: S.Iterator
mutating func next() async -> Element? {
base.next()
}
}
}

View File

@ -0,0 +1,81 @@
//
// PushSubscription.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import Foundation
import CryptoKit
public struct PushSubscription {
public let accountID: String
public internal(set) var endpoint: URL
public let secretKey: P256.KeyAgreement.PrivateKey
public let authSecret: Data
public var alerts: Alerts
public var policy: Policy
var defaultsDict: [String: Any] {
[
"accountID": accountID,
"endpoint": endpoint.absoluteString,
"secretKey": secretKey.rawRepresentation,
"authSecret": authSecret,
"alerts": alerts.rawValue,
"policy": policy.rawValue
]
}
init?(defaultsDict: [String: Any]) {
guard let accountID = defaultsDict["accountID"] as? String,
let endpoint = (defaultsDict["endpoint"] as? String).flatMap(URL.init(string:)),
let secretKey = (defaultsDict["secretKey"] as? Data).flatMap({ try? P256.KeyAgreement.PrivateKey(rawRepresentation: $0) }),
let authSecret = defaultsDict["authSecret"] as? Data,
let alerts = defaultsDict["alerts"] as? Int,
let policy = (defaultsDict["policy"] as? String).flatMap(Policy.init(rawValue:)) else {
return nil
}
self.accountID = accountID
self.endpoint = endpoint
self.secretKey = secretKey
self.authSecret = authSecret
self.alerts = Alerts(rawValue: alerts)
self.policy = policy
}
init(accountID: String, endpoint: URL, secretKey: P256.KeyAgreement.PrivateKey, authSecret: Data, alerts: Alerts, policy: Policy) {
self.accountID = accountID
self.endpoint = endpoint
self.secretKey = secretKey
self.authSecret = authSecret
self.alerts = alerts
self.policy = policy
}
public enum Policy: String, CaseIterable, Identifiable, Sendable {
case all, followed, followers
public var id: some Hashable {
self
}
}
public struct Alerts: OptionSet, Hashable, Sendable {
public static let mention = Alerts(rawValue: 1 << 0)
public static let status = Alerts(rawValue: 1 << 1)
public static let reblog = Alerts(rawValue: 1 << 2)
public static let follow = Alerts(rawValue: 1 << 3)
public static let followRequest = Alerts(rawValue: 1 << 4)
public static let favorite = Alerts(rawValue: 1 << 5)
public static let poll = Alerts(rawValue: 1 << 6)
public static let update = Alerts(rawValue: 1 << 7)
public static let emojiReaction = Alerts(rawValue: 1 << 8)
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
}
}

View File

@ -0,0 +1,12 @@
import XCTest
@testable import PushNotifications
final class PushNotificationsTests: XCTestCase {
func testExample() throws {
// XCTest Documentation
// https://developer.apple.com/documentation/xctest
// Defining Test Cases and Test Methods
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
}
}

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.7 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "TTTKit", name: "TTTKit",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,9 +23,15 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "TTTKit", name: "TTTKit",
dependencies: []), dependencies: [],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
.testTarget( .testTarget(
name: "TTTKitTests", name: "TTTKitTests",
dependencies: ["TTTKit"]), dependencies: ["TTTKit"],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
] ]
) )

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.7 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "TuskerComponents", name: "TuskerComponents",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.
@ -23,7 +23,10 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on. // Targets can depend on other targets in this package, and on products in packages this package depends on.
.target( .target(
name: "TuskerComponents", name: "TuskerComponents",
dependencies: []), dependencies: [],
swiftSettings: [
.swiftLanguageMode(.v5)
]),
// .testTarget( // .testTarget(
// name: "TuskerComponentsTests", // name: "TuskerComponentsTests",
// dependencies: ["TuskerComponents"]), // dependencies: ["TuskerComponents"]),

View File

@ -0,0 +1,70 @@
//
// AsyncPicker.swift
// TuskerComponents
//
// Created by Shadowfacts on 4/9/24.
//
import SwiftUI
public struct AsyncPicker<V: Hashable, Content: View>: View {
let titleKey: LocalizedStringKey
let alignment: Alignment
@Binding var value: V
let onChange: (V) async -> Bool
let content: Content
@State private var isLoading = false
public init(_ titleKey: LocalizedStringKey, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
self.titleKey = titleKey
self.alignment = alignment
self._value = value
self.onChange = onChange
self.content = content()
}
public var body: some View {
LabeledContent(titleKey) {
picker
}
}
private var picker: some View {
ZStack(alignment: alignment) {
Picker(titleKey, selection: Binding(get: {
value
}, set: { newValue in
let oldValue = value
value = newValue
isLoading = true
Task {
let operationCompleted = await onChange(newValue)
if !operationCompleted {
value = oldValue
}
isLoading = false
}
})) {
content
}
.labelsHidden()
.opacity(isLoading ? 0 : 1)
if isLoading {
ProgressView()
}
}
}
}
#Preview {
@State var value = 0
return AsyncPicker("", value: $value) { _ in
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
return true
} content: {
ForEach(0..<10) {
Text("\($0)").tag($0)
}
}
}

View File

@ -0,0 +1,66 @@
//
// AsyncToggle.swift
// TuskerComponents
//
// Created by Shadowfacts on 4/7/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import SwiftUI
public struct AsyncToggle: View {
let titleKey: LocalizedStringKey
@Binding var mode: Mode
let onChange: (Bool) async -> Bool
public init(_ titleKey: LocalizedStringKey, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
self.titleKey = titleKey
self._mode = mode
self.onChange = onChange
}
public var body: some View {
LabeledContent(titleKey) {
toggleOrSpinner
}
}
@ViewBuilder
private var toggleOrSpinner: some View {
ZStack {
Toggle(titleKey, isOn: Binding {
mode == .on
} set: { newValue in
mode = .loading
Task {
let operationCompleted = await onChange(newValue)
if operationCompleted {
mode = newValue ? .on : .off
} else {
mode = newValue ? .off : .on
}
}
})
.labelsHidden()
.opacity(mode == .loading ? 0 : 1)
if mode == .loading {
ProgressView()
}
}
}
public enum Mode {
case off
case loading
case on
}
}
#Preview {
@State var mode = AsyncToggle.Mode.on
return AsyncToggle("", mode: $mode) { _ in
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
return true
}
}

View File

@ -1,6 +1,6 @@
// //
// FuzzyMatcher.swift // FuzzyMatcher.swift
// ComposeUI // TuskerComponents
// //
// Created by Shadowfacts on 10/10/20. // Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved. // Copyright © 2020 Shadowfacts. All rights reserved.
@ -8,7 +8,7 @@
import Foundation import Foundation
struct FuzzyMatcher { public struct FuzzyMatcher {
private init() {} private init() {}
@ -21,7 +21,7 @@ struct FuzzyMatcher {
/// +2 points for every char in `pattern` that occurs in `str` sequentially /// +2 points for every char in `pattern` that occurs in `str` sequentially
/// -2 points for every char in `pattern` that does not occur in `str` sequentially /// -2 points for every char in `pattern` that does not occur in `str` sequentially
/// -1 point for every char in `str` skipped between matching chars from the `pattern` /// -1 point for every char in `str` skipped between matching chars from the `pattern`
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) { public static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
let pattern = pattern.lowercased() let pattern = pattern.lowercased()
let str = str.lowercased() let str = str.lowercased()

View File

@ -47,9 +47,7 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
private func makeConfiguration() -> UIButton.Configuration { private func makeConfiguration() -> UIButton.Configuration {
var config = UIButton.Configuration.borderless() var config = UIButton.Configuration.borderless()
if #available(iOS 16.0, *) {
config.indicator = .popup config.indicator = .popup
}
if buttonStyle.hasIcon { if buttonStyle.hasIcon {
config.image = selectedOption.image config.image = selectedOption.image
} }

View File

@ -0,0 +1,23 @@
{
"pins" : [
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
"version" : "1.2.1"
}
},
{
"identity" : "swift-url",
"kind" : "remoteSourceControl",
"location" : "https://github.com/karwa/swift-url.git",
"state" : {
"branch" : "main",
"revision" : "01ad5a103d14839a68c55ee556513e5939008e9e"
}
}
],
"version" : 2
}

View File

@ -1,4 +1,4 @@
// swift-tools-version: 5.8 // swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package. // The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription import PackageDescription
@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "TuskerPreferences", name: "TuskerPreferences",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v16),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, making them visible to other packages. // Products define the executables and libraries a package produces, making them visible to other packages.
@ -22,7 +22,17 @@ let package = Package(
// Targets can depend on other targets in this package and products from dependencies. // Targets can depend on other targets in this package and products from dependencies.
.target( .target(
name: "TuskerPreferences", name: "TuskerPreferences",
dependencies: ["Pachyderm"] dependencies: ["Pachyderm"],
swiftSettings: [
.swiftLanguageMode(.v5)
]
), ),
.testTarget(
name: "TuskerPreferencesTests",
dependencies: ["TuskerPreferences"],
swiftSettings: [
.swiftLanguageMode(.v5)
]
)
] ]
) )

View File

@ -0,0 +1,282 @@
//
// Coding.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
private protocol PreferenceProtocol {
associatedtype Key: PreferenceKey
var storedValue: Key.Value? { get }
init()
}
extension Preference: PreferenceProtocol {
}
struct PreferenceCoding<Wrapped: Codable>: Codable {
let wrapped: Wrapped
init(wrapped: Wrapped) {
self.wrapped = wrapped
}
init(from decoder: any Decoder) throws {
self.wrapped = try Wrapped(from: PreferenceDecoder(wrapped: decoder))
}
func encode(to encoder: any Encoder) throws {
try wrapped.encode(to: PreferenceEncoder(wrapped: encoder))
}
}
private struct PreferenceDecoder: Decoder {
let wrapped: any Decoder
var codingPath: [any CodingKey] {
wrapped.codingPath
}
var userInfo: [CodingUserInfoKey : Any] {
wrapped.userInfo
}
func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key : CodingKey {
KeyedDecodingContainer(PreferenceDecodingContainer(wrapped: try wrapped.container(keyedBy: type)))
}
func unkeyedContainer() throws -> any UnkeyedDecodingContainer {
throw Error.onlyKeyedContainerSupported
}
func singleValueContainer() throws -> any SingleValueDecodingContainer {
throw Error.onlyKeyedContainerSupported
}
enum Error: Swift.Error {
case onlyKeyedContainerSupported
}
}
private struct PreferenceDecodingContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
let wrapped: KeyedDecodingContainer<Key>
var codingPath: [any CodingKey] {
wrapped.codingPath
}
var allKeys: [Key] {
wrapped.allKeys
}
func contains(_ key: Key) -> Bool {
wrapped.contains(key)
}
func decodeNil(forKey key: Key) throws -> Bool {
try wrapped.decodeNil(forKey: key)
}
func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: String.Type, forKey key: Key) throws -> String {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
try wrapped.decode(type, forKey: key)
}
func decode<T>(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable {
if let type = type as? any PreferenceProtocol.Type,
!contains(key) {
func makePreference<P: PreferenceProtocol>(_: P.Type) -> T {
P() as! T
}
return _openExistential(type, do: makePreference)
}
return try wrapped.decode(type, forKey: key)
}
func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> where NestedKey : CodingKey {
try wrapped.nestedContainer(keyedBy: type, forKey: key)
}
func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer {
try wrapped.nestedUnkeyedContainer(forKey: key)
}
func superDecoder() throws -> any Decoder {
try wrapped.superDecoder()
}
func superDecoder(forKey key: Key) throws -> any Decoder {
try wrapped.superDecoder(forKey: key)
}
}
private struct PreferenceEncoder: Encoder {
let wrapped: any Encoder
var codingPath: [any CodingKey] {
wrapped.codingPath
}
var userInfo: [CodingUserInfoKey : Any] {
wrapped.userInfo
}
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
KeyedEncodingContainer(PreferenceEncodingContainer(wrapped: wrapped.container(keyedBy: type)))
}
func unkeyedContainer() -> any UnkeyedEncodingContainer {
fatalError("Only keyed containers supported")
}
func singleValueContainer() -> any SingleValueEncodingContainer {
fatalError("Only keyed containers supported")
}
}
private struct PreferenceEncodingContainer<Key: CodingKey>: KeyedEncodingContainerProtocol {
var wrapped: KeyedEncodingContainer<Key>
var codingPath: [any CodingKey] {
wrapped.codingPath
}
mutating func encodeNil(forKey key: Key) throws {
try wrapped.encodeNil(forKey: key)
}
mutating func encode(_ value: Bool, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: String, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Double, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Float, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int8, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int16, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int32, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int64, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt8, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt16, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt32, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt64, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode<T>(_ value: T, forKey key: Key) throws where T : Encodable {
if let value = value as? any PreferenceProtocol,
value.storedValue == nil {
return
}
try wrapped.encode(value, forKey: key)
}
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
wrapped.nestedContainer(keyedBy: keyType, forKey: key)
}
mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer {
wrapped.nestedUnkeyedContainer(forKey: key)
}
mutating func superEncoder() -> any Encoder {
wrapped.superEncoder()
}
mutating func superEncoder(forKey key: Key) -> any Encoder {
wrapped.superEncoder(forKey: key)
}
}

View File

@ -0,0 +1,28 @@
//
// AdvancedKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import Pachyderm
struct StatusContentTypeKey: MigratablePreferenceKey {
static var defaultValue: StatusContentType { .plain }
}
struct FeatureFlagsKey: MigratablePreferenceKey, CustomCodablePreferenceKey {
static var defaultValue: Set<FeatureFlag> { [] }
static func encode(value: Set<FeatureFlag>, to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value.map(\.rawValue))
}
static func decode(from decoder: any Decoder) throws -> Set<FeatureFlag>? {
let container = try decoder.singleValueContainer()
let names = try container.decode([String].self)
return Set(names.compactMap(FeatureFlag.init(rawValue:)))
}
}

View File

@ -0,0 +1,49 @@
//
// AppearanceKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import UIKit
public struct ThemeKey: MigratablePreferenceKey {
public static var defaultValue: Theme { .unspecified }
}
public struct AccentColorKey: MigratablePreferenceKey {
public static var defaultValue: AccentColor { .default }
}
struct AvatarStyleKey: MigratablePreferenceKey {
static var defaultValue: AvatarStyle { .roundRect }
}
struct LeadingSwipeActionsKey: MigratablePreferenceKey {
static var defaultValue: [StatusSwipeAction] { [.favorite, .reblog] }
}
struct TrailingSwipeActionsKey: MigratablePreferenceKey {
static var defaultValue: [StatusSwipeAction] { [.reply, .share] }
}
public struct WidescreenNavigationModeKey: MigratablePreferenceKey {
public static var defaultValue: WidescreenNavigationMode { .multiColumn }
public static func shouldMigrate(oldValue: WidescreenNavigationMode) -> Bool {
oldValue != .splitScreen
}
}
struct AttachmentBlurModeKey: MigratablePreferenceKey {
static var defaultValue: AttachmentBlurMode { .useStatusSetting }
static func didSet(in store: PreferenceStore, newValue: AttachmentBlurMode) {
if newValue == .always {
store.blurMediaBehindContentWarning = true
} else if newValue == .never {
store.blurMediaBehindContentWarning = false
}
}
}

View File

@ -0,0 +1,40 @@
//
// BehaviorKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
struct OppositeCollapseKeywordsKey: MigratablePreferenceKey {
static var defaultValue: [String] { [] }
}
struct ConfirmReblogKey: MigratablePreferenceKey {
static var defaultValue: Bool {
#if os(visionOS)
true
#else
false
#endif
}
}
struct TimelineSyncModeKey: MigratablePreferenceKey {
static var defaultValue: TimelineSyncMode { .icloud }
}
struct InAppSafariKey: MigratablePreferenceKey {
static var defaultValue: Bool {
#if targetEnvironment(macCatalyst) || os(visionOS)
false
#else
if ProcessInfo.processInfo.isiOSAppOnMac {
false
} else {
true
}
#endif
}
}

View File

@ -0,0 +1,16 @@
//
// CommonKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
public struct TrueKey: MigratablePreferenceKey {
public static var defaultValue: Bool { true }
}
public struct FalseKey: MigratablePreferenceKey {
public static var defaultValue: Bool { false }
}

View File

@ -0,0 +1,20 @@
//
// ComposingKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
struct PostVisibilityKey: MigratablePreferenceKey {
static var defaultValue: PostVisibility { .serverDefault }
}
struct ReplyVisibilityKey: MigratablePreferenceKey {
static var defaultValue: ReplyVisibility { .sameAsPost }
}
struct ContentWarningCopyModeKey: MigratablePreferenceKey {
static var defaultValue: ContentWarningCopyMode { .asIs }
}

View File

@ -0,0 +1,12 @@
//
// DigitalWellnessKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
struct NotificationsModeKey: MigratablePreferenceKey {
static var defaultValue: NotificationsMode { .allNotifications }
}

View File

@ -0,0 +1,205 @@
//
// LegacyPreferences.swift
// TuskerPreferences
//
// Created by Shadowfacts on 8/28/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
public final class LegacyPreferences: Decodable {
init() {}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
self.defaultPostVisibility = .visibility(existing)
} else {
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
}
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
} else {
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
}
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
}
// MARK: Appearance
@Published public var theme = UIUserInterfaceStyle.unspecified
@Published public var pureBlackDarkMode = true
@Published public var accentColor = AccentColor.default
@Published public var avatarStyle = AvatarStyle.roundRect
@Published public var hideCustomEmojiInUsernames = false
@Published public var showIsStatusReplyIcon = false
@Published public var alwaysShowStatusVisibilityIcon = false
@Published public var hideActionsInTimeline = false
@Published public var showLinkPreviews = true
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = LegacyPreferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing
@Published public var defaultPostVisibility = PostVisibility.serverDefault
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published public var requireAttachmentDescriptions = false
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published public var mentionReblogger = false
@Published public var useTwitterKeyboard = false
// MARK: Media
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting
@Published public var blurMediaBehindContentWarning = true
@Published public var automaticallyPlayGifs = true
@Published public var showUncroppedMediaInline = true
@Published public var showAttachmentBadges = true
@Published public var attachmentAltBadgeInverted = false
// MARK: Behavior
@Published public var openLinksInApps = true
@Published public var useInAppSafari = true
@Published public var inAppSafariAutomaticReaderMode = false
@Published public var expandAllContentWarnings = false
@Published public var collapseLongPosts = true
@Published public var oppositeCollapseKeywords: [String] = []
@Published public var confirmBeforeReblog = false
@Published public var timelineStateRestoration = true
@Published public var timelineSyncMode = TimelineSyncMode.icloud
@Published public var hideReblogsInTimelines = false
@Published public var hideRepliesInTimelines = false
// MARK: Digital Wellness
@Published public var showFavoriteAndReblogCounts = true
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
@Published public var grayscaleImages = false
@Published public var disableInfiniteScrolling = false
@Published public var hideTrends = false
// MARK: Advanced
@Published public var statusContentType: StatusContentType = .plain
@Published public var reportErrorsAutomatically = true
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
// MARK:
@Published public var hasShownLocalTimelineDescription = false
@Published public var hasShownFederatedTimelineDescription = false
private enum CodingKeys: String, CodingKey {
case theme
case pureBlackDarkMode
case accentColor
case avatarStyle
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case hideActionsInTimeline
case showLinkPreviews
case leadingStatusSwipeActions
case trailingStatusSwipeActions
case widescreenNavigationMode
case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility
case defaultReplyVisibility
case requireAttachmentDescriptions
case contentWarningCopyMode
case mentionReblogger
case useTwitterKeyboard
case blurAllMedia // only used for migration
case attachmentBlurMode
case blurMediaBehindContentWarning
case automaticallyPlayGifs
case showUncroppedMediaInline
case showAttachmentBadges
case attachmentAltBadgeInverted
case openLinksInApps
case useInAppSafari
case inAppSafariAutomaticReaderMode
case expandAllContentWarnings
case collapseLongPosts
case oppositeCollapseKeywords
case confirmBeforeReblog
case timelineStateRestoration
case timelineSyncMode
case hideReblogsInTimelines
case hideRepliesInTimelines
case showFavoriteAndReblogCounts
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case hideTrends = "hideDiscover"
case statusContentType
case reportErrorsAutomatically
case enabledFeatureFlags
case hasShownLocalTimelineDescription
case hasShownFederatedTimelineDescription
}
}
extension UIUserInterfaceStyle: Codable {}

View File

@ -0,0 +1,106 @@
//
// PreferenceStore+Migrate.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import UIKit
extension PreferenceStore {
func migrate(from legacy: LegacyPreferences) {
var migrations: [any MigrationProtocol] = [
Migration(from: \.theme.theme, to: \.$theme),
Migration(from: \.pureBlackDarkMode, to: \.$pureBlackDarkMode),
Migration(from: \.accentColor, to: \.$accentColor),
Migration(from: \.avatarStyle, to: \.$avatarStyle),
Migration(from: \.hideCustomEmojiInUsernames, to: \.$hideCustomEmojiInUsernames),
Migration(from: \.showIsStatusReplyIcon, to: \.$showIsStatusReplyIcon),
Migration(from: \.alwaysShowStatusVisibilityIcon, to: \.$alwaysShowStatusVisibilityIcon),
Migration(from: \.hideActionsInTimeline, to: \.$hideActionsInTimeline),
Migration(from: \.showLinkPreviews, to: \.$showLinkPreviews),
Migration(from: \.leadingStatusSwipeActions, to: \.$leadingStatusSwipeActions),
Migration(from: \.trailingStatusSwipeActions, to: \.$trailingStatusSwipeActions),
Migration(from: \.widescreenNavigationMode, to: \.$widescreenNavigationMode),
Migration(from: \.underlineTextLinks, to: \.$underlineTextLinks),
Migration(from: \.showAttachmentsInTimeline, to: \.$showAttachmentsInTimeline),
Migration(from: \.defaultPostVisibility, to: \.$defaultPostVisibility),
Migration(from: \.defaultReplyVisibility, to: \.$defaultReplyVisibility),
Migration(from: \.requireAttachmentDescriptions, to: \.$requireAttachmentDescriptions),
Migration(from: \.contentWarningCopyMode, to: \.$contentWarningCopyMode),
Migration(from: \.mentionReblogger, to: \.$mentionReblogger),
Migration(from: \.useTwitterKeyboard, to: \.$useTwitterKeyboard),
Migration(from: \.attachmentBlurMode, to: \.$attachmentBlurMode),
Migration(from: \.blurMediaBehindContentWarning, to: \.$blurMediaBehindContentWarning),
Migration(from: \.automaticallyPlayGifs, to: \.$automaticallyPlayGifs),
Migration(from: \.showUncroppedMediaInline, to: \.$showUncroppedMediaInline),
Migration(from: \.showAttachmentBadges, to: \.$showAttachmentBadges),
Migration(from: \.attachmentAltBadgeInverted, to: \.$attachmentAltBadgeInverted),
Migration(from: \.openLinksInApps, to: \.$openLinksInApps),
Migration(from: \.expandAllContentWarnings, to: \.$expandAllContentWarnings),
Migration(from: \.collapseLongPosts, to: \.$collapseLongPosts),
Migration(from: \.oppositeCollapseKeywords, to: \.$oppositeCollapseKeywords),
Migration(from: \.confirmBeforeReblog, to: \.$confirmBeforeReblog),
Migration(from: \.timelineStateRestoration, to: \.$timelineStateRestoration),
Migration(from: \.timelineSyncMode, to: \.$timelineSyncMode),
Migration(from: \.hideReblogsInTimelines, to: \.$hideReblogsInTimelines),
Migration(from: \.hideRepliesInTimelines, to: \.$hideRepliesInTimelines),
Migration(from: \.showFavoriteAndReblogCounts, to: \.$showFavoriteAndReblogCounts),
Migration(from: \.defaultNotificationsMode, to: \.$defaultNotificationsMode),
Migration(from: \.grayscaleImages, to: \.$grayscaleImages),
Migration(from: \.disableInfiniteScrolling, to: \.$disableInfiniteScrolling),
Migration(from: \.hideTrends, to: \.$hideTrends),
Migration(from: \.statusContentType, to: \.$statusContentType),
Migration(from: \.reportErrorsAutomatically, to: \.$reportErrorsAutomatically),
Migration(from: \.enabledFeatureFlags, to: \.$enabledFeatureFlags),
Migration(from: \.hasShownLocalTimelineDescription, to: \.$hasShownLocalTimelineDescription),
Migration(from: \.hasShownFederatedTimelineDescription, to: \.$hasShownFederatedTimelineDescription),
]
#if !targetEnvironment(macCatalyst) && !os(visionOS)
migrations.append(contentsOf: [
Migration(from: \.useInAppSafari, to: \.$useInAppSafari),
Migration(from: \.inAppSafariAutomaticReaderMode, to: \.$inAppSafariAutomaticReaderMode),
] as [any MigrationProtocol])
#endif
for migration in migrations {
migration.migrate(from: legacy, to: self)
}
}
}
private protocol MigrationProtocol {
func migrate(from legacy: LegacyPreferences, to store: PreferenceStore)
}
private struct Migration<Key: MigratablePreferenceKey>: MigrationProtocol where Key.Value: Equatable {
let from: KeyPath<LegacyPreferences, Key.Value>
let to: KeyPath<PreferenceStore, PreferencePublisher<Key>>
func migrate(from legacy: LegacyPreferences, to store: PreferenceStore) {
let value = legacy[keyPath: from]
if Key.shouldMigrate(oldValue: value) {
Preference.set(enclosingInstance: store, storage: to.appending(path: \.preference), newValue: value)
}
}
}
private extension UIUserInterfaceStyle {
var theme: Theme {
switch self {
case .light:
.light
case .dark:
.dark
default:
.unspecified
}
}
}

View File

@ -0,0 +1,101 @@
//
// Preference.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import Combine
// TODO: once we target iOS 17, use Observable for this
@propertyWrapper
final class Preference<Key: PreferenceKey>: Codable {
@Published private(set) var storedValue: Key.Value?
var wrappedValue: Key.Value {
get {
storedValue ?? Key.defaultValue
}
set {
fatalError("unreachable")
}
}
init() {
self.storedValue = nil
}
init(from decoder: any Decoder) throws {
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
self.storedValue = try keyType.decode(from: decoder) as! Key.Value?
} else if let container = try? decoder.singleValueContainer() {
self.storedValue = try? container.decode(Key.Value.self)
}
}
func encode(to encoder: any Encoder) throws {
if let storedValue {
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
func encode<K: CustomCodablePreferenceKey>(_: K.Type) throws {
try K.encode(value: storedValue as! K.Value, to: encoder)
}
return try _openExistential(keyType, do: encode)
} else {
var container = encoder.singleValueContainer()
try container.encode(storedValue)
}
}
}
static subscript(
_enclosingInstance instance: PreferenceStore,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<PreferenceStore, Key.Value>,
storage storageKeyPath: ReferenceWritableKeyPath<PreferenceStore, Preference>
) -> Key.Value {
get {
get(enclosingInstance: instance, storage: storageKeyPath)
}
set {
set(enclosingInstance: instance, storage: storageKeyPath, newValue: newValue)
Key.didSet(in: instance, newValue: newValue)
}
}
// for testing only
@inline(__always)
static func get<Enclosing>(
enclosingInstance: Enclosing,
storage: KeyPath<Enclosing, Preference>
) -> Key.Value where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
let pref = enclosingInstance[keyPath: storage]
return pref.storedValue ?? Key.defaultValue
}
// for testing only
@inline(__always)
static func set<Enclosing>(
enclosingInstance: Enclosing,
storage: KeyPath<Enclosing, Preference>,
newValue: Key.Value
) where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
enclosingInstance.objectWillChange.send()
let pref = enclosingInstance[keyPath: storage]
pref.storedValue = newValue
}
var projectedValue: PreferencePublisher<Key> {
.init(preference: self)
}
}
public struct PreferencePublisher<Key: PreferenceKey>: Publisher {
public typealias Output = Key.Value
public typealias Failure = Never
let preference: Preference<Key>
public func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Key.Value == S.Input {
preference.$storedValue.map { $0 ?? Key.defaultValue }.receive(subscriber: subscriber)
}
}

View File

@ -0,0 +1,35 @@
//
// PreferenceKey.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/12/24.
//
import Foundation
public protocol PreferenceKey {
associatedtype Value: Codable
static var defaultValue: Value { get }
static func didSet(in store: PreferenceStore, newValue: Value)
}
extension PreferenceKey {
public static func didSet(in store: PreferenceStore, newValue: Value) {}
}
protocol MigratablePreferenceKey: PreferenceKey where Value: Equatable {
static func shouldMigrate(oldValue: Value) -> Bool
}
extension MigratablePreferenceKey {
static func shouldMigrate(oldValue: Value) -> Bool {
oldValue != defaultValue
}
}
protocol CustomCodablePreferenceKey: PreferenceKey {
static func encode(value: Value, to encoder: any Encoder) throws
static func decode(from decoder: any Decoder) throws -> Value?
}

View File

@ -0,0 +1,82 @@
//
// PreferenceStore.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/12/24.
//
import Foundation
import UIKit
import Pachyderm
public final class PreferenceStore: ObservableObject, Codable {
// MARK: Appearance
@Preference<ThemeKey> public var theme
@Preference<TrueKey> public var pureBlackDarkMode
@Preference<AccentColorKey> public var accentColor
@Preference<AvatarStyleKey> public var avatarStyle
@Preference<FalseKey> public var hideCustomEmojiInUsernames
@Preference<FalseKey> public var showIsStatusReplyIcon
@Preference<FalseKey> public var alwaysShowStatusVisibilityIcon
@Preference<FalseKey> public var hideActionsInTimeline
@Preference<TrueKey> public var showLinkPreviews
@Preference<LeadingSwipeActionsKey> public var leadingStatusSwipeActions
@Preference<TrailingSwipeActionsKey> public var trailingStatusSwipeActions
@Preference<WidescreenNavigationModeKey> public var widescreenNavigationMode
@Preference<FalseKey> public var underlineTextLinks
@Preference<TrueKey> public var showAttachmentsInTimeline
@Preference<AttachmentBlurModeKey> public var attachmentBlurMode
@Preference<TrueKey> public var blurMediaBehindContentWarning
@Preference<TrueKey> public var automaticallyPlayGifs
@Preference<TrueKey> public var showUncroppedMediaInline
@Preference<TrueKey> public var showAttachmentBadges
@Preference<FalseKey> public var attachmentAltBadgeInverted
// MARK: Composing
@Preference<PostVisibilityKey> public var defaultPostVisibility
@Preference<ReplyVisibilityKey> public var defaultReplyVisibility
@Preference<FalseKey> public var requireAttachmentDescriptions
@Preference<ContentWarningCopyModeKey> public var contentWarningCopyMode
@Preference<FalseKey> public var mentionReblogger
@Preference<FalseKey> public var useTwitterKeyboard
// MARK: Behavior
@Preference<TrueKey> public var openLinksInApps
@Preference<InAppSafariKey> public var useInAppSafari
@Preference<FalseKey> public var inAppSafariAutomaticReaderMode
@Preference<FalseKey> public var expandAllContentWarnings
@Preference<TrueKey> public var collapseLongPosts
@Preference<OppositeCollapseKeywordsKey> public var oppositeCollapseKeywords
@Preference<ConfirmReblogKey> public var confirmBeforeReblog
@Preference<TrueKey> public var timelineStateRestoration
@Preference<TimelineSyncModeKey> public var timelineSyncMode
@Preference<FalseKey> public var hideReblogsInTimelines
@Preference<FalseKey> public var hideRepliesInTimelines
// MARK: Digital Wellness
@Preference<TrueKey> public var showFavoriteAndReblogCounts
@Preference<NotificationsModeKey> public var defaultNotificationsMode
@Preference<FalseKey> public var grayscaleImages
@Preference<FalseKey> public var disableInfiniteScrolling
@Preference<FalseKey> public var hideTrends
// MARK: Advanced
@Preference<StatusContentTypeKey> public var statusContentType
@Preference<TrueKey> public var reportErrorsAutomatically
@Preference<FeatureFlagsKey> public var enabledFeatureFlags
// MARK: Internal
@Preference<FalseKey> public var hasShownLocalTimelineDescription
@Preference<FalseKey> public var hasShownFederatedTimelineDescription
}
extension PreferenceStore {
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
enabledFeatureFlags.contains(flag)
}
public func getValue<Key: PreferenceKey>(preferenceKeyPath: KeyPath<PreferenceStore, PreferencePublisher<Key>>) -> Key.Value {
self[keyPath: preferenceKeyPath].preference.wrappedValue
}
}

View File

@ -2,426 +2,42 @@
// Preferences.swift // Preferences.swift
// TuskerPreferences // TuskerPreferences
// //
// Created by Shadowfacts on 8/28/18. // Created by Shadowfacts on 4/12/24.
// Copyright © 2018 Shadowfacts. All rights reserved.
// //
import UIKit import Foundation
import Pachyderm
import Combine
public final class Preferences: Codable, ObservableObject {
public struct Preferences {
@MainActor @MainActor
public static var shared: Preferences = load() public static let shared: PreferenceStore = load()
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") private static var legacyURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
private static var preferencesURL = appGroupDirectory.appendingPathComponent("preferences.v2").appendingPathExtension("plist")
private static var nonAppGroupURL = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
private init() {}
@MainActor @MainActor
public static func save() { public static func save() {
let encoder = PropertyListEncoder() let encoder = PropertyListEncoder()
let data = try? encoder.encode(shared) let data = try? encoder.encode(PreferenceCoding(wrapped: shared))
try? data?.write(to: archiveURL, options: .noFileProtection) try? data?.write(to: preferencesURL, options: .noFileProtection)
} }
public static func load() -> Preferences { private static func load() -> PreferenceStore {
let decoder = PropertyListDecoder() let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL), if let data = try? Data(contentsOf: preferencesURL),
let preferences = try? decoder.decode(Preferences.self, from: data) { let store = try? decoder.decode(PreferenceCoding<PreferenceStore>.self, from: data) {
return preferences return store.wrapped
} } else if let legacyData = (try? Data(contentsOf: legacyURL)) ?? (try? Data(contentsOf: nonAppGroupURL)),
return Preferences() let legacy = try? decoder.decode(LegacyPreferences.self, from: legacyData) {
} let store = PreferenceStore()
store.migrate(from: legacy)
@MainActor return store
public static func migrate(from url: URL) -> Result<Void, any Error> {
do {
try? FileManager.default.removeItem(at: archiveURL)
try FileManager.default.moveItem(at: url, to: archiveURL)
} catch {
return .failure(error)
}
shared = load()
return .success(())
}
private init() {}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
self.defaultPostVisibility = .visibility(existing)
} else { } else {
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility) return PreferenceStore()
}
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
} else {
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
}
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme)
try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode)
try container.encode(accentColor, forKey: .accentColor)
try container.encode(avatarStyle, forKey: .avatarStyle)
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
try container.encode(showLinkPreviews, forKey: .showLinkPreviews)
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
try container.encode(mentionReblogger, forKey: .mentionReblogger)
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari)
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
try container.encode(grayscaleImages, forKey: .grayscaleImages)
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
try container.encode(hideTrends, forKey: .hideTrends)
try container.encode(statusContentType, forKey: .statusContentType)
try container.encode(reportErrorsAutomatically, forKey: .reportErrorsAutomatically)
try container.encode(enabledFeatureFlags, forKey: .enabledFeatureFlags)
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription)
}
// MARK: Appearance
@Published public var theme = UIUserInterfaceStyle.unspecified
@Published public var pureBlackDarkMode = true
@Published public var accentColor = AccentColor.default
@Published public var avatarStyle = AvatarStyle.roundRect
@Published public var hideCustomEmojiInUsernames = false
@Published public var showIsStatusReplyIcon = false
@Published public var alwaysShowStatusVisibilityIcon = false
@Published public var hideActionsInTimeline = false
@Published public var showLinkPreviews = true
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing
@Published public var defaultPostVisibility = PostVisibility.serverDefault
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published public var requireAttachmentDescriptions = false
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published public var mentionReblogger = false
@Published public var useTwitterKeyboard = false
// MARK: Media
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
didSet {
if attachmentBlurMode == .always {
blurMediaBehindContentWarning = true
} else if attachmentBlurMode == .never {
blurMediaBehindContentWarning = false
} }
} }
} }
@Published public var blurMediaBehindContentWarning = true
@Published public var automaticallyPlayGifs = true
@Published public var showUncroppedMediaInline = true
@Published public var showAttachmentBadges = true
// MARK: Behavior
@Published public var openLinksInApps = true
@Published public var useInAppSafari = true
@Published public var inAppSafariAutomaticReaderMode = false
@Published public var expandAllContentWarnings = false
@Published public var collapseLongPosts = true
@Published public var oppositeCollapseKeywords: [String] = []
@Published public var confirmBeforeReblog = false
@Published public var timelineStateRestoration = true
@Published public var timelineSyncMode = TimelineSyncMode.icloud
@Published public var hideReblogsInTimelines = false
@Published public var hideRepliesInTimelines = false
// MARK: Digital Wellness
@Published public var showFavoriteAndReblogCounts = true
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
@Published public var grayscaleImages = false
@Published public var disableInfiniteScrolling = false
@Published public var hideTrends = false
// MARK: Advanced
@Published public var statusContentType: StatusContentType = .plain
@Published public var reportErrorsAutomatically = true
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
// MARK:
@Published public var hasShownLocalTimelineDescription = false
@Published public var hasShownFederatedTimelineDescription = false
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
enabledFeatureFlags.contains(flag)
}
private enum CodingKeys: String, CodingKey {
case theme
case pureBlackDarkMode
case accentColor
case avatarStyle
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case hideActionsInTimeline
case showLinkPreviews
case leadingStatusSwipeActions
case trailingStatusSwipeActions
case widescreenNavigationMode
case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility
case defaultReplyVisibility
case requireAttachmentDescriptions
case contentWarningCopyMode
case mentionReblogger
case useTwitterKeyboard
case blurAllMedia // only used for migration
case attachmentBlurMode
case blurMediaBehindContentWarning
case automaticallyPlayGifs
case showUncroppedMediaInline
case showAttachmentBadges
case openLinksInApps
case useInAppSafari
case inAppSafariAutomaticReaderMode
case expandAllContentWarnings
case collapseLongPosts
case oppositeCollapseKeywords
case confirmBeforeReblog
case timelineStateRestoration
case timelineSyncMode
case hideReblogsInTimelines
case hideRepliesInTimelines
case showFavoriteAndReblogCounts
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case hideTrends = "hideDiscover"
case statusContentType
case reportErrorsAutomatically
case enabledFeatureFlags
case hasShownLocalTimelineDescription
case hasShownFederatedTimelineDescription
}
}
extension Preferences {
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting
case always
case never
public var displayName: String {
switch self {
case .useStatusSetting:
return "Default"
case .always:
return "Always"
case .never:
return "Never"
}
}
}
}
extension UIUserInterfaceStyle: Codable {}
extension Preferences {
public enum AccentColor: String, Codable, CaseIterable {
case `default`
case purple
case indigo
case blue
case cyan
case teal
case mint
case green
// case yellow
case orange
case red
case pink
// case brown
public var color: UIColor? {
switch self {
case .default:
return nil
case .blue:
return .systemBlue
// case .brown:
// return .systemBrown
case .cyan:
return .systemCyan
case .green:
return .systemGreen
case .indigo:
return .systemIndigo
case .mint:
return .systemMint
case .orange:
return .systemOrange
case .pink:
return .systemPink
case .purple:
return .systemPurple
case .red:
return .systemRed
case .teal:
return .systemTeal
// case .yellow:
// return .systemYellow
}
}
public var name: String {
switch self {
case .default:
return "Default"
case .blue:
return "Blue"
// case .brown:
// return "Brown"
case .cyan:
return "Cyan"
case .green:
return "Green"
case .indigo:
return "Indigo"
case .mint:
return "Mint"
case .orange:
return "Orange"
case .pink:
return "Pink"
case .purple:
return "Purple"
case .red:
return "Red"
case .teal:
return "Teal"
// case .yellow:
// return "Yellow"
}
}
}
}
extension Preferences {
public enum TimelineSyncMode: String, Codable {
case mastodon
case icloud
}
}
extension Preferences {
public enum FeatureFlag: String, Codable {
case iPadMultiColumn = "ipad-multi-column"
case iPadBrowserNavigation = "ipad-browser-navigation"
}
}
extension Preferences {
public enum WidescreenNavigationMode: String, Codable {
case stack
case splitScreen
case multiColumn
}
}

View File

@ -0,0 +1,86 @@
//
// AccentColor.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import UIKit
public enum AccentColor: String, Codable, CaseIterable {
case `default`
case purple
case indigo
case blue
case cyan
case teal
case mint
case green
// case yellow
case orange
case red
case pink
// case brown
public var color: UIColor? {
switch self {
case .default:
return nil
case .blue:
return .systemBlue
// case .brown:
// return .systemBrown
case .cyan:
return .systemCyan
case .green:
return .systemGreen
case .indigo:
return .systemIndigo
case .mint:
return .systemMint
case .orange:
return .systemOrange
case .pink:
return .systemPink
case .purple:
return .systemPurple
case .red:
return .systemRed
case .teal:
return .systemTeal
// case .yellow:
// return .systemYellow
}
}
public var name: String {
switch self {
case .default:
return "Default"
case .blue:
return "Blue"
// case .brown:
// return "Brown"
case .cyan:
return "Cyan"
case .green:
return "Green"
case .indigo:
return "Indigo"
case .mint:
return "Mint"
case .orange:
return "Orange"
case .pink:
return "Pink"
case .purple:
return "Purple"
case .red:
return "Red"
case .teal:
return "Teal"
// case .yellow:
// return "Yellow"
}
}
}

View File

@ -0,0 +1,25 @@
//
// AttachmentBlurMode.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting
case always
case never
public var displayName: String {
switch self {
case .useStatusSetting:
return "Default"
case .always:
return "Always"
case .never:
return "Never"
}
}
}

View File

@ -0,0 +1,13 @@
//
// FeatureFlag.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
public enum FeatureFlag: String, Codable {
case iPadBrowserNavigation = "ipad-browser-navigation"
case pushNotifCustomEmoji = "push-notif-custom-emoji"
}

View File

@ -12,7 +12,7 @@ public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable {
case serverDefault case serverDefault
case visibility(Visibility) case visibility(Visibility)
public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) } public private(set) static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self { switch self {
@ -57,7 +57,7 @@ public enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost case sameAsPost
case visibility(Visibility) case visibility(Visibility)
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } public private(set) static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
@MainActor @MainActor
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {

View File

@ -0,0 +1,24 @@
//
// Theme.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import UIKit
public enum Theme: String, Codable {
case unspecified, light, dark
public var userInterfaceStyle: UIUserInterfaceStyle {
switch self {
case .unspecified:
.unspecified
case .light:
.light
case .dark:
.dark
}
}
}

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