Compare commits

...

446 Commits

Author SHA1 Message Date
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
Shadowfacts cced930549 Bump build number and update changelog 2024-03-31 21:00:52 -04:00
Shadowfacts 7b2bd1a7af Apply grayscale attachments preference to videos in gallery 2024-03-31 20:56:20 -04:00
Shadowfacts f447150bbc Maybe improve grayscale gifv playback performance 2024-03-31 20:51:51 -04:00
Shadowfacts 08bd78d51b Fix changing greyscale images preference breaking gifvs looping 2024-03-31 20:51:33 -04:00
Shadowfacts f0ec372f50 Fix attachment blur view corners not being curved 2024-03-31 20:44:13 -04:00
Shadowfacts d2c28ada7f Improve gallery video autoplay behavior 2024-03-31 20:41:57 -04:00
Shadowfacts 375ad25919 Tweak gallery animation timing 2024-03-31 16:07:09 -04:00
Shadowfacts abf0568398 Improve gallery presentation/dismissal animation layering 2024-03-31 15:56:51 -04:00
Shadowfacts 2386f545e2 Fix gallery button delay on Catalyst/Designed for iPad 2024-03-31 15:44:15 -04:00
Shadowfacts 908c4ee085 Catalyst: Fix gallery close/share buttons 2024-03-31 14:12:07 -04:00
Shadowfacts 23e5e87915 Enable trackpad scrolling to dismiss gallery 2024-03-31 14:08:18 -04:00
Shadowfacts b4693252be Fix how we're getting the Sentry installation ID 2024-03-31 12:52:56 -04:00
Shadowfacts f3cf2dd8ec Catalyst: Fix gallery presentation animation not working 2024-03-31 12:35:10 -04:00
Shadowfacts d96ec2a732 Make gallery control buttons square 2024-03-30 15:21:23 -04:00
Shadowfacts b8fe0454b5 Inset gallery controls on devices without safe area insets 2024-03-30 15:18:17 -04:00
Shadowfacts 1166c6e639 Fix gallery transition dimming view not being removed 2024-03-30 15:18:02 -04:00
Shadowfacts eda552c7c9 Add pointer interactions to gallery controls 2024-03-30 15:17:26 -04:00
Shadowfacts 841c08be2c Fix crash when sharing attachment from context menu on iPad 2024-03-30 14:43:03 -04:00
Shadowfacts eafb506d64 Fix blurhash image being used as gallery content 2024-03-29 22:18:24 -04:00
Shadowfacts fe00015248 Add background to gallery close/share buttons 2024-03-29 22:10:14 -04:00
Shadowfacts 509ed305cd Ignore safe area for gallery content 2024-03-29 22:06:28 -04:00
Shadowfacts c05107bccd Scale evenly in both dimensions in gallery animations 2024-03-29 18:59:27 -04:00
Shadowfacts 4fcc32ca4b Fix gallery controls popping in over content after presentation 2024-03-29 18:22:33 -04:00
Shadowfacts 6857529d06 Video gallery controls
See #450
2024-03-28 23:19:32 -04:00
Shadowfacts 42e29862ac Fix crash when compose screen dismissed while adding attachments 2024-03-25 10:06:38 -04:00
Shadowfacts 3ecee61013 Fix Save to Photos UIActivity icon being stretched 2024-03-20 12:29:10 -04:00
Shadowfacts f9aee46bbe Asynchronously share video instead of fetching it on the main thread 2024-03-20 12:23:18 -04:00
Shadowfacts 1cf3ce48ce Support sharing/saving videos and gifvs from gallery
See #450
2024-03-20 12:00:57 -04:00
Shadowfacts 072bb0daf0 Fix grayscale images preference not applying to gifvs 2024-03-20 11:54:47 -04:00
Shadowfacts d36e0ad27d Grayscale images in new gallery
See #450
2024-03-20 11:54:35 -04:00
Shadowfacts a80cbe79c2 Re-add image analysis interaction
See #450
2024-03-20 11:49:00 -04:00
Shadowfacts cf71fc3f98 Remove old gallery implementation
See #450
2024-03-19 15:20:18 -04:00
Shadowfacts be977dbea9 Gallery rewrite
See #450
2024-03-19 15:04:14 -04:00
Shadowfacts f327cfd197 Move SwiftPM packages into separate group 2024-03-17 18:57:51 -04:00
Shadowfacts 4bb01becd2 Update Sentry to fix required reason API issues 2024-03-17 13:34:34 -04:00
Shadowfacts 64fcc87516 Add privacy manifest to share extension 2024-03-17 13:34:15 -04:00
Shadowfacts 62e528fc22 Bump build number and update changelog 2024-03-17 13:20:44 -04:00
Shadowfacts 030fd4467d Add privacy manifest 2024-03-17 13:17:13 -04:00
Shadowfacts 489840019e Add Save to Photos action to attachment context menu
Closes #462
2024-03-17 12:38:50 -04:00
Shadowfacts 9af8c06b1c Use ellipsis after share action title 2024-03-17 12:22:27 -04:00
Shadowfacts 55e0573a5c Add share menu action to attachment context menu 2024-03-17 12:22:13 -04:00
Shadowfacts ac142ae11c Update HTMLStreamer 2024-03-17 12:09:50 -04:00
Shadowfacts 99a58e2c33 Extract TimelineLikeDataSource into separate protocol 2024-03-10 14:49:57 -04:00
Shadowfacts c740fb1c1f Change status/account cell separator insets 2024-03-09 18:27:44 -05:00
Shadowfacts 175001d561 Fix more strict concurrency warnings 2024-03-09 14:18:28 -05:00
Shadowfacts d481ef6c9f Fix crash when removing the same poll option multiple times
SwiftUI doesn't detect updates to CoreData objects when directly
mutating the NSMutableOrderedSet of a relationship

Closes #458
2024-03-09 14:15:14 -05:00
Shadowfacts 3caa419659 Make profile header follower/following counts separate buttons 2024-03-09 14:07:23 -05:00
Shadowfacts 074b028015 Show first verified link on account collection view cell 2024-03-09 13:54:56 -05:00
Shadowfacts bab0dd3294 Bump HTMLStreamer
Closes #454
2024-02-28 18:00:16 -05:00
Shadowfacts 8a3acc6889 Use UIControl.performPrimaryAction instead of SPI on iOS 17.4 2024-02-28 12:20:55 -05:00
Shadowfacts d37c5dde2f Bump build number and update changelog 2024-02-23 00:00:15 -05:00
Shadowfacts 53260555f6 Remove now-redundant whitespace removal 2024-02-22 23:53:27 -05:00
Shadowfacts 70524dd642 Bump HTMLStreamer 2024-02-22 23:42:42 -05:00
Shadowfacts b6232a9f1e Use tab bar on visionOS 2024-02-22 23:32:38 -05:00
Shadowfacts 41481f465a Fix potential bug with matched geometry VC transition 2024-02-21 10:31:40 -05:00
Shadowfacts 527e7129af Bump HTMLStreamer 2024-02-06 18:48:10 -05:00
Shadowfacts 229b51686c Update HTMLStreamer, fix certain links not appearing
Closes #456
2024-02-04 15:12:14 -05:00
Shadowfacts e156a97861 visionOS: Don't use gallery VC transition 2024-02-04 11:52:48 -05:00
Shadowfacts bdec14c463 Remove dead code 2024-02-04 11:49:41 -05:00
Shadowfacts ec0509c645 visionOS: Don't use deprecated UI for scene placement 2024-02-04 11:46:04 -05:00
Shadowfacts 4500e9be27 visionOS: Don't use certain nib-based cells 2024-02-03 12:41:03 -05:00
Shadowfacts a2cc3a0436 visionOS: Exclude unused code 2024-02-03 12:29:06 -05:00
Shadowfacts dc654812b1 visionOS: Don't use deprecated UITextViewDelegate method 2024-02-03 12:24:24 -05:00
Shadowfacts f122383d0b visionOS: Disable in-app Safari 2024-02-03 12:24:18 -05:00
Shadowfacts 0f6492a051 Disable strict concurrency checking 2024-01-28 14:59:40 -05:00
Shadowfacts b235f0e826 Another round of strict concurrency fixes 2024-01-28 14:59:03 -05:00
Shadowfacts 27d44340e8 Even more strict concurrency fixes 2024-01-27 15:48:58 -05:00
Shadowfacts fc26c9fb54 More strict concurrency fixes 2024-01-27 14:58:36 -05:00
Shadowfacts ba60f92223 Compiles with strict concurrency checking 2024-01-27 11:40:42 -05:00
Shadowfacts c489d018bd Merge branch 'develop' into strict-concurrency
# Conflicts:
#	Tusker/Caching/ImageCache.swift
#	Tusker/Extensions/PKDrawing+Render.swift
#	Tusker/MultiThreadDictionary.swift
#	Tusker/Views/BaseEmojiLabel.swift
2024-01-26 11:32:12 -05:00
Shadowfacts a9a518c6c1 Fix trailing whitespace not being stripped from compose reply content 2024-01-26 11:25:29 -05:00
Shadowfacts b4bdf8b0dc Fix building for visionOS 2024-01-26 11:15:21 -05:00
Shadowfacts 94f71541f8 Merge branch 'develop' into vision
# Conflicts:
#	Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift
#	Tusker/Screens/Timeline/TimelineViewController.swift
#	Tusker/Views/Status/TimelineStatusCollectionViewCell.swift
2024-01-26 11:11:41 -05:00
Shadowfacts c2402303cc First pass at strict concurrency checking 2024-01-26 11:02:40 -05:00
Shadowfacts 5cef76e494 Fix crash when searching for "from:me" 2024-01-22 17:24:55 -05:00
Shadowfacts bf27b8fd47 Fix issues when changing scope after searching 2024-01-22 17:21:53 -05:00
Shadowfacts 32b8d27949 Don't report network errors when syncing timeline marker 2024-01-22 17:05:03 -05:00
Shadowfacts fb5581ae67 Bump build number and update changelog 2024-01-21 11:33:32 -05:00
Shadowfacts cd01d2f8c3 Pin HTMLStreamer version 2024-01-17 15:52:56 -05:00
Shadowfacts 65c3c8026d Fix whitespace in statuses not being trimmed 2024-01-17 15:51:54 -05:00
Shadowfacts 534f83e716 Fix links not being converted from HTML correctly 2024-01-16 19:17:44 -05:00
Shadowfacts 93c859a3c4 Fix TextConverter inserting newlines 2023-12-23 10:47:40 -05:00
Shadowfacts 4d183fe0b2 Merge branch '2024' into develop 2023-12-22 20:45:19 -05:00
Shadowfacts fd72390a22 Replace SwiftSoup with HTMLStreamer 2023-12-22 20:44:46 -05:00
Shadowfacts 5a4323067a Bump build number and update changelog 2023-12-22 11:02:29 -05:00
Shadowfacts 43d8434e17 Fix crash due to Explore data source being update off main thread when list deleted 2023-12-22 10:39:24 -05:00
Shadowfacts e8576277e0 Bump build number and update changelog 2023-12-17 18:16:47 -05:00
Shadowfacts 7f0a9d8d5a Fix status that is reblogged and contains a followed hashtag not showing reblogger label 2023-12-17 18:09:25 -05:00
Shadowfacts 51f4a780e2 Show loading indicator while translating status 2023-12-16 16:14:18 -05:00
Shadowfacts 180a8eb18d Fix Status reblogs inverse relationship being to-one instead of to-many 2023-12-14 21:57:44 -05:00
Shadowfacts eb61043867 Fix timeline state restoration not returning to correct scroll position in certain circumstances
All of the work done by the restoreInitial callback needs to be async,
so that when the TimelineLikeController signals that the loading
indicator should be removed, the collection view is in the right place.

Closes #439
2023-12-14 18:28:22 -05:00
Shadowfacts e09935125f Fix copying/pasting images from Safari on macOS not working
Closes #453
2023-12-14 18:01:34 -05:00
Shadowfacts e8ef9345e9 Fix visibility/local-only buttons not appearing on Catalyst
You need to pass the configuration to the initializer to get it to show up in the Mac idiom

Fixes #452
2023-12-05 22:00:48 -05:00
Shadowfacts 28c1a9092b Add server-provided translation
Closes #331
2023-12-04 19:31:51 -05:00
Shadowfacts 5e609aa40d V2 instance API, add translation to InstanceFeatures 2023-12-04 17:55:03 -05:00
Shadowfacts 158940f8e6 Refactor StatusContentContainer to use an array of subviews 2023-12-04 17:06:10 -05:00
Shadowfacts 141e8b96a5 Show label when attachments are hidden in timelines 2023-12-04 16:38:04 -05:00
Shadowfacts 108a02826f Remove incorrect workaround for crash when LazilyDecoding used on nil MO 2023-12-04 16:20:22 -05:00
Shadowfacts be1ca70ebf Add preference for showing attachments in timeline
Closes #330
2023-12-04 16:18:54 -05:00
Shadowfacts 34edd8a13f Fix reblogged statuses being pruned while still referenced, add workaround for crash 2023-12-03 15:08:38 -05:00
Shadowfacts 23f383a7f9 Get rid of network request during share extension launch
Closes #438
2023-12-02 15:33:15 -05:00
Shadowfacts 99caaa0f28 Bump version and update changelog 2023-11-29 18:05:58 -05:00
Shadowfacts 0f70c9059e Fix error decoding certain statuses on pixelfed 2023-11-19 22:52:58 -05:00
Shadowfacts 6d7074e71d Tweak profile header separator 2023-11-19 21:22:00 -05:00
Shadowfacts 13809b91d1 Fix crash if window removed while fast account switcher is hiding 2023-11-18 11:36:59 -05:00
Shadowfacts 16f6dc84c9 Update Sentry package 2023-11-18 11:15:47 -05:00
Shadowfacts cdfb06f4a7 Render IDN domains in for logged-in accounts 2023-11-18 11:08:35 -05:00
Shadowfacts 4e98e569eb Fix avatars in follow request notification not being rounded
Closes #448
2023-11-18 11:00:19 -05:00
Shadowfacts 6d3ffd7dd3 Style blockquote appropriately
Closes #22
2023-11-18 10:56:05 -05:00
Shadowfacts ca7fe74a90 Add accessibility description/action to status edit history entry 2023-11-10 14:48:48 -05:00
Shadowfacts 380f878d81 Use server language preference for default search token suggestion 2023-11-10 14:42:48 -05:00
Shadowfacts 1c36312850 Fix status deletions not being handled properly in logged-out views 2023-11-10 14:35:36 -05:00
Shadowfacts de946be008 Fix crash if ContentTextView asked for context menu config w/o mastodon controller 2023-11-10 14:20:33 -05:00
Shadowfacts b40d815274 Ensure LazilyDecoding runs on the managed object context's thread
Maybe fix the crash in KeyPath machinery?
2023-11-10 14:16:16 -05:00
Shadowfacts bc7500bde9 Fix crash when uploading attachment without known MIME type or extension 2023-11-10 14:08:11 -05:00
Shadowfacts 676e603ffc Fix crash when showing trending hashtag with less than two days of history 2023-11-10 14:04:11 -05:00
Shadowfacts 01bbfc31f2 visionOS: Improve suggested profile card appearance 2023-11-08 21:49:21 -05:00
Shadowfacts a846954dcd visionOS: Improve trending link cell appearance 2023-11-08 17:47:01 -05:00
Shadowfacts 53302e3b26 visionOS: Remove trends loading indicator highlight 2023-11-08 17:05:58 -05:00
Shadowfacts c0301ce7e7 visionOS: Further Compose screen tweaks 2023-11-08 17:02:32 -05:00
Shadowfacts 14f32f24fa visionOS: Use bordered prominent style for status actions 2023-11-08 16:37:12 -05:00
Shadowfacts 19db78e352 visionOS: Don't highlight non-selectable list rows 2023-11-07 22:52:13 -05:00
Shadowfacts 9d01bbabd7 visionOS: Use UIColor.link for text links 2023-11-07 22:42:32 -05:00
Shadowfacts a93a4fccc1 visionOS: Fix timeline jump button appearance 2023-11-07 22:31:57 -05:00
Shadowfacts 1da25300ca Merge branch 'develop' into vision 2023-11-07 22:26:22 -05:00
Shadowfacts cb47443649 Bump version and update changelog 2023-11-07 22:16:48 -05:00
Shadowfacts 86862825f6 Assert that the compose draft belongs to the view context 2023-11-05 18:32:05 -05:00
Shadowfacts e6f1968609 Fix TimelineLikeCollectionViewController.apply not actually applying snapshots on the main thread 2023-11-05 18:22:20 -05:00
Shadowfacts 4c5da1b5a9 Add URL handler for opening Compose window 2023-11-05 15:24:55 -05:00
Shadowfacts e57ef210fd Fix language picker button not having a pointer effect 2023-11-05 11:32:49 -05:00
Shadowfacts dcdfe853e1 Fix Cmd+W closing sometimes closing non-foreground window on macOS
Closes #444
2023-11-05 11:14:58 -05:00
Shadowfacts 34e57c297b Tweak HEIF/HEIC handling 2023-11-03 11:07:43 -04:00
Shadowfacts 6c2c2e6ae7 More logging to try and pin down LazilyDecoding EXC_BAD_ACCESS 2023-11-02 18:18:08 -04:00
Shadowfacts aae3bd0bba Remove dead code 2023-11-02 17:53:26 -04:00
Shadowfacts 2b5d4681e3 Prevent mul/und from being used as language
Closes #440
2023-11-02 10:44:52 -04:00
Shadowfacts e4eff2d362 Bump version and update changelog 2023-10-28 14:14:02 -05:00
Shadowfacts 37311e5f17 Fix potential crash due to race condition in timeline gap filling 2023-10-28 14:03:08 -05:00
Shadowfacts af5a0b7bbd Fix crash with large image dismiss gesture 2023-10-28 13:58:39 -05:00
Shadowfacts 3aa45cb365 Maybe fix crash due to reading ScaledMetric on background thread
ScaledMetric.wrappedValue calls into Font.scaleFactor(textStyle:in:)
which uses a dictionary setter
2023-10-28 13:56:25 -05:00
Shadowfacts a07b398cbe Maybe fix crash due to VC hierarchy consistency check failing on split collapse/expand 2023-10-28 13:52:54 -05:00
Shadowfacts 2ccec2f4df Fix crash if URLComponents.url is nil in instance selector 2023-10-28 13:47:44 -05:00
Shadowfacts 0de9a9fd37 Fix list timeline refresh failing if initial load returned no statuses 2023-10-28 13:36:11 -05:00
Shadowfacts bd21e88e8b Add UI for changing list reply policy and exclusivity
Closes #428
2023-10-28 12:16:14 -05:00
Shadowfacts 2464e2530f Remove dead code 2023-10-27 17:29:51 -05:00
Shadowfacts 44021d3ad2 Convert edit list screen to collection view 2023-10-27 17:29:51 -05:00
Shadowfacts a46eaafbcf Add reply policy and exclusive fields to lists 2023-10-27 17:00:53 -05:00
Shadowfacts eb496243c7 Use server preference for local-only on Hometown
Closes #281
2023-10-27 15:12:48 -05:00
Shadowfacts 6e5e0c3bb5 Use server preferences for default visibility and language
Closes #282
2023-10-27 14:59:21 -05:00
Shadowfacts dfc8234908 Attribute authenticated API requests to the user
Closes #134
2023-10-26 17:30:31 -05:00
Shadowfacts 157c8629a9 Add underline links preference
Closes #397
2023-10-24 16:02:03 -04:00
Shadowfacts bde21fbc6c Fix crash due to prematurely pruned statuses being fetched
If the app hasn't launched in long enough, we may be displaying old statuses as a result of state restoration. If the user leaves the app, those statuses can't get pruned, because the user may return. We need to make sure the lastFetchedAt date is current, since awakeFromFetch won't be called until the object is faulted in (which wasn't happening immediately during state restoration).
2023-10-24 15:50:58 -04:00
Shadowfacts 74820e8922 Underline links when button shapes accessibility setting is on 2023-10-24 15:50:58 -04:00
Shadowfacts f7a9075b77 Fix timeline jump button having background when button shapes accessibility setting is on 2023-10-24 15:50:58 -04:00
Shadowfacts 4af56e48bf Clean up TimelineLikeCollectionViewController.apply(_:animatingDifferences:) 2023-10-24 14:56:39 -04:00
Shadowfacts 978486bc15 visionOS: Improve button appearance in Compose attachment list 2023-10-20 11:27:24 -04:00
Shadowfacts 27dd8a1927 visionOS: Hide light/dark mode prefs 2023-10-20 11:27:24 -04:00
Shadowfacts 78196e14c3 visionOS: Improve Compose main text view appearance 2023-10-20 11:27:24 -04:00
Shadowfacts a0eb5dc596 visionOS: Move Compose toolbar controls to ornament 2023-10-20 11:27:24 -04:00
Shadowfacts e4c22a0205 Compile for visionOS 2023-10-20 11:27:24 -04:00
Shadowfacts c4bf5d406d Fix older notifications not loading when initially visible set fits on one screen
Closes #346
2023-10-19 21:21:50 -04:00
Shadowfacts 53d43b5707 Update changelog 2023-10-01 22:14:26 -04:00
Shadowfacts b1564d822e Bump version and move to xcconfig to fix warnings 2023-10-01 22:14:01 -04:00
Shadowfacts a8a2f0a26c Add search operators UI on Mastodon 4.2
Closes #433
2023-10-01 21:40:53 -04:00
Shadowfacts 46e1205327 Fix delay before My Profile sidebar item appears on launch 2023-10-01 10:20:45 -04:00
Shadowfacts 6a2de2be55 Make suggested profile cells uniform height on trends screen 2023-10-01 10:15:00 -04:00
Shadowfacts db6ba0c62c Remove navigation mode preference feature flag 2023-10-01 00:14:20 -04:00
Shadowfacts 16029dc161 Fix Appearance > Interface prefs using wrong row background color 2023-10-01 00:12:01 -04:00
Shadowfacts 31a0db014a Improve multi-column layout for suggested profiles 2023-10-01 00:08:00 -04:00
Shadowfacts 5be8005e24 Use two columns for trending links/accounts on wide screens 2023-09-29 17:33:18 -04:00
Shadowfacts ad4e112e96 Fix switching back to previous navigation mode 2023-09-29 17:18:29 -04:00
Shadowfacts 7a2dc7d3c4 Improve readable-width content inset behavior 2023-09-28 21:30:30 -04:00
Shadowfacts 0948371f83 Improve appearance of lists when converting from HTML
Closes #434
2023-09-27 17:35:36 -04:00
Shadowfacts 3ba1a00257 Reconfigure visible updates when refreshing
Closes #300
2023-09-26 09:42:39 -04:00
Shadowfacts 1b42cd7816 Fix cell reuse bug with follow/action notifications 2023-09-26 09:18:01 -04:00
Shadowfacts a2fe0dfb78 Avoid unnecessarily recreating avatar views in notifications cells 2023-09-25 21:44:43 -04:00
Shadowfacts bf1ed57180 Allow authoring local-only posts on Akkoma
Closes #332
2023-09-25 21:23:28 -04:00
Shadowfacts 6821f1b9a0 Don't show doubled "New Post" in window titlebar on macOS
Closes #429
2023-09-24 23:50:08 -04:00
Shadowfacts 7ae741cd83 Fix Live Text control reappearing when swiping between gallery pages with controls hidden
Closes #431
2023-09-24 23:44:40 -04:00
Shadowfacts fe9ad83ddc Fix replies with content warnings showing confirm dialog when unchanged
Closes #430
2023-09-24 23:28:36 -04:00
Shadowfacts 6b7c828cc9 Try to compress videos to fit within instance limits
Closes #425
2023-09-16 14:07:49 -04:00
Shadowfacts 2be1ee19de Improve error message when uploading attachment to Pixelfed fails
See #425
2023-09-16 13:56:46 -04:00
Shadowfacts 3f15a453bd Update to recommended Xcode settings 2023-09-16 13:50:39 -04:00
Shadowfacts 53611d80d6 Bump version and update changelog 2023-09-16 13:48:05 -04:00
Shadowfacts 4614b25f33 Remove test code 2023-09-09 12:42:34 -04:00
Shadowfacts 519446c5a8 Fix crash if autocomplete controller dealloc'd before search task starts 2023-09-09 12:28:53 -04:00
Shadowfacts 4b52cafb9a Bump version and build number 2023-09-09 12:28:21 -04:00
Shadowfacts 1ca84a3b95 Don't swizzle unnecessarily on iOS 17 2023-09-09 11:45:54 -04:00
Shadowfacts b28792eb29 Report string when mention url decoding fails 2023-09-09 11:41:54 -04:00
Shadowfacts 9c3be68e1c Don't report 422 or 500 errors 2023-09-09 11:40:18 -04:00
Shadowfacts df9ce81060 Fix crash when ComposeUIConfig.dismiss called after hosting controller dealloc'd
I'm not sure how this can happen (possibly if the user dismissed the
compose screen while the status was being posted? but I haven't been
able to reproduce that), but guard against it since it's causing crashes
2023-09-09 11:37:25 -04:00
Shadowfacts 173eda1757 Prevent dismissing compose screen while posting 2023-09-09 11:35:46 -04:00
Shadowfacts b2b15b8b6e Disallow posting direct messages on Pixelfed 2023-09-09 11:07:27 -04:00
Shadowfacts f448090c2a Gate navigation mode preference behind feature flag 2023-09-09 10:57:56 -04:00
Shadowfacts 232e3285ae Fix widescreen navigation mode preference not persisting 2023-09-09 10:56:21 -04:00
Shadowfacts ebc127c921 Add readable content inset to certain screens 2023-09-09 10:56:11 -04:00
Shadowfacts 41665b1060 Indicate that edit history may be incomplete for remote posts
Closes #385
2023-09-07 18:04:08 -04:00
Shadowfacts 3a3b7aaee4 Use custom UITraitDefinition on iOS 17 2023-09-06 13:51:27 -04:00
Shadowfacts f2485f0ba1 Add feature flag for browser-style navigation 2023-09-06 13:27:42 -04:00
Shadowfacts 75caf2c1eb Enable switching between navigation modes 2023-09-06 13:19:06 -04:00
Shadowfacts f1a6a405c2 Fix crash when split VC collapses with multi-column nav controller 2023-09-05 20:47:11 -04:00
Shadowfacts 88105f22a0 Add widescreen navigation mode preference 2023-09-05 19:21:50 -04:00
Shadowfacts 9c368f295e Initial multi-column navigation controller implementation 2023-09-05 19:21:37 -04:00
Shadowfacts 04deb08bcf Add feature flags to advanced preferences 2023-09-04 23:35:40 -04:00
Shadowfacts f704d15dd7 Make UserActivityType.handle MainActor-bound 2023-08-23 17:07:41 -07:00
Shadowfacts 297af7b905 Tweak instance type matching, add iceshrimp 2023-07-24 22:42:17 -07:00
Shadowfacts 6c0564e0ee Bump version and update changelog 2023-07-23 10:36:11 -07:00
Shadowfacts 3d232d81ba Fix firefish instances not being detected 2023-07-22 11:23:16 -07:00
Shadowfacts 3109aafd20 Workaround for status collapse button overlapping other views in the cell 2023-07-18 21:14:43 -07:00
Shadowfacts 105a01811a Actual fix for links appearing as the wrong color
Closes #402
2023-07-18 21:01:30 -07:00
Shadowfacts 33999fe895 Fix crash/hang when showing emoji autocomplete with very many emojis
Closes #424
2023-07-13 23:41:02 -07:00
Shadowfacts 7f12479514 Fix not being able to share images from Shortcuts actions that have public.image and public.file-url representations
Closes #420
2023-07-08 15:37:45 -07:00
Shadowfacts 0eb000224e Fix double posting in poor network conditions
Closes #421
2023-07-08 15:24:40 -07:00
Shadowfacts 3c9692d5b2 Remove ambiguating constraint priorities, avoid removing and recreating the same constraints
Closes #407
2023-07-05 20:30:55 -07:00
Shadowfacts 50bfaf7236 Clamp uncropped attachment aspect ratio
Closes #418
2023-07-04 11:11:20 -07:00
Shadowfacts 385f31728d Fix sharing screenshot from markup not working
Closes #419
2023-07-04 11:07:35 -07:00
Shadowfacts bcd487d311 Fix favorites count button changing with when (un)faving
Closes #406
2023-07-04 10:25:32 -07:00
Shadowfacts 8f8e2a2aea Add unfollow hashtag action to Explore screen
Closes #417
2023-07-04 09:56:35 -07:00
Shadowfacts 54034ff727 Ignore HTTP 503 errors 2023-07-02 11:53:49 -07:00
Shadowfacts ee5db96c9e Workaround for links using the wrong tint color
Closes #402
2023-07-02 09:46:17 -07:00
Shadowfacts f825760fe9 Fix profile header follow button icon spacing 2023-06-26 22:18:27 -07:00
Shadowfacts a339884d1f Fix ScrollingSegmentedControl being cut off at smaller the default dynamic type size
Closes #410
2023-06-26 21:52:51 -07:00
Shadowfacts 1de586f907 Fix reblog with visibility not working 2023-06-26 21:41:43 -07:00
Shadowfacts bd162afdcc Fix showing incorrect visibilities in reblog confirmation alert 2023-06-26 21:40:43 -07:00
Shadowfacts 956b817045 Correct log level 2023-06-26 21:39:09 -07:00
Shadowfacts 28ee0908d7 Blur link card images when status is sensitive
Closes #412
2023-06-26 21:35:15 -07:00
Shadowfacts c3cf38b0c9 Fix not being able to refresh Mentions tab on Pleroma
Closes #411
2023-06-26 21:17:21 -07:00
Shadowfacts 7929e7530f Fix incorrect context menu preview on filtered post
Closes #413
2023-06-26 21:12:20 -07:00
Shadowfacts a11e453112 Fix reblog confirmation alert not being centered in non-fullscreen window
Closes #415
2023-06-26 21:01:23 -07:00
Shadowfacts 2e7ad1626e Fix avatars being squished in certain places
Closes #414
2023-06-26 20:47:38 -07:00
Shadowfacts 4182c15500 Fix invalid status notifications not being removed
Closes #416
2023-06-26 20:38:10 -07:00
Shadowfacts 4b43726e1d Fix not being able to follow hashtags on akkoma
Closes #408
2023-06-03 18:07:44 -07:00
Shadowfacts a4e7082ab8 Fix race condition in Compose screen when loading account 2023-05-28 22:28:41 -07:00
Shadowfacts f0b8f92791 Use cached logged-in account for things
Fixes various race conditions with loading account

Closes #251
2023-05-28 22:26:46 -07:00
Shadowfacts da88303a22 Cache active account ID in CoreData
See #251
2023-05-28 22:23:04 -07:00
Shadowfacts cb5b70a23a Remove direct accesses of MastodonController.instance
Fixes potential race conditions
2023-05-28 22:10:51 -07:00
Shadowfacts 2b5b749dc8 Avoid setting duplicate breadcrumbs 2023-05-28 22:10:51 -07:00
Shadowfacts ef00c0e2df Cache own instance in CoreData
See #251
2023-05-28 22:10:10 -07:00
Shadowfacts 06f7e306e0 Provide UserAccountInfo to MastodonController at initialization 2023-05-28 21:28:20 -07:00
Shadowfacts 878744b636 Tweak how Sentry installation ID is read 2023-05-28 21:04:29 -07:00
Shadowfacts f84694b809 Fix compose toolbar background not extending to full width on landscape iPhones 2023-05-28 15:34:56 -07:00
Shadowfacts 473ef018c9 Fix DuckableContainerVC not resetting when dismissed programatically
Fixes #396
2023-05-28 15:06:59 -07:00
Shadowfacts 9a734565b0 Fix backgrounding app on iPad dismissing modally-presented VC
Closes #399
Closes #316
2023-05-28 14:37:41 -07:00
Shadowfacts 2eda9657ac Don't use deprecated interfaceOrientation for detecting portrait mode 2023-05-28 14:18:13 -07:00
Shadowfacts 203c1852d4 Reuse poll option views when updating status cell
Fixes flicker/animation due to new option views begin added in default
state and then changed back to the state of the existing view.

Fixes #403
2023-05-28 12:19:45 -07:00
Shadowfacts 708112c486 Don't reconfigure conversation main status unnecessarily 2023-05-28 12:16:48 -07:00
Shadowfacts 5b321fcc78 Remove deferred loading indicator, causes more trouble than it's worth
Closes #404
2023-05-28 11:17:16 -07:00
Shadowfacts 59231e513f Fix crash if status for leaked collection view cell updates 2023-05-27 15:38:13 -07:00
Shadowfacts bf6dfab121 Fix not checking if section exists before getting item identifiers
Closes #398
2023-05-27 15:33:33 -07:00
Shadowfacts f5f1be9f7d Fix crash due to force-unwrapping uninitialized search controller
Closes #395
2023-05-27 15:31:02 -07:00
Shadowfacts c0148bb770 Fix Delete attachment context menu action not working
Closes #394
2023-05-27 15:28:39 -07:00
Shadowfacts d938c555b7 Fix Recognize Text action accessing view context MO off of main thread
Closes #393
2023-05-27 15:26:13 -07:00
Shadowfacts 52efc8b752 Fix crash if contextMenuConfiguration called on status cell that doesn't have a delegate
Closes #392
2023-05-27 15:23:49 -07:00
Shadowfacts 822e3f91c4 Fix crash if language code is less than 3 chars
Closes #391
2023-05-27 15:23:11 -07:00
Shadowfacts d0a1aec1c0 Fix crash when action notification cell doesn't have any statuses
Closes #390
2023-05-27 15:21:34 -07:00
Shadowfacts e8305184af Fix tip jar button width changing while purchasing
Closes #389
2023-05-27 15:20:42 -07:00
Shadowfacts e9727ac2c5 Fix reblogs count button not being leading-aligned
Closes #388
2023-05-27 15:18:03 -07:00
Shadowfacts d9a6bb0fd2 Fix ambiguous constraints in poll view 2023-05-27 15:11:53 -07:00
Shadowfacts 13a807ba4f Fix poll options view blocking context menu gesture
Closes #387
2023-05-27 15:00:10 -07:00
Shadowfacts 32c5eee0b5 Fix conversation main status cell flashing wrong background color
Closes #386
2023-05-27 14:52:59 -07:00
Shadowfacts 06f761bf56 Bump build number and update changelog 2023-05-16 13:10:42 -04:00
Shadowfacts 4b16a69275 Fix expand focused attachment animation not working 2023-05-16 13:02:28 -04:00
Shadowfacts a309b041bf Update release changelog 2023-05-16 12:49:11 -04:00
Shadowfacts 8c40a5a9e8 Bump build number and update changelog 2023-05-16 11:57:46 -04:00
Shadowfacts 3b11dd216f Change conversation main status favorites/reblogs count order to match Mastodon 2023-05-16 11:43:53 -04:00
Shadowfacts 8db5649cd5 Show unknown attachments
Closes #47
2023-05-16 11:40:59 -04:00
Shadowfacts f2f6eb81f7 Change favorite/reblog action order to match Mastodon 2023-05-16 11:28:28 -04:00
Shadowfacts f6831ec02b Add QuickLook fallback for showing unknown attachments in the gallery
Closes #169
2023-05-16 11:25:28 -04:00
Shadowfacts 7f64654800 Fix crash when adding drawing attachment 2023-05-16 10:57:27 -04:00
Shadowfacts 8e570027a1 Bump build number and update changelog 2023-05-16 00:18:34 -04:00
Shadowfacts df9fb3c527 Fix Save Draft action not working 2023-05-16 00:06:58 -04:00
Shadowfacts 2080fdc955 Fix replied-to status not updating when selecting different draft 2023-05-16 00:04:30 -04:00
Shadowfacts 70f8748364 Fix crash if draft attachment lacks data 2023-05-16 00:03:54 -04:00
Shadowfacts 0343e2e310 Bump build number and update changelog 2023-05-16 00:01:28 -04:00
Shadowfacts 80645a089c Remove deleted statuses on notifications screen 2023-05-15 23:45:18 -04:00
Shadowfacts 37442bcb48 Fix crash if selected search scope somehow changes before the view is loaded 2023-05-15 23:45:18 -04:00
Shadowfacts a99072dd7c Fix crash if there are duplicate accounts in fav/reblog notification list 2023-05-15 23:45:18 -04:00
Shadowfacts 6b57ec8b97 Cleanup orphaned local attachments 2023-05-15 23:45:18 -04:00
Shadowfacts d84d402271 Fix various issues when dealing with multiple Compose/Drafts screens simultaneously 2023-05-15 22:57:07 -04:00
Shadowfacts f004c82302 Fix crash if TimelineGapCollectionViewCell is somehow accessibility-activated 2023-05-15 22:03:51 -04:00
Shadowfacts 126e8c8858 Resolve Mastodon remote status links
Closes #384
2023-05-15 22:01:44 -04:00
Shadowfacts dbc89509d7 Fix expand thread cell using wrong background color
Closes #383
2023-05-15 21:25:01 -04:00
Shadowfacts 0ba38e4a3a Fix handoff to iPad/Mac modally presenting new screen rather than pushing nav 2023-05-15 21:17:26 -04:00
Shadowfacts 361ce456cf Bump build number and update changelog 2023-05-14 22:31:10 -04:00
Shadowfacts c1cfde9d49 Don't show Markdown formatting warning on Calckey 2023-05-14 21:44:20 -04:00
Shadowfacts daa38772b4 Fix crash when editing hide-action filter 2023-05-14 21:32:22 -04:00
Shadowfacts dc83172aea Support filtering on Notifications screen 2023-05-14 19:15:18 -04:00
Shadowfacts b909a633a6 Fix monospace font not being set on profile statuses HTML converter 2023-05-14 19:09:06 -04:00
Shadowfacts 1f95a6cb8e Fix constraints breaking on expand thread cell 2023-05-14 19:08:52 -04:00
Shadowfacts 468af3f9a6 Move CollapseState out of NotificationGroup 2023-05-14 18:55:34 -04:00
Shadowfacts 038e4b2e4e Fix crash when action notification cell label leaks 2023-05-14 18:44:08 -04:00
Shadowfacts de53e0dcd6 Fix editing Markdown/HTML statuses 2023-05-14 17:46:10 -04:00
Shadowfacts 1cf7434918 Fix editing posts not working on Akkoma 2023-05-14 17:31:08 -04:00
Shadowfacts fc7e7f502b Bump build number and update changelog 2023-05-14 17:30:01 -04:00
Shadowfacts 38a2ebd32b Fix link card images not loading on Mastodon 2023-05-14 16:24:54 -04:00
Shadowfacts 3b965b92f2 Don't update constraints from StatusContentContainer.setCollapsed 2023-05-14 15:53:24 -04:00
Shadowfacts 421cb7ba03 Fix conversation main status flickering when context is loaded 2023-05-14 15:25:09 -04:00
Shadowfacts 8319935a3d BaseEmojiLabel improvements
Avoid rechecking disk/memory caches when fetching

Use UIImage thumbnail API, rather than UIGraphicsImageRenderer, and make
thumbnail off main thread when possible
2023-05-14 15:19:00 -04:00
Shadowfacts 91ef386a41 Fix reblogger label getting updated twice for every cell 2023-05-14 14:58:46 -04:00
Shadowfacts c8eec17180 Fix custom emoji in display name being replaced multiple times unnecessarily 2023-05-14 14:41:36 -04:00
Shadowfacts c94e60d49b Enable editing on Pleroma 2.5+ 2023-05-14 13:55:28 -04:00
Shadowfacts b00170c3f9 Move InstanceFeatures.Version to separate file 2023-05-14 13:51:41 -04:00
Shadowfacts b37e5fffbf Silence CloudKit debug logging 2023-05-13 15:03:48 -04:00
Shadowfacts 8c27a9368f Estimate height when resolving status collapse state 2023-05-13 15:00:03 -04:00
Shadowfacts 735659dee6 Don't leave space for checkbox when no checkboxes are shown 2023-05-13 14:14:38 -04:00
Shadowfacts bf02b185ed Fix StatusState copying removing cached state
Closes #380
2023-05-13 13:53:04 -04:00
Shadowfacts 4ccf5d21a4 Disable boost to original audience for the users own DMs
Closes #382
2023-05-13 13:50:07 -04:00
Shadowfacts 9ac1c43511 Update favorite/reblog button appearance immediately on tap
Fixes #381
2023-05-13 13:48:49 -04:00
Shadowfacts 76b9496fe6 Revert "Unseparate out updateStatusState method"
This reverts commit 2157126332.
2023-05-13 13:18:57 -04:00
Shadowfacts ae8191ca0e Don't use prepareThumbnail in Compose screen
Fixes crash when sharing certain images to share sheet extension
2023-05-13 12:38:51 -04:00
Shadowfacts a9a9bfebeb Fix share sheet extension not working with Apple News
Closes #375
2023-05-12 22:00:00 -04:00
Shadowfacts 2d8e2f0824 Fix hitches due to AttachmentView not using pre-prepared images 2023-05-12 21:40:17 -04:00
Shadowfacts 6f18d46037 Properly conform Client.Error to LocalizedError 2023-05-11 23:26:06 -04:00
387 changed files with 18094 additions and 6551 deletions

View File

@ -1,3 +1,178 @@
## 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
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.
Features/Improvements:
- Show search operators on Mastodon 4.2
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
- Allow changing list reply policy and exclusivity options on Edit List screen
- Add Translate action to conversations (on supported Mastodon instances)
- Style block quotes correclty in rich-text posts
- Improve the appearance of lists in rich-text posts
- Add preference to underline links
- Compress uploaded video attachments to fit within instance limits
- Add preference to hide attachments in timelines
- Update visible timestamps after refresh notifications/timelines
- iPadOS: Allow switching between split screen and fullscreen navigation modes
- Pixelfed: Improve error message when uploading attachment fails
- Akkoma: Enable composing local-only posts
Bugfixes:
- Fix older notifications not loading if all initiially-loaded ones are grouped together
- Fix List timelines failing to refresh if they were initially empty
- Fix replies to posts with CWs always showing confirmation dialog when cancelling
- Fix Compose screen permitting setting the language to multiple/undefined
- Fix crash when uploading attachments without file extensions
- Fix Live Text button reappearing with swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- Fix avatars on follow request notifications not being rounded
- Fix timeline jump button appearing incorrectly when Button Shapes acccessibility setting is on
- Fix public instance timeline screen not handling post deletion correctly
- Fix post that's reblogged and contains a followed hashtag not showing the reblogger
- Fix crash on launch when reblogged posts are visible
- Fix crash when showing display names with custom emoji in certain places
- Fix crash when showing trending hashtags without history data
- Fix potential crash on instance selector screen
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix potential crash after deleting List on the Eplore screen
- Pixelfed: Fix error decoding certain posts
- VoiceOver: Fix history entries on Edit History screen not having descriptions
- iPadOS: Fix delay on app launch before "My Profile" sidebar item appears
- iPadOS: Fix language picker button not highlighting when hovered with the cursor
- macOS: Fix "New Post" window title appearing twice
- macOS: Fix Cmd+W sometimes closing non-foreground windows
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.7
This update adds support for iOS 17 and includes some minor changes.
Changes:
- Support iOS 17
- Indicate that edit history may be incomplete for remote posts
- Fix crash when collapsing to tab-bar mode in certain circumstances
- Fix potential crashes when using autocomplete on the Compose screen
- Fix Iceshrimp instances not being detected
## 2023.6
This update fixes a number of bugs and improves stability throughout the app. See below for a list of fixes.
Bugfixes:
- Fix issues displaying main post in the Conversation screen
- Fix crash when opening the Compose screen in certain locales
- Fix issues when collapsing from sidebar to tab bar mode
- Fix incorrect UI being displayed when accessing certain parts of the app immediately after launch
- Fix link card images not being blurred on posts marked sensitive
- Fix links appearing with incorrect accent color intermittently
- Fix being unable to remove followed hashtags from the Explore screen
- Akkoma: Fix not being able to follow hashtags
- Pleroma: Fix refreshing Mentions failing
- iPhone: Fix ducked Compose screen disappearing when rotating on large phones
## 2023.5
This update adds new several Compose-related features, including the ability to edit posts, a share sheet extension, and a post language picker. See below for the full list of improvements and bugfixes.
Features/Improvements:
- Edit posts
- Indicate edited posts in timestamp
- Show post edit history from Conversation screen
- Add Share Sheet extension
- Add expanded attachment view on Compose screen
- Add an attachment, select the description text field, then tap the expand button
- Expanded view allows you to see the attachment while writing the description
- Allows playing back videos while writing description
- iOS 16: Allows zooming in to the attachment
- Add language picker to the Compose screen
- Improve Compose screen ducking behavior
- Show reblogger's avatar on reblogged posts
- Use system photo picker instead of custom interface
- Improve hashtag search UI in Customize Timelines
- Improve status collapse/expand animation on Notifications screen
- Apply filters to Notifications screen
- Improve performance when scrolling through timeline
- Improve error messages when editing filters
- Change favorite/reblog button order to match Mastodon UI
- Gracefully handle unknown attachment types
- iPadOS: Persist sidebar visibility across
Bugfixes:
- Fix scroll-to-top not working in in-app Safari
- Fix inaccruate titles in certain error popups
- Fix error decoding post HTML
- Fix replied-to account not being the first @-mention
- Fix "No Content" message on profiles using wrong background color
- Fix reblogged posts appearing in Bookmarks
- Fix spurious errors when loading timeline
- Fix crash when displaying certain profiles
- Fix crash when the server returns invalid notifications
- Fix link previews not appearing in Notifications
- Fix Notifications screen taking a long time to load
- Fix deleted posts not being removed from Notifications screen
- Fix crashes when switching between sidebar/tab-bar modes
- Fix instance features not being detected on IDNA domains
- Fix list/hashtag timelines missing controls when opened in new window
- Fix reblog button being enabled on the user's own direct posts
- Fix main post in Conversation flickering
- Fix link card images not loading on Mastodon
- Fix crash when editing filter with the Hide action
- Fix certain remote status links not being resolved
- Fix Handoff to iPad/Mac presenting new screen modally
- GoToSocial: Fix decoding certain posts
- Calckey: Fix decoding certain posts
- iPadOS: Fix Compose window lacking a title
- iPadOS: Fix keyboard focus highlight not showing
- macOS: Fix sidebar keyboard shortcuts not working
## 2023.4
Features/Improvements:
- Add preference for non-pure-black dark mode

View File

@ -1,5 +1,282 @@
# Changelog
## 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)
Features/Improvements:
- Rewrite attachment gallery
- Fixes a number of long-standing issues
- Adds a custom video player that shows controls and caption
- Supports sharing/saving videos
Bugfixes:
- Fix hang when sharing video/gifv attachments
- Fix stretched icon for Save to Photos action when sharing attachment
- Fix crash when Compose screen is dismissed while adding attachments
- Fix crash when sharing attachment from context menu on iPad
## 2024.1 (113)
Features/Improvements:
- Add Share and Save to Photos context menu actions to attachments
- Show verified link in account lists
- Change cell separator appearance on posts
Bugfixes:
- Fix tapping Followers button on profiles opening Following screen
- Fix crash when removing poll option on Compose screen
- Fix leading indentation in post text being ignored
- Fix crash when viewing posts containing HTML numeric character references
- Fix paragraphs starting with links being combined with previous paragraph
## 2024.1 (112)
Bugfixes:
- Fix profile field links not displaying
- Fix various issues displaying rich text in posts
- Fix issue changing scope after searching
- Fix crash when searching for "from:me"
## 2024.1 (111)
This build contains a complete rewrite of the HTML parsing pipeline for displaying posts. If you notice any issues with how post text appears—especially when it differs from on the web—please report it!
## 2023.8 (110)
Bugfixes:
- Fix potential crash after deleting List on Explore screen
## 2023.8 (109)
Features/Improvements:
- Add Translate action to conversations (on supported Mastodon instances)
- Improve share extension launch speed
- Add preference for hiding attachments in timelines
Bugfixes:
- Fix crash during state restoration when reblogged statuses are present
- Fix timeline state restoration using incorrect scroll position in certain circumstances
- Fix status that is reblogged and contains a followed hashtag not showing reblogger label
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.8 (107)
Features/Improvements:
- Style blockquotes in statuses
- Use server language preference for search operator suggestions
- Render IDN domains in the account switcher
Bugfixes:
- Fix crash when showing trending hashtags with improper history data
- Fix crash when uploading attachment w/o file extension
- Fix status deletions not being handled properly in logged out views
- Fix status history entries not having VoiceOver descriptions
- Fix avatars in follow request notifications not being rounded
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix error decoding certain statuses on Pixelfed
## 2023.8 (106)
Bugfixes:
- Fix being able to set post language to multiple/undefined
- iPadOS: Fix language picker button not having a pointer effect
- macOS: Fix Cmd+W sometimes closing the non-foreground window
## 2023.8 (105)
Features/Improvements:
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
- Add preference to underline links
- Allow changing list reply policy and exclusivity from menu on Edit List screen
- Attribute network requests to user, rather than developer, when appropriate
Bugfixes:
- Fix older notifications not loading if all initially-loaded are grouped together
- Fix list timelines failing to refresh if there were no statuses initially
- Fix timeline jump button having a background when Button Shapes accessibility setting is on
- Fix crash when relaunching app after not being launched in more than a week
- Fix potential crash on instance selector screen
- Fix crash when showing display names with custom emojis in certain places
## 2023.8 (104)
Features/Improvements:
- Show search operators on Mastodon 4.2
- Enable composing local-only posts on Akkoma
- Update timestamps after refreshing notifications/timelines
- Improve list appearance in rich text posts
- Improve error message when uploading attachment to Pixelfed fails
- Compress uploaded videos to fit within instance limits
- iPad: Allow switching between split screen and full screen navigation
Bugfixes:
- Fix replies to posts with content warnings always showing confirmation dialog before closing
- Fix Live Text control reappearing when swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- iPad: Fix delay on app launch before "My Profile" sidebar item appears
- macOS: Fix "New Post" window title appearing twice
## 2023.7 (103)
Features/Improvements:
- Add support for iOS 17
- Indicate that edit history may be incomplete for remote posts
Bugfixes:
- Fix crash when collapsing to tab-bar mode in certain circumstances
- Fix potential crashes when using autocomplete on the Compose screen
- Fix Iceshrimp instances not being detected
## 2023.6 (100)
Bugfixes:
- Fix Conversation main post flashing incorrect background color when touched
- Fix reblogs count button in Conversation main post not being left-aligned
- Fix Conversation main post flickering when context loaded
- Fix context menu not appearing when long pressing finished/voted poll
- Fix Tip Jar button width changing while purchasing
- Fix crash when opening Compose screen in certain locales
- Fix potential issue with Recognize Text context menu action on attachments
- Fix attachment deletion context menu action not working
- Fix crash when collapsing from sidebar to tab bar mode
- Fix crash when post deleted before Notifications screen is loaded
- Fix race conditions when accessing certain parts of the app immediately upon launch
- Fix crash when viewing invalid user post notifications
- Fix non-square avatars not displaying correctly in various places
- Fix incorrect context menu preview being shown on filtered posts
- Fix link card images not being blurred on sensitive posts
- Fix reblog confirmation alert showing incorrect visibilities for non-public posts
- Fix Home/Notifications tab switchers being cut off with smaller than default Dynamic Type sizes
- Fix posts using incorrect accent color for links in certain circumstances
- Fix not being able to remove followed hashtags from Explore screen
- Fix not being able to attach images from Markup share sheet or Shortcuts share action
- Fix very wide attachments being untappably short
- Fix double posting in poor network conditions
- Fix crash when autocompleting emoji on instances with a large number of custom emoji
- Akkoma: Fix not being able to follow hashtags
- Pleroma: Fix refreshing Mentions failing
- iPhone: Fix ducked Compose screen breaking when rotating on Plus/Max iPhone models
- iPhone: Fix Compose toolbar not extending to the full width of the screen in landscape on iPhone
- iPadOS: Fix closing app dismissing in-app Safari
- iPadOS: Fix reblog confirmation alert not being centered in split view
## 2023.5 (98)
Bugfixes:
- Fix broken animation when opening/closing expanded attachment view on Compose screen
## 2023.5 (97)
Features/Improvements:
- Change favorite/reblog button order to match Mastodon
- Use QuickLook as a fallback for uknown attachment types
Bugfixes:
- Fix crash when adding drawing attachment
## 2023.5 (96)
Features/Improvements:
- Resolve Mastodon's remote status links
Bugfixes:
- Fix handoff to iPad/Mac presenting new screen modally rather than navigating
- Fix crash if timeline gap cell is accessibility-activated after leaking
- Fix various crashes when multiple Compose/Drafts screens are opened
- Delete orphaned draft attachments
- Fix deleted posts not getting removed from Notifications screen
- Fix replied-to status not changing when selecting draft
## 2023.5 (94)
Features/Improvements:
- Apply filters to Notifications screen
Bugfixes:
- Fix editing posts not working on Akkoma
- Fix editing Markdown/HTML posts
- Fix crash when editing filter with Hide action
## 2023.5 (91)
Features/Improvements:
- Improve performance when scrolling through timeline
- Improve error messages when editing filters
- Enable editing posts on Pleroma 2.5+
Bugfixes:
- Fix share sheet extension not working with Apple News
- Fix crash when sharing certain photos with share extension
- Fix reblog button being enabled on Direct posts
- Fix expanded statuses collapsing when opening Conversation
- Fix main post in Conversation flickering when context loaded
- Fix link card images not loading on Mastodon
## 2023.5 (89)
This build is a hotfix for an issue loading notifications in certain circumstances. The changelong for the previous build (adding post editing) is included below.

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,361 @@
//
// 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
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
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
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 {
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 attachment = notification.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
do {
let newContent = try content.updating(from: intent)
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 #available(iOS 16.0, macOS 13.0, *),
let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
URL(string: string)
}
}
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

@ -8,6 +8,7 @@
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
class ActionViewController: UIViewController {
@ -17,25 +18,29 @@ class ActionViewController: UIViewController {
super.viewDidLoad()
findURLFromWebPage { (components) in
if let components = components {
self.searchForURLInApp(components)
} else {
self.findURLItem { (components) in
if let components = components {
self.searchForURLInApp(components)
DispatchQueue.main.async {
if let components {
self.searchForURLInApp(components)
} else {
self.findURLItem { (components) in
if let components {
DispatchQueue.main.async {
self.searchForURLInApp(components)
}
}
}
}
}
}
}
private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
private func findURLFromWebPage(completion: @escaping @Sendable (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) else {
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
continue
}
provider.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil) { (result, error) in
provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { (result, error) in
guard let result = result as? [String: Any],
let jsResult = result[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
let urlString = jsResult["activityPubURL"] as? String ?? jsResult["url"] as? String,
@ -53,13 +58,13 @@ class ActionViewController: UIViewController {
completion(nil)
}
private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) {
for item in extensionContext!.inputItems as! [NSExtensionItem] {
for provider in item.attachments! {
guard provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) else {
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {
continue
}
provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (result, error) in
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (result, error) in
guard let result = result as? URL,
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
completion(nil)

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -72,12 +72,13 @@ class PostService: ObservableObject {
mediaIDs: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.visibility,
visibility: draft.localOnly && mastodonController.instanceFeatures.localOnlyPostsVisibility ? Status.localPostVisibility : draft.visibility.rawValue,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
pollOptions: draft.poll?.pollOptions.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
localOnly: mastodonController.instanceFeatures.localOnlyPosts && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil,
idempotencyKey: draft.id.uuidString
)
}
@ -110,16 +111,12 @@ class PostService: ObservableObject {
do {
(data, utType) = try await getData(for: attachment)
currentStep += 1
} catch let error as AttachmentData.Error {
} catch let error as DraftAttachment.ExportError {
throw Error.attachmentData(index: index, cause: error)
}
do {
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded.id)
currentStep += 1
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded.id)
currentStep += 1
}
return attachments
}
@ -137,10 +134,21 @@ class PostService: ObservableObject {
}
}
private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment {
guard let mimeType = utType.preferredMIMEType else {
throw Error.attachmentMissingMimeType(index: index, type: utType)
}
var filename = "file"
if let ext = utType.preferredFilenameExtension {
filename.append(".\(ext)")
}
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename)
let req = Client.upload(attachment: formAttachment, description: description)
return try await mastodonController.run(req).0
do {
return try await mastodonController.run(req).0
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
}
private func textForPosting() -> String {
@ -168,7 +176,8 @@ class PostService: ObservableObject {
}
enum Error: Swift.Error, LocalizedError {
case attachmentData(index: Int, cause: AttachmentData.Error)
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
case attachmentMissingMimeType(index: Int, type: UTType)
case attachmentUpload(index: Int, cause: Client.Error)
case posting(Client.Error)
@ -176,6 +185,8 @@ class PostService: ObservableObject {
switch self {
case let .attachmentData(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .attachmentMissingMimeType(index: index, type: type):
return "Attachment \(index + 1): unknown MIME type for \(type.identifier)"
case let .attachmentUpload(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .posting(error):

View File

@ -15,7 +15,8 @@ public protocol ComposeMastodonContext {
var instanceFeatures: InstanceFeatures { get }
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void)
func getCustomEmojis() async -> [Emoji]
@MainActor
func searchCachedAccounts(query: String) -> [AccountProtocol]

View File

@ -49,7 +49,9 @@ class AttachmentRowController: ViewController {
private func removeAttachment() {
withAnimation {
parent.draft.attachments.remove(attachment)
var newAttachments = parent.draft.draftAttachments
newAttachments.removeAll(where: { $0.id == attachment.id })
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
}
}
@ -70,8 +72,8 @@ class AttachmentRowController: ViewController {
private func recognizeText() {
descriptionMode = .recognizingText
DispatchQueue.global(qos: .userInitiated).async {
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
DispatchQueue.main.async {
let data: Data
switch result {
case .success((let d, _)):
@ -134,7 +136,7 @@ class AttachmentRowController: ViewController {
.overlay {
thumbnailFocusedOverlay
}
.frame(width: 80, height: 80)
.frame(width: thumbnailSize, height: thumbnailSize)
.onTapGesture {
textEditorFocused = false
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
@ -160,7 +162,7 @@ class AttachmentRowController: ViewController {
switch controller.descriptionMode {
case .allowEntry:
InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80)
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
.focused($textEditorFocused)
@ -175,11 +177,27 @@ class AttachmentRowController: ViewController {
Text(error.localizedDescription)
}
.onAppear(perform: controller.updateAttachmentDescriptionState)
#if os(visionOS)
.onChange(of: textEditorFocused) {
if !textEditorFocused && controller.focusAttachmentOnTextEditorUnfocus {
controller.focusAttachment()
}
}
#else
.onChange(of: textEditorFocused) { newValue in
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
controller.focusAttachment()
}
}
#endif
}
private var thumbnailSize: CGFloat {
#if os(visionOS)
120
#else
80
#endif
}
@ViewBuilder
@ -206,6 +224,7 @@ extension AttachmentRowController {
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, *) {

View File

@ -40,9 +40,14 @@ class AttachmentThumbnailController: ViewController {
case .video, .gifv:
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
#endif
case .audio, .unknown:
break
@ -87,29 +92,39 @@ class AttachmentThumbnailController: ViewController {
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
#endif
} else if let data = try? Data(contentsOf: url) {
if type == .gif {
self.gifController = GIFController(gifData: data)
} else if type.conforms(to: .image),
let image = UIImage(data: data) {
if fullSize {
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
// crashing share extension. see FB12186346
// if fullSize {
image.prepareForDisplay { prepared in
DispatchQueue.main.async {
self.image = prepared
self.image = image
}
}
} else {
image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
DispatchQueue.main.async {
self.image = prepared
}
}
}
// } else {
// image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
// DispatchQueue.main.async {
// self.image = prepared
// }
// }
// }
}
}
case .none:
break
}
}

View File

@ -85,8 +85,11 @@ class AttachmentsListController: ViewController {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
guard self.canAddAttachment else { return }
DispatchQueue.main.async { [weak self] in
guard let self,
self.canAddAttachment else {
return
}
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
@ -96,14 +99,17 @@ class AttachmentsListController: ViewController {
}
private func addImage() {
parent.deleteDraftOnDisappear = false
parent.config.presentAssetPicker?({ results in
self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider))
})
}
private func addDrawing() {
parent.deleteDraftOnDisappear = false
parent.config.presentDrawing?(PKDrawing()) { drawing in
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
attachment.id = UUID()
attachment.drawing = drawing
attachment.draft = self.draft
self.draft.attachments.add(attachment)
@ -128,9 +134,9 @@ class AttachmentsListController: ViewController {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
attachmentsList
Group {
attachmentsList
if controller.parent.config.presentAssetPicker != nil {
addImageButton
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
@ -144,6 +150,10 @@ class AttachmentsListController: ViewController {
togglePollButton
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
#if os(visionOS)
.buttonStyle(.bordered)
.labelStyle(AttachmentButtonLabelStyle())
#endif
}
private var attachmentsList: some View {
@ -243,3 +253,11 @@ fileprivate struct SheetOrPopover<V: View>: ViewModifier {
}
}
}
@available(visionOS 1.0, *)
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
DefaultLabelStyle().makeBody(configuration: configuration)
.foregroundStyle(.white)
}
}

View File

@ -8,6 +8,7 @@
import SwiftUI
import Pachyderm
import Combine
import TuskerComponents
class AutocompleteEmojisController: ViewController {
unowned let composeController: ComposeController
@ -18,19 +19,7 @@ class AutocompleteEmojisController: ViewController {
@Published var expanded = false
@Published var emojis: [Emoji] = []
var emojisBySection: [String: [Emoji]] {
var values: [String: [Emoji]] = [:]
for emoji in emojis {
let key = emoji.category ?? ""
if !values.keys.contains(key) {
values[key] = [emoji]
} else {
values[key]!.append(emoji)
}
}
return values
}
@Published var emojisBySection: [String: [Emoji]] = [:]
init(composeController: ComposeController) {
self.composeController = composeController
@ -48,19 +37,15 @@ class AutocompleteEmojisController: ViewController {
.removeDuplicates()
.sink { [unowned self] query in
self.searchTask?.cancel()
self.searchTask = Task {
await self.queryChanged(query)
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
var emojis = await withCheckedContinuation { continuation in
composeController.mastodonController.getCustomEmojis {
continuation.resume(returning: $0)
}
}
var emojis = await composeController.mastodonController.getCustomEmojis()
guard !Task.isCancelled else {
return
}
@ -77,11 +62,20 @@ class AutocompleteEmojisController: ViewController {
var shortcodes = Set<String>()
var newEmojis = [Emoji]()
var newEmojisBySection = [String: [Emoji]]()
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
newEmojis.append(emoji)
shortcodes.insert(emoji.shortcode)
let category = emoji.category ?? ""
if newEmojisBySection.keys.contains(category) {
newEmojisBySection[category]!.append(emoji)
} else {
newEmojisBySection[category] = [emoji]
}
}
self.emojis = newEmojis
self.emojisBySection = newEmojisBySection
}
private func toggleExpanded() {
@ -160,7 +154,7 @@ class AutocompleteEmojisController: ViewController {
private var horizontalScrollView: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
LazyHStack(spacing: 8) {
ForEach(controller.emojis, id: \.shortcode) { emoji in
Button(action: { controller.autocomplete(with: emoji) }) {
HStack(spacing: 4) {
@ -174,8 +168,6 @@ class AutocompleteEmojisController: ViewController {
.frame(height: emojiSize)
}
.animation(.linear(duration: 0.2), value: controller.emojis)
Spacer(minLength: emojiSize)
}
.padding(.horizontal, 8)
.frame(height: emojiSize + 16)

View File

@ -8,6 +8,7 @@
import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
class AutocompleteHashtagsController: ViewController {
unowned let composeController: ComposeController
@ -34,8 +35,8 @@ class AutocompleteHashtagsController: ViewController {
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
.sink { [unowned self] query in
self.searchTask?.cancel()
self.searchTask = Task {
await self.queryChanged(query)
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}

View File

@ -36,8 +36,9 @@ class AutocompleteMentionsController: ViewController {
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
.sink { [unowned self] query in
self.searchTask?.cancel()
self.searchTask = Task {
await self.queryChanged(query)
// weak in case the autocomplete controller is dealloc'd racing with the task starting
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}

View File

@ -10,6 +10,7 @@ import Combine
import Pachyderm
import TuskerComponents
import MatchedGeometryPresentation
import CoreData
public final class ComposeController: ViewController {
public typealias FetchAttachment = (URL) async -> UIImage?
@ -19,7 +20,11 @@ public final class ComposeController: ViewController {
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
public typealias EmojiImageView = (Emoji) -> AnyView
@Published public private(set) var draft: Draft
@Published public private(set) var draft: Draft {
didSet {
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
}
}
@Published public var config: ComposeUIConfig
@Published public var mastodonController: ComposeMastodonContext
let fetchAvatar: AvatarImageView.FetchAvatar
@ -54,7 +59,10 @@ public final class ComposeController: ViewController {
@Published public private(set) var didPostSuccessfully = false
@Published var hasChangedLanguageSelection = false
var isPosting: Bool {
private var isDisappearing = false
private var userConfirmedDelete = false
public var isPosting: Bool {
poster != nil
}
@ -102,6 +110,7 @@ public final class ComposeController: ViewController {
emojiImageView: @escaping EmojiImageView
) {
self.draft = draft
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
self.config = config
self.mastodonController = mastodonController
self.fetchAvatar = fetchAvatar
@ -119,6 +128,7 @@ public final class ComposeController: ViewController {
if #available(iOS 16.0, *) {
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)
}
public var view: some View {
@ -129,6 +139,15 @@ public final class ComposeController: ViewController {
.environment(\.composeUIConfig, config)
}
@MainActor
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
deleted.contains(where: { $0.objectID == self.draft.objectID }),
!isDisappearing {
self.config.dismiss(.cancel)
}
}
public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
return false
@ -172,6 +191,7 @@ public final class ComposeController: ViewController {
@MainActor
func cancel(deleteDraft: Bool) {
deleteDraftOnDisappear = true
userConfirmedDelete = deleteDraft
config.dismiss(.cancel)
}
@ -193,6 +213,7 @@ public final class ComposeController: ViewController {
do {
try await poster.post()
deleteDraftOnDisappear = true
didPostSuccessfully = true
// wait .25 seconds so the user can see the progress bar has completed
@ -216,16 +237,18 @@ public final class ComposeController: ViewController {
}
func selectDraft(_ newDraft: Draft) {
if !self.draft.hasContent {
DraftsPersistentContainer.shared.viewContext.delete(self.draft)
let oldDraft = self.draft
self.draft = newDraft
if !oldDraft.hasContent {
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
}
DraftsPersistentContainer.shared.save()
self.draft = newDraft
}
func onDisappear() {
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully) {
isDisappearing = true
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) {
DraftsPersistentContainer.shared.viewContext.delete(draft)
}
DraftsPersistentContainer.shared.save()
@ -252,7 +275,9 @@ public final class ComposeController: ViewController {
@OptionalObservedObject var poster: PostService?
@EnvironmentObject var controller: ComposeController
@EnvironmentObject var draft: Draft
#if !os(visionOS)
@StateObject private var keyboardReader = KeyboardReader()
#endif
@State private var globalFrameOutsideList = CGRect.zero
init(poster: PostService?) {
@ -295,16 +320,25 @@ public final class ComposeController: ViewController {
.transition(.move(edge: .bottom))
.animation(.default, value: controller.currentInput?.autocompleteState)
#if !os(visionOS)
ControllerView(controller: { controller.toolbarController })
#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))
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
ToolbarItem(placement: .confirmationAction) { postButton }
#if os(visionOS)
ToolbarItem(placement: .bottomOrnament) {
ControllerView(controller: { controller.toolbarController })
}
#endif
}
.background(GeometryReader { proxy in
Color.clear
@ -321,8 +355,10 @@ public final class ComposeController: ViewController {
}, message: { error in
Text(error.localizedDescription)
})
.matchedGeometryPresentation(id: Binding(get: {
controller.focusedAttachment?.0.id
.matchedGeometryPresentation(id: Binding(get: { () -> UUID?? in
let id = controller.focusedAttachment?.0.id
// this needs to be a double optional, since the type used for for the presentationID in the geom source is a UUID?
return id.map { Optional.some($0) }
}, set: {
if $0 == nil {
controller.focusedAttachment = nil
@ -352,6 +388,8 @@ public final class ComposeController: ViewController {
rowTopInset: 8,
globalFrameOutsideList: globalFrameOutsideList
)
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
.id(id)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
@ -392,7 +430,9 @@ public final class ComposeController: ViewController {
.listRowBackground(config.backgroundColor)
}
.listStyle(.plain)
#if !os(visionOS)
.scrollDismissesKeyboardInteractivelyIfAvailable()
#endif
.disabled(controller.isPosting)
}
@ -402,6 +442,7 @@ public final class ComposeController: ViewController {
// otherwise all Buttons in the nav bar are made semibold
.font(.system(size: 17, weight: .regular))
}
.disabled(controller.isPosting)
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
// edit drafts can't be saved
if draft.editedStatusID == nil {
@ -434,6 +475,7 @@ public final class ComposeController: ViewController {
}
}
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private var keyboardInset: CGFloat {
if #unavailable(iOS 16.0),
@ -444,6 +486,7 @@ public final class ComposeController: ViewController {
return 0
}
}
#endif
}
}

View File

@ -7,6 +7,7 @@
import SwiftUI
import TuskerComponents
import CoreData
class DraftsController: ViewController {
@ -152,9 +153,11 @@ private struct DraftRow: View {
Spacer()
Text(draft.lastModified.formatted(.abbreviatedTimeAgo))
.font(.body)
.foregroundColor(.secondary)
if let lastModified = draft.lastModified {
Text(lastModified.formatted(.abbreviatedTimeAgo))
.font(.body)
.foregroundColor(.secondary)
}
}
}
}

View File

@ -108,7 +108,7 @@ private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
}
struct AttachmentDescriptionTextViewID: Hashable {
let attachmentID: UUID
let attachmentID: UUID!
init(_ attachment: DraftAttachment) {
self.attachmentID = attachment.id

View File

@ -34,11 +34,16 @@ class PollController: ViewController {
}
private func moveOptions(indices: IndexSet, newIndex: Int) {
poll.options.moveObjects(at: indices, to: newIndex)
// see AttachmentsListController.moveAttachments
var array = poll.pollOptions
array.move(fromOffsets: indices, toOffset: newIndex)
poll.options = NSMutableOrderedSet(array: array)
}
private func removeOption(_ option: PollOption) {
poll.options.remove(option)
var array = poll.pollOptions
array.remove(at: poll.options.index(of: option))
poll.options = NSMutableOrderedSet(array: array)
}
private var canAddOption: Bool {
@ -123,9 +128,15 @@ class PollController: ViewController {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.foregroundColor(backgroundColor)
)
#if os(visionOS)
.onChange(of: controller.duration) {
poll.duration = controller.duration.timeInterval
}
#else
.onChange(of: controller.duration) { newValue in
poll.duration = newValue.timeInterval
}
#endif
}
private var backgroundColor: Color {

View File

@ -11,9 +11,6 @@ import TuskerComponents
class ToolbarController: ViewController {
static let height: CGFloat = 44
private static let visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] = Pachyderm.Visibility.allCases.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
}
unowned let parent: ComposeController
@ -48,61 +45,34 @@ class ToolbarController: ViewController {
@EnvironmentObject private var composeController: ComposeController
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
#if !os(visionOS)
@State private var minWidth: CGFloat?
@State private var realWidth: CGFloat?
#endif
var body: some View {
#if os(visionOS)
buttons
#else
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
cwButton
MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
.disabled(draft.editedStatusID != nil)
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker
.padding(.horizontal, -8)
.disabled(draft.editedStatusID != nil)
}
if let currentInput = composeController.currentInput,
currentInput.toolbarElements.contains(.emojiPicker) {
customEmojiButton
}
if let currentInput = composeController.currentInput,
currentInput.toolbarElements.contains(.formattingButtons),
composeController.config.contentType != .plain {
Spacer()
formatButtons
}
Spacer()
if #available(iOS 16.0, *),
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
}
}
.padding(.horizontal, 16)
.frame(minWidth: minWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
realWidth = width
}
})
buttons
.padding(.horizontal, 16)
.frame(minWidth: minWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
realWidth = width
}
})
}
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(height: ToolbarController.height)
.frame(maxWidth: .infinity)
.background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
.overlay(alignment: .top) {
Divider()
.edgesIgnoringSafeArea([.leading, .trailing])
}
.background(GeometryReader { proxy in
Color.clear
@ -111,6 +81,52 @@ class ToolbarController: ViewController {
minWidth = width
}
})
#endif
}
@ViewBuilder
private var buttons: some View {
HStack(spacing: 0) {
cwButton
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
#if !targetEnvironment(macCatalyst) && !os(visionOS)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker
#if targetEnvironment(macCatalyst)
.padding(.leading, 4)
#elseif !os(visionOS)
.padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil)
}
if let currentInput = composeController.currentInput,
currentInput.toolbarElements.contains(.emojiPicker) {
customEmojiButton
}
if let currentInput = composeController.currentInput,
currentInput.toolbarElements.contains(.formattingButtons),
composeController.config.contentType != .plain {
Spacer()
formatButtons
}
Spacer()
if #available(iOS 16.0, *),
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
}
}
}
private var cwButton: some View {
@ -120,6 +136,29 @@ class ToolbarController: ViewController {
.hoverEffect()
}
private var visibilityBinding: Binding<Pachyderm.Visibility> {
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
// changing the visibility when local-only.
if draft.localOnly,
composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility {
return .constant(.public)
} else {
return $draft.visibility
}
}
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
let visibilities: [Pachyderm.Visibility]
if !composeController.mastodonController.instanceFeatures.composeDirectStatuses {
visibilities = [.public, .unlisted, .private]
} else {
visibilities = Pachyderm.Visibility.allCases
}
return visibilities.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
}
}
private var localOnlyPicker: some View {
let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
return MenuPicker(selection: $draft.localOnly, options: [
@ -142,13 +181,8 @@ class ToolbarController: ViewController {
private var formatButtons: some View {
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
Button(action: controller.formatAction(format)) {
if let imageName = format.imageName {
Image(systemName: imageName)
.font(.system(size: imageSize))
} else if let (str, attrs) = format.title {
let container = try! AttributeContainer(attrs, including: \.uiKit)
Text(AttributedString(str, attributes: container))
}
Image(systemName: format.imageName)
.font(.system(size: imageSize))
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)

View File

@ -25,10 +25,11 @@ public class Draft: NSManagedObject, Identifiable {
@NSManaged public var contentWarningEnabled: Bool
@NSManaged public var editedStatusID: String?
@NSManaged public var id: UUID
@NSManaged public var initialContentWarning: String?
@NSManaged public var initialText: String
@NSManaged public var inReplyToID: String?
@NSManaged public var language: String? // ISO 639 language code
@NSManaged public var lastModified: Date
@NSManaged public var lastModified: Date!
@NSManaged public var localOnly: Bool
@NSManaged public var text: String
@NSManaged private var visibilityStr: String
@ -65,7 +66,7 @@ public class Draft: NSManagedObject, Identifiable {
extension Draft {
public var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty) ||
(contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) ||
attachments.count > 0 ||
poll?.hasContent == true
}

View File

@ -18,6 +18,10 @@ private let encoder = PropertyListEncoder()
@objc
public final class DraftAttachment: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<DraftAttachment> {
return NSFetchRequest<DraftAttachment>(entityName: "DraftAttachment")
}
@NSManaged internal var assetID: String?
@NSManaged public var attachmentDescription: String
@NSManaged internal private(set) var drawingData: Data?
@ -26,7 +30,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
@NSManaged public var editedAttachmentURL: URL?
@NSManaged public var fileURL: URL?
@NSManaged internal var fileType: String?
@NSManaged public var id: UUID
@NSManaged public var id: UUID!
@NSManaged internal var draft: Draft
@ -54,7 +58,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
} else if let fileURL, let fileType {
return .file(fileURL, UTType(fileType)!)
} else {
fatalError()
return .none
}
}
@ -72,6 +76,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
case drawing(PKDrawing)
case file(URL, UTType)
case editing(String, Attachment.Kind, URL)
case none
}
public override func prepareForDeletion() {
@ -131,6 +136,9 @@ extension DraftAttachment {
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
private let imageType = UTType.image.identifier
private let heifType = UTType.heif.identifier
private let heicType = UTType.heic.identifier
private let jpegType = UTType.jpeg.identifier
private let pngType = UTType.png.identifier
private let mp4Type = UTType.mpeg4Movie.identifier
@ -142,21 +150,38 @@ extension DraftAttachment: NSItemProviderReading {
// todo: is there a better way of handling movies than manually adding all possible UTI types?
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
[/*typeIdentifier, */gifType, jpegType, pngType, mp4Type, quickTimeType]
[/*typeIdentifier, */ gifType, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType]
}
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
var data = data
var type = UTType(typeIdentifier)!
// the type is .image in certain circumstances:
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
if type == .image,
let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)),
let pngData = image.pngData() {
data = pngData
type = .png
}
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
attachment.id = UUID()
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!)
attachment.fileType = typeIdentifier
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
attachment.fileType = type.identifier
attachment.attachmentDescription = ""
return attachment
}
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
static var attachmentsDirectory: URL {
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
let directoryURL = containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
return containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
}
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
let directoryURL = attachmentsDirectory
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type)
try data.write(to: attachmentURL)
@ -194,7 +219,7 @@ extension DraftAttachment {
options.isNetworkAccessAllowed = true
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
if let exportSession {
Self.exportVideoData(session: exportSession, completion: completion)
Self.exportVideoData(session: exportSession, features: features, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
@ -220,7 +245,7 @@ extension DraftAttachment {
completion(.failure(.noVideoExportSession))
return
}
Self.exportVideoData(session: session, completion: completion)
Self.exportVideoData(session: session, features: features, completion: completion)
} else {
let fileData: Data
do {
@ -238,6 +263,8 @@ extension DraftAttachment {
completion(.success((fileData, type)))
}
}
} else {
completion(.failure(.noData))
}
}
@ -249,20 +276,13 @@ extension DraftAttachment {
var data = data
var type = type
if type != .png && type != .jpeg,
let image = UIImage(data: data) {
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic {
if needsColorSpaceConversion || type == .heic || type == .heif {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png {
@ -276,9 +296,12 @@ extension DraftAttachment {
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
private static func exportVideoData(session: AVAssetExportSession, features: InstanceFeatures, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
if let configuration = features.mediaAttachmentsConfiguration {
session.fileLengthLimit = Int64(configuration.videoSizeLimit)
}
session.exportAsynchronously {
guard session.status == .completed else {
completion(.failure(.videoExport(session.error!)))
@ -301,5 +324,6 @@ extension DraftAttachment {
case noVideoExportSession
case loadingDrawing
case loadingData
case noData
}
}

View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
<attribute name="accountID" attributeType="String"/>
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="initialContentWarning" optional="YES" attributeType="String"/>
<attribute name="initialText" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
@ -38,7 +39,4 @@
<attribute name="text" attributeType="String" defaultValueString=""/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
</entity>
<entity name="TestEntity" representedClassName="TestEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
</model>

View File

@ -81,6 +81,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
contentWarning: String,
inReplyToID: String?,
visibility: Visibility,
language: String?,
localOnly: Bool
) -> Draft {
let draft = Draft(context: viewContext)
@ -88,9 +89,11 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.text = text
draft.initialText = text
draft.contentWarning = contentWarning
draft.initialContentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID
draft.visibility = visibility
draft.language = language
draft.localOnly = localOnly
save()
return draft
@ -112,6 +115,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.initialText = source.text
draft.contentWarning = source.spoilerText
draft.contentWarningEnabled = !source.spoilerText.isEmpty
draft.initialContentWarning = source.spoilerText
draft.inReplyToID = inReplyToID
draft.visibility = visibility
draft.localOnly = localOnly
@ -157,6 +161,29 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.attachments.add(draftAttachment)
}
public func removeOrphanedAttachments(completion: @escaping () -> Void) {
guard let files = try? FileManager.default.contentsOfDirectory(at: DraftAttachment.attachmentsDirectory, includingPropertiesForKeys: nil),
!files.isEmpty else {
return
}
performBackgroundTask { context in
let allAttachmentsReq = DraftAttachment.fetchRequest()
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
return
}
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
for url in orphaned {
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
}
}
completion()
}
}
@objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return

View File

@ -5,6 +5,8 @@
// Created by Shadowfacts on 3/7/23.
//
#if !os(visionOS)
import UIKit
import Combine
@ -37,3 +39,5 @@ class KeyboardReader: ObservableObject {
}
}
}
#endif

View File

@ -1,278 +0,0 @@
//
// AttachmentData.swift
// ComposeUI
//
// Created by Shadowfacts on 1/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Photos
import UniformTypeIdentifiers
import PencilKit
import InstanceFeatures
enum AttachmentData {
case asset(PHAsset)
case image(Data, originalType: UTType)
case video(URL)
case drawing(PKDrawing)
case gif(Data)
var type: AttachmentType {
switch self {
case let .asset(asset):
return asset.attachmentType!
case .image(_, originalType: _):
return .image
case .video(_):
return .video
case .drawing(_):
return .image
case .gif(_):
return .image
}
}
var isAsset: Bool {
switch self {
case .asset(_):
return true
default:
return false
}
}
var canSaveToDraft: Bool {
switch self {
case .video(_):
return false
default:
return true
}
}
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
switch self {
case let .image(originalData, originalType):
let data: Data
let type: UTType
switch originalType {
case .png, .jpeg:
data = originalData
type = originalType
default:
let image = UIImage(data: originalData)!
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
case let .asset(asset):
if asset.mediaType == .image {
let options = PHImageRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
options.resizeMode = .none
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
guard let data = data, let dataUTI = dataUTI else {
completion(.failure(.missingData))
return
}
let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
}
} else if asset.mediaType == .video {
let options = PHVideoRequestOptions()
options.deliveryMode = .automatic
options.isNetworkAccessAllowed = true
options.version = .current
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
if let exportSession = exportSession {
AttachmentData.exportVideoData(session: exportSession, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
completion(.failure(.noVideoExportSession))
}
}
} else {
fatalError("assetType must be either image or video")
}
case let .video(url):
let asset = AVURLAsset(url: url)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
completion(.failure(.noVideoExportSession))
return
}
AttachmentData.exportVideoData(session: session, completion: completion)
case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(.success((image.pngData()!, .png)))
case let .gif(data):
completion(.success((data, .gif)))
}
}
private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
guard !skipAllConversion else {
return (data, type)
}
var data = data
var type = type
let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
} else {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
type = .jpeg
}
}
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
session.exportAsynchronously {
guard session.status == .completed else {
completion(.failure(.videoExport(session.error!)))
return
}
do {
let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, .mpeg4Movie)))
} catch {
completion(.failure(.videoExport(error)))
}
}
}
enum AttachmentType {
case image, video
}
enum Error: Swift.Error, LocalizedError {
case missingData
case videoExport(Swift.Error)
case noVideoExportSession
var localizedDescription: String {
switch self {
case .missingData:
return "Missing Data"
case .videoExport(let error):
return "Exporting video: \(error)"
case .noVideoExportSession:
return "Couldn't create video export session"
}
}
}
}
extension PHAsset {
var attachmentType: AttachmentData.AttachmentType? {
switch self.mediaType {
case .image:
return .image
case .video:
return .video
default:
return nil
}
}
}
extension AttachmentData: Codable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .asset(asset):
try container.encode("asset", forKey: .type)
try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
case let .image(originalData, originalType):
try container.encode("image", forKey: .type)
try container.encode(originalType, forKey: .imageType)
try container.encode(originalData, forKey: .imageData)
case .video(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded"))
case let .drawing(drawing):
try container.encode("drawing", forKey: .type)
let drawingData = drawing.dataRepresentation()
try container.encode(drawingData, forKey: .drawing)
case .gif(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded"))
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(String.self, forKey: .type) {
case "asset":
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else {
throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier")
}
self = .asset(asset)
case "image":
let data = try container.decode(Data.self, forKey: .imageData)
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
self = .image(data, originalType: type)
} else {
guard let image = UIImage(data: data) else {
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
}
let jpegData = image.jpegData(compressionQuality: 1)!
self = .image(jpegData, originalType: .jpeg)
}
case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing)
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
}
}
enum CodingKeys: CodingKey {
case type
case imageData
case imageType
/// The local identifier of the PHAsset for this attachment
case assetIdentifier
/// The PKDrawing object for this attachment.
case drawing
}
}
extension AttachmentData: Equatable {
static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool {
switch (lhs, rhs) {
case let (.asset(a), .asset(b)):
return a.localIdentifier == b.localIdentifier
case let (.image(a, originalType: aType), .image(b, originalType: bType)):
return a == b && aType == bType
case let (.video(a), .video(b)):
return a == b
case let (.drawing(a), .drawing(b)):
return a == b
default:
return false
}
}
}

View File

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

View File

@ -11,7 +11,7 @@ import PencilKit
extension PKDrawing {
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
func imageInLightMode(from rect: CGRect, scale: CGFloat = 1) -> UIImage {
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
var drawingImage: UIImage!
lightTraitCollection.performAsCurrent {

View File

@ -1,36 +0,0 @@
//
// File.swift
//
//
// Created by Shadowfacts on 4/22/23.
//
import SwiftUI
struct TestView: View {
@State var manager = DraftsPersistentContainer()
var body: some View {
VStack {
Button("Add") {
let entity = TestEntity(context: manager.viewContext)
entity.id = UUID()
try! manager.viewContext.save()
}
InnerView()
.environment(\.managedObjectContext, manager.viewContext)
}
}
}
struct InnerView: View {
@FetchRequest(sortDescriptors: []) var results: FetchedResults<TestEntity>
var body: some View {
List {
ForEach(results) { result in
Text(result.id?.uuidString ?? "<nil>")
}
}
}
}

View File

@ -8,6 +8,11 @@
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 {
@ -17,4 +22,5 @@ extension View {
self
}
}
#endif
}

View File

@ -22,13 +22,21 @@ struct InlineAttachmentDescriptionView: View {
self.minHeight = minHeight
}
private var placeholderOffset: CGSize {
#if os(visionOS)
CGSize(width: 8, height: 8)
#else
CGSize(width: 4, height: 8)
#endif
}
var body: some View {
ZStack(alignment: .topLeading) {
if attachment.attachmentDescription.isEmpty {
placeholder
.font(.body)
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
.offset(placeholderOffset)
}
WrappedTextView(
@ -84,6 +92,10 @@ private struct WrappedTextView: UIViewRepresentable {
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.textContainer.lineBreakMode = .byWordWrapping
#if os(visionOS)
view.borderStyle = .roundedRect
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
#endif
return view
}

View File

@ -52,12 +52,19 @@ struct EmojiTextField: UIViewRepresentable {
if text != uiView.text {
uiView.text = text
}
if placeholder != uiView.attributedPlaceholder?.string {
uiView.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
.foregroundColor: UIColor.secondaryLabel,
])
}
context.coordinator.text = $text
context.coordinator.maxLength = maxLength
context.coordinator.focusNextView = focusNextView
#if !os(visionOS)
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
#endif
if becomeFirstResponder?.wrappedValue == true {
DispatchQueue.main.async {

View File

@ -22,14 +22,21 @@ struct LanguagePicker: View {
}
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
guard let bcp47Lang = mode.primaryLanguage else {
guard let bcp47Lang = mode.primaryLanguage,
!bcp47Lang.isEmpty else {
return nil
}
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: 3)]
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: min(3, bcp47Lang.count))]
if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
}
let code = Locale.LanguageCode(String(maybeIso639Code))
let identifier = String(maybeIso639Code)
// mul (for multiple languages) and unk (unknown) are ISO codes, but not ones that akkoma permits, so we ignore them on all platforms
guard identifier != "mul",
identifier != "und" else {
return nil
}
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage {
return code
} else {
@ -38,13 +45,10 @@ struct LanguagePicker: View {
}
private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first {
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage {
return code
} else {
return nil
}
if let identifier = Locale.preferredLanguages.first,
case let code = Locale.LanguageCode(identifier),
code.isISOLanguage {
return code
} else {
return nil
}
@ -65,6 +69,8 @@ struct LanguagePicker: View {
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
}
.accessibilityLabel("Post Language")
.padding(5)
.hoverEffect()
.sheet(isPresented: $isShowingSheet) {
NavigationStack {
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
@ -123,7 +129,9 @@ private struct LanguagePickerList: View {
.scrollContentBackground(.hidden)
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
.searchable(text: $query)
#if !os(visionOS)
.scrollDismissesKeyboard(.interactively)
#endif
.navigationTitle("Post Language")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -137,20 +145,32 @@ private struct LanguagePickerList: View {
// make sure recents always contains the currently selected lang
let recents = addRecentLang(languageCode)
recentLangs = recents
.filter { $0 != "mul" && $0 != "und" }
.map { Lang(code: .init($0)) }
.sorted { $0.name < $1.name }
langs = Locale.LanguageCode.isoLanguageCodes
.filter { $0.identifier != "mul" && $0.identifier != "und" }
.map { Lang(code: $0) }
.sorted { $0.name < $1.name }
}
#if os(visionOS)
.onChange(of: query, initial: true) {
filteredLangsChanged(query: query)
}
#else
.onChange(of: query) { newValue in
if newValue.isEmpty {
filteredLangs = nil
} else {
filteredLangs = langs.filter {
$0.name.localizedCaseInsensitiveContains(newValue) || $0.code.identifier.localizedCaseInsensitiveContains(newValue)
}
filteredLangsChanged(query: newValue)
}
#endif
}
private func filteredLangsChanged(query: String) {
if query.isEmpty {
filteredLangs = nil
} else {
filteredLangs = langs.filter {
$0.name.localizedCaseInsensitiveContains(query) || $0.code.identifier.localizedCaseInsensitiveContains(query)
}
}
}

View File

@ -23,19 +23,41 @@ struct MainTextView: View {
controller.config
}
private var placeholderOffset: CGSize {
#if os(visionOS)
CGSize(width: 8, height: 8)
#else
CGSize(width: 4, height: 8)
#endif
}
private var textViewBackgroundColor: UIColor? {
#if os(visionOS)
nil
#else
colorScheme == .dark ? UIColor(config.fillColor) : .secondarySystemBackground
#endif
}
var body: some View {
ZStack(alignment: .topLeading) {
colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground)
MainWrappedTextViewRepresentable(
text: $draft.text,
backgroundColor: textViewBackgroundColor,
becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder,
updateSelection: $updateSelection,
textDidChange: textDidChange
)
if draft.text.isEmpty {
ControllerView(controller: { PlaceholderController() })
.font(.system(size: fontSize))
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
.offset(placeholderOffset)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, updateSelection: $updateSelection, textDidChange: textDidChange)
}
.frame(height: effectiveHeight)
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
@ -62,6 +84,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
let backgroundColor: UIColor?
@Binding var becomeFirstResponder: Bool
@Binding var updateSelection: ((UITextView) -> Void)?
let textDidChange: (UITextView) -> Void
@ -74,10 +97,16 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
context.coordinator.textView = textView
textView.delegate = context.coordinator
textView.isEditable = true
textView.backgroundColor = .clear
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
textView.adjustsFontForContentSizeCategory = true
textView.textContainer.lineBreakMode = .byWordWrapping
#if os(visionOS)
textView.borderStyle = .roundedRect
// yes, the X inset is 4 less than the placeholder offset
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
#endif
return textView
}
@ -90,6 +119,8 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
uiView.isEditable = isEnabled
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
uiView.backgroundColor = backgroundColor
context.coordinator.text = $text
if let updateSelection {
@ -228,11 +259,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
if range.length > 0 {
let formatMenu = suggestedActions[index] as! UIMenu
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
var image: UIImage?
if let imageName = fmt.imageName {
image = UIImage(systemName: imageName)
}
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
self?.applyFormat(fmt)
}
})

View File

@ -18,10 +18,6 @@ struct PollOptionView: View {
self.remove = remove
}
private var optionIndex: Int {
poll.options.index(of: option)
}
var body: some View {
HStack(spacing: 4) {
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor)
@ -41,7 +37,8 @@ struct PollOptionView: View {
}
private var textField: some View {
let placeholder = "Option \(optionIndex + 1)"
let index = poll.options.index(of: option)
let placeholder = index != NSNotFound ? "Option \(index + 1)" : ""
let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars
return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength)
}

View File

@ -7,9 +7,8 @@
import UIKit
@MainActor
public protocol DuckableViewController: UIViewController {
var duckableDelegate: DuckableViewControllerDelegate? { get set }
func duckableViewControllerShouldDuck() -> DuckAttemptAction
func duckableViewControllerMayAttemptToDuck()
@ -26,10 +25,6 @@ extension DuckableViewController {
public func duckableViewControllerDidFinishAnimatingDuck() {}
}
public protocol DuckableViewControllerDelegate: AnyObject {
func duckableViewControllerWillDismiss(animated: Bool)
}
public enum DuckAttemptAction {
case duck
case dismiss

View File

@ -11,7 +11,7 @@ let duckedCornerRadius: CGFloat = 10
let detentHeight: CGFloat = 44
@available(iOS 16.0, *)
public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
public class DuckableContainerViewController: UIViewController {
public let child: UIViewController
private var bottomConstraint: NSLayoutConstraint!
@ -62,7 +62,9 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
guard case .idle = state else {
if animated,
case .ducked(_, placeholder: let placeholder) = state {
#if !os(visionOS)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
#endif
let origConstant = placeholder.topConstraint.constant
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
@ -87,7 +89,6 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
}
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.duckableDelegate = self
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = self
present(viewController, animated: animated) {
@ -96,7 +97,10 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
}
}
public func duckableViewControllerWillDismiss(animated: Bool) {
func dismissalTransitionWillBegin() {
guard case .presentingDucked(_, _) = state else {
return
}
state = .idle
bottomConstraint.isActive = false
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
@ -144,7 +148,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
case .block:
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
case .dismiss:
duckableViewControllerWillDismiss(animated: true)
// duckableViewControllerWillDismiss()
dismiss(animated: true)
}
}
@ -189,7 +193,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
@available(iOS 16.0, *)
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
let controller = UISheetPresentationController(presentedViewController: presented, presenting: presenting)
let controller = DuckableSheetPresentationController(presentedViewController: presented, presenting: presenting)
controller.delegate = self
controller.prefersGrabberVisible = true
controller.selectedDetentIdentifier = .large
@ -215,6 +219,14 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate
}
}
@available(iOS 16.0, *)
class DuckableSheetPresentationController: UISheetPresentationController {
override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
(self.delegate as! DuckableContainerViewController).dismissalTransitionWillBegin()
}
}
@available(iOS 16.0, *)
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {

8
Packages/GalleryVC/.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,26 @@
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "GalleryVC",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "GalleryVC",
targets: ["GalleryVC"]),
],
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: "GalleryVC"),
.testTarget(
name: "GalleryVCTests",
dependencies: ["GalleryVC"]),
]
)

View File

@ -0,0 +1,46 @@
//
// GalleryContentViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/17/24.
//
import UIKit
@MainActor
public protocol GalleryContentViewController: UIViewController {
var container: GalleryContentViewControllerContainer? { get set }
var contentSize: CGSize { get }
var activityItemsForSharing: [Any] { get }
var caption: String? { get }
var contentOverlayAccessoryViewController: UIViewController? { get }
var bottomControlsAccessoryViewController: UIViewController? { get }
var canAnimateFromSourceView: Bool { get }
func setControlsVisible(_ visible: Bool, animated: Bool)
func galleryContentDidAppear()
func galleryContentWillDisappear()
}
public extension GalleryContentViewController {
var contentOverlayAccessoryViewController: UIViewController? {
nil
}
var bottomControlsAccessoryViewController: UIViewController? {
nil
}
var canAnimateFromSourceView: Bool {
true
}
func setControlsVisible(_ visible: Bool, animated: Bool) {
}
func galleryContentDidAppear() {
}
func galleryContentWillDisappear() {
}
}

View File

@ -0,0 +1,18 @@
//
// GalleryContentViewControllerContainer.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import Foundation
@MainActor
public protocol GalleryContentViewControllerContainer: AnyObject {
var galleryControlsVisible: Bool { get }
func setGalleryContentLoading(_ loading: Bool)
func galleryContentChanged()
func disableGalleryScrollAndZoom()
func setGalleryControlsVisible(_ visible: Bool, animated: Bool)
}

View File

@ -0,0 +1,22 @@
//
// GalleryDataSource.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
@MainActor
public protocol GalleryDataSource {
func galleryItemsCount() -> Int
func galleryContentViewController(forItemAt index: Int) -> GalleryContentViewController
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView?
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]?
}
public extension GalleryDataSource {
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? {
nil
}
}

View File

@ -0,0 +1,140 @@
//
// GalleryDismissAnimationController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/1/24.
//
import UIKit
class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
private let sourceView: UIView
private let interactiveTranslation: CGPoint?
private let interactiveVelocity: CGPoint?
init(sourceView: UIView, interactiveTranslation: CGPoint?, interactiveVelocity: CGPoint?) {
self.sourceView = sourceView
self.interactiveTranslation = interactiveTranslation
self.interactiveVelocity = interactiveVelocity
}
func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval {
return 0.3
}
func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
guard let to = transitionContext.viewController(forKey: .to),
let from = transitionContext.viewController(forKey: .from) as? GalleryViewController else {
fatalError()
}
let itemViewController = from.currentItemViewController
if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
animateCrossFadeTransition(using: transitionContext)
return
}
let container = transitionContext.containerView
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
let origSourceTransform = sourceView.transform
let appliedSourceToDestTransform: Bool
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
appliedSourceToDestTransform = true
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
let sourceToDestTransform = origSourceTransform
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
.scaledBy(x: scale, y: scale)
sourceView.transform = sourceToDestTransform
} else {
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()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true
container.addSubview(content.view)
content.view.frame = destFrameInContainer
content.view.layer.opacity = 1
container.layoutIfNeeded()
let duration = self.transitionDuration(using: transitionContext)
var initialVelocity: CGVector
if let interactiveVelocity,
let interactiveTranslation,
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
let yDistance = sourceFrameInContainer.midY - destFrameInContainer.midY
initialVelocity = CGVector(
dx: xDistance == 0 ? 0 : interactiveVelocity.x / xDistance,
dy: yDistance == 0 ? 0 : interactiveVelocity.y / yDistance
)
} else {
initialVelocity = .zero
}
initialVelocity.dx = max(-10, min(10, initialVelocity.dx))
initialVelocity.dy = max(-10, min(10, initialVelocity.dy))
// no bounce for the dismiss animation
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: initialVelocity)
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
animator.addAnimations {
from.view.layer.opacity = 0
if appliedSourceToDestTransform {
self.sourceView.transform = origSourceTransform
}
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
itemViewController.setControlsVisible(false, animated: false)
}
animator.addCompletion { _ in
transitionContext.completeTransition(true)
}
animator.startAnimation()
}
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to) else {
return
}
toVC.view.frame = transitionContext.containerView.bounds
fromVC.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(toVC.view)
transitionContext.containerView.addSubview(fromVC.view)
let duration = transitionDuration(using: transitionContext)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
animator.addAnimations {
fromVC.view.alpha = 0
}
animator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
animator.startAnimation()
}
}

View File

@ -0,0 +1,83 @@
//
// GalleryDismissInteraction.swift
// GalleryVC
//
// Created by Shadowfacts on 3/1/24.
//
import UIKit
@MainActor
class GalleryDismissInteraction: NSObject {
private unowned let viewController: GalleryViewController
private var content: GalleryContentViewController?
private var origContentFrameInGallery: CGRect?
private var origControlsVisible: Bool?
private(set) var isActive = false
private(set) var dismissVelocity: CGPoint?
private(set) var dismissTranslation: CGPoint?
init(viewController: GalleryViewController) {
self.viewController = viewController
super.init()
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
panRecognizer.delegate = self
panRecognizer.allowedScrollTypesMask = .continuous
viewController.view.addGestureRecognizer(panRecognizer)
}
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
isActive = true
origContentFrameInGallery = viewController.view.convert(viewController.currentItemViewController.content.view.bounds, from: viewController.currentItemViewController.content.view)
content = viewController.currentItemViewController.takeContent()
content!.view.translatesAutoresizingMaskIntoConstraints = true
content!.view.frame = origContentFrameInGallery!
viewController.view.addSubview(content!.view)
origControlsVisible = viewController.currentItemViewController.controlsVisible
if origControlsVisible! {
viewController.currentItemViewController.setControlsVisible(false, animated: true)
}
case .changed:
let translation = recognizer.translation(in: viewController.view)
content!.view.frame = origContentFrameInGallery!.offsetBy(dx: translation.x, dy: translation.y)
case .ended:
let translation = recognizer.translation(in: viewController.view)
let velocity = recognizer.velocity(in: viewController.view)
dismissVelocity = velocity
dismissTranslation = translation
viewController.dismiss(animated: true)
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
isActive = false
default:
break
}
}
}
extension GalleryDismissInteraction: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let itemVC = viewController.currentItemViewController
if viewController.galleryDataSource.galleryContentTransitionSourceView(forItemAt: itemVC.itemIndex) == nil {
return false
} else if itemVC.scrollView.zoomScale > itemVC.scrollView.minimumZoomScale {
return false
} else if !itemVC.scrollAndZoomEnabled {
return false
} else {
return true
}
}
}

View File

@ -0,0 +1,566 @@
//
// GalleryItemViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
import AVFoundation
@MainActor
protocol GalleryItemViewControllerDelegate: AnyObject {
func isGalleryBeingPresented() -> Bool
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
func galleryItemClose(_ item: GalleryItemViewController)
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
}
class GalleryItemViewController: UIViewController {
private weak var delegate: GalleryItemViewControllerDelegate?
let itemIndex: Int
let content: GalleryContentViewController
private var overlayVC: UIViewController?
private var activityIndicator: UIActivityIndicatorView?
private(set) var scrollView: UIScrollView!
private var topControlsView: UIView!
private var shareButton: UIButton!
private var shareButtonLeadingConstraint: NSLayoutConstraint!
private var shareButtonTopConstraint: NSLayoutConstraint!
private var closeButtonTrailingConstraint: NSLayoutConstraint!
private var closeButtonTopConstraint: NSLayoutConstraint!
private var bottomControlsView: UIStackView!
private(set) var captionTextView: UITextView!
private var singleTap: UITapGestureRecognizer!
private var doubleTap: UITapGestureRecognizer!
private var contentViewLeadingConstraint: NSLayoutConstraint?
private var contentViewTopConstraint: NSLayoutConstraint?
private(set) var controlsVisible: Bool = true
private(set) var scrollAndZoomEnabled = true
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible
}
init(delegate: GalleryItemViewControllerDelegate, itemIndex: Int, content: GalleryContentViewController) {
self.delegate = delegate
self.itemIndex = itemIndex
self.content = content
super.init(nibName: nil, bundle: nil)
content.container = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
view.addSubview(scrollView)
addContent()
centerContent()
overlayVC = content.contentOverlayAccessoryViewController
if let overlayVC {
overlayVC.view.isHidden = activityIndicator != nil
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayVC.view)
NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: content.view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: content.view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: content.view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: content.view.bottomAnchor),
])
}
topControlsView = UIView()
topControlsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(topControlsView)
var shareConfig = UIButton.Configuration.gray()
shareConfig.cornerStyle = .dynamic
shareConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
shareConfig.baseForegroundColor = .white
shareConfig.image = UIImage(systemName: "square.and.arrow.up")
shareConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
shareButton = UIButton(configuration: shareConfig)
shareButton.addTarget(self, action: #selector(shareButtonPressed), for: .touchUpInside)
shareButton.isPointerInteractionEnabled = true
shareButton.pointerStyleProvider = { button, effect, shape in
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
}
shareButton.preferredBehavioralStyle = .pad
shareButton.translatesAutoresizingMaskIntoConstraints = false
updateShareButton()
topControlsView.addSubview(shareButton)
var closeConfig = UIButton.Configuration.gray()
closeConfig.cornerStyle = .dynamic
closeConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
closeConfig.baseForegroundColor = .white
closeConfig.image = UIImage(systemName: "xmark")
closeConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
let closeButton = UIButton(configuration: closeConfig)
closeButton.addTarget(self, action: #selector(closeButtonPressed), for: .touchUpInside)
closeButton.isPointerInteractionEnabled = true
closeButton.pointerStyleProvider = { button, effect, shape in
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
}
closeButton.preferredBehavioralStyle = .pad
closeButton.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(closeButton)
bottomControlsView = UIStackView()
bottomControlsView.translatesAutoresizingMaskIntoConstraints = false
bottomControlsView.axis = .vertical
bottomControlsView.alignment = .fill
bottomControlsView.backgroundColor = .black.withAlphaComponent(0.5)
view.addSubview(bottomControlsView)
if let controlsAccessory = content.bottomControlsAccessoryViewController {
addChild(controlsAccessory)
bottomControlsView.addArrangedSubview(controlsAccessory.view)
controlsAccessory.didMove(toParent: self)
// Make sure the controls accessory is within the safe area.
let spacer = UIView()
bottomControlsView.addArrangedSubview(spacer)
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
spacerTopConstraint.priority = .init(999)
spacerTopConstraint.isActive = true
}
captionTextView = UITextView()
captionTextView.backgroundColor = .clear
captionTextView.textColor = .white
captionTextView.isEditable = false
captionTextView.isSelectable = true
captionTextView.font = .preferredFont(forTextStyle: .body)
captionTextView.adjustsFontForContentSizeCategory = true
captionTextView.alwaysBounceVertical = true
updateCaptionTextView()
bottomControlsView.addArrangedSubview(captionTextView)
#if targetEnvironment(macCatalyst)
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
#else
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
#endif
closeButtonTrailingConstraint = topControlsView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor)
shareButtonLeadingConstraint = shareButton.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
topControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
topControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
topControlsView.topAnchor.constraint(equalTo: view.topAnchor),
shareButtonLeadingConstraint,
shareButtonTopConstraint,
shareButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
shareButton.widthAnchor.constraint(equalTo: shareButton.heightAnchor),
closeButtonTrailingConstraint,
closeButtonTopConstraint,
closeButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
closeButton.widthAnchor.constraint(equalTo: closeButton.heightAnchor),
bottomControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
bottomControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
bottomControlsView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
captionTextView.heightAnchor.constraint(equalToConstant: 150),
])
updateTopControlsInsets()
singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed))
singleTap.delegate = self
doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed))
doubleTap.delegate = self
doubleTap.numberOfTapsRequired = 2
// This is needed to prevent a delay between tapping a button on and the action firing on Catalyst and Designed for iPad
doubleTap.delaysTouchesEnded = false
// this requirement is needed to make sure the double tap is ever recognized
singleTap.require(toFail: doubleTap)
view.addGestureRecognizer(singleTap)
view.addGestureRecognizer(doubleTap)
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
updateZoomScale(resetZoom: false)
// Ensure the transform is correct if the controls are hidden
setControlsVisible(controlsVisible, animated: false)
updateTopControlsInsets()
}
override func 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()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if controlsVisible && !captionTextView.isHidden {
captionTextView.flashScrollIndicators()
}
}
func takeContent() -> GalleryContentViewController {
content.willMove(toParent: nil)
content.removeFromParent()
content.view.removeFromSuperview()
return content
}
func addContent() {
content.loadViewIfNeeded()
content.setControlsVisible(controlsVisible, animated: false)
content.view.translatesAutoresizingMaskIntoConstraints = false
if content.parent != self {
addChild(content)
content.didMove(toParent: self)
}
if scrollAndZoomEnabled {
scrollView.addSubview(content.view)
contentViewLeadingConstraint = content.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
contentViewLeadingConstraint!.isActive = true
contentViewTopConstraint = content.view.topAnchor.constraint(equalTo: scrollView.topAnchor)
contentViewTopConstraint!.isActive = true
updateZoomScale(resetZoom: true)
} else {
// If the content was previously added, deactivate the old constraints.
contentViewLeadingConstraint?.isActive = false
contentViewTopConstraint?.isActive = false
view.addSubview(content.view)
NSLayoutConstraint.activate([
content.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
content.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
content.view.topAnchor.constraint(equalTo: view.topAnchor),
content.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
if let overlayVC {
NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
content.view.layoutIfNeeded()
}
func setControlsVisible(_ visible: Bool, animated: Bool) {
controlsVisible = visible
guard let topControlsView,
let bottomControlsView else {
return
}
func updateControlsViews() {
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
content.setControlsVisible(visible, animated: animated)
}
if animated {
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
animator.addAnimations(updateControlsViews)
animator.startAnimation()
} else {
updateControlsViews()
}
setNeedsUpdateOfHomeIndicatorAutoHidden()
}
func updateZoomScale(resetZoom: Bool) {
scrollView.contentSize = content.contentSize
guard scrollAndZoomEnabled else {
scrollView.maximumZoomScale = 1
scrollView.minimumZoomScale = 1
scrollView.zoomScale = 1
return
}
guard content.contentSize.width > 0 && content.contentSize.height > 0 else {
return
}
let heightScale = view.bounds.height / content.contentSize.height
let widthScale = view.bounds.width / content.contentSize.width
let minScale = min(widthScale, heightScale)
let maxScale = minScale >= 1 ? minScale + 2 : 2
scrollView.minimumZoomScale = minScale
scrollView.maximumZoomScale = maxScale
if resetZoom {
scrollView.zoomScale = minScale
} else {
scrollView.zoomScale = max(minScale, min(maxScale, scrollView.zoomScale))
}
centerContent()
}
private func centerContent() {
guard scrollAndZoomEnabled else {
return
}
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
// which means it's already been scaled by the zoom factor.
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
contentViewTopConstraint!.constant = yOffset
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
contentViewLeadingConstraint!.constant = xOffset
}
private func updateShareButton() {
shareButton.isEnabled = !content.activityItemsForSharing.isEmpty
}
private func updateCaptionTextView() {
guard let caption = content.caption,
!caption.isEmpty else {
captionTextView.isHidden = true
return
}
captionTextView.text = caption
}
private func updateTopControlsInsets() {
let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
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) {
// the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges
// since the corner radius didn't change
let notchWidth: CGFloat = 210
let earWidth = (view.bounds.width - notchWidth) / 2
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset
} else if islandDeviceTopInsets.contains(view.safeAreaInsets.top) {
shareButtonLeadingConstraint.constant = 24
shareButtonTopConstraint.constant = 24
closeButtonTrailingConstraint.constant = 24
closeButtonTopConstraint.constant = 24
} else {
shareButtonLeadingConstraint.constant = 8
shareButtonTopConstraint.constant = 8
closeButtonTrailingConstraint.constant = 8
closeButtonTopConstraint.constant = 8
}
}
private func zoomRectFor(scale: CGFloat, center: CGPoint) -> CGRect {
var zoomRect = CGRect.zero
zoomRect.size.width = content.view.frame.width / scale
zoomRect.size.height = content.view.frame.height / scale
let newCenter = scrollView.convert(center, to: content.view)
zoomRect.origin.x = newCenter.x - (zoomRect.width / 2)
zoomRect.origin.y = newCenter.y - (zoomRect.height / 2)
return zoomRect
}
private func animateZoomOut() {
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
animator.addAnimations {
self.scrollView.zoomScale = self.scrollView.minimumZoomScale
self.scrollView.layoutIfNeeded()
}
animator.startAnimation()
}
// MARK: Interaction
@objc private func viewPressed() {
if scrollAndZoomEnabled,
scrollView.zoomScale > scrollView.minimumZoomScale {
animateZoomOut()
} else {
setControlsVisible(!controlsVisible, animated: true)
}
}
@objc private func viewDoublePressed(_ recognizer: UITapGestureRecognizer) {
guard scrollAndZoomEnabled else {
return
}
if scrollView.zoomScale <= scrollView.minimumZoomScale {
let point = recognizer.location(in: recognizer.view)
let scale = min(
max(
scrollView.bounds.width / content.contentSize.width,
scrollView.bounds.height / content.contentSize.height,
scrollView.zoomScale + 0.75
),
scrollView.maximumZoomScale
)
let rect = zoomRectFor(scale: scale, center: point)
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
animator.addAnimations {
self.scrollView.zoom(to: rect, animated: false)
self.view.layoutIfNeeded()
}
animator.startAnimation()
} else {
animateZoomOut()
}
}
@objc private func closeButtonPressed() {
delegate?.galleryItemClose(self)
}
@objc private func shareButtonPressed() {
let items = content.activityItemsForSharing
guard !items.isEmpty else {
return
}
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: delegate?.galleryItemApplicationActivities(self))
activityVC.popoverPresentationController?.sourceView = shareButton
present(activityVC, animated: true)
}
}
extension GalleryItemViewController: GalleryContentViewControllerContainer {
var galleryControlsVisible: Bool {
controlsVisible
}
func setGalleryContentLoading(_ loading: Bool) {
if loading {
overlayVC?.view.isHidden = true
if activityIndicator == nil {
let activityIndicator = UIActivityIndicatorView(style: .large)
self.activityIndicator = activityIndicator
activityIndicator.startAnimating()
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
} else {
if let activityIndicator {
// If we're in the middle of the presentation animation,
// wait until it finishes to hide the loading indicator.
// Since the updated content frame won't affect the animation,
// make sure the loading indicator remains visible.
if let delegate,
delegate.isGalleryBeingPresented() {
delegate.addPresentationAnimationCompletion { [unowned self] in
self.setGalleryContentLoading(false)
}
} else {
activityIndicator.removeFromSuperview()
self.activityIndicator = nil
self.overlayVC?.view.isHidden = false
}
}
}
}
func galleryContentChanged() {
updateZoomScale(resetZoom: true)
updateShareButton()
updateCaptionTextView()
}
func disableGalleryScrollAndZoom() {
scrollAndZoomEnabled = false
updateZoomScale(resetZoom: true)
scrollView.isScrollEnabled = false
// Make sure the content is re-added with the correct constraints
if content.parent == self {
addContent()
}
}
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
setControlsVisible(visible, animated: animated)
}
}
extension GalleryItemViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
if scrollAndZoomEnabled {
return content.view
} else {
return nil
}
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true)
} else {
setControlsVisible(false, animated: true)
}
centerContent()
scrollView.layoutIfNeeded()
}
}
extension GalleryItemViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == singleTap {
let loc = gestureRecognizer.location(in: view)
return !topControlsView.frame.contains(loc) && !bottomControlsView.frame.contains(loc)
} else if gestureRecognizer == doubleTap {
let loc = gestureRecognizer.location(in: content.view)
return content.view.bounds.contains(loc)
} else {
return true
}
}
}

View File

@ -0,0 +1,136 @@
//
// GalleryPresentationAnimationController.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
private let sourceView: UIView
init(sourceView: UIView) {
self.sourceView = sourceView
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else {
fatalError()
}
let itemViewController = to.currentItemViewController
if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
animateCrossFadeTransition(using: transitionContext)
return
}
let container = transitionContext.containerView
to.view.frame = container.bounds
container.addSubview(to.view)
container.layoutIfNeeded()
// Make sure the zoom scale is updated before getting the content view frame, since it needs to take into account the correct transform.
itemViewController.updateZoomScale(resetZoom: true)
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
// Use a transformation to make the actual source view appear to move into the destination frame.
// Doing this while having the content view fade-in papers over the z-index change when
// there was something overlapping the source view.
let origSourceTransform = sourceView.transform
let sourceToDestTransform: CGAffineTransform?
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
// Scale evenly in both dimensions, to prevent the source view appearing to stretch/distort during the animation.
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
sourceToDestTransform = origSourceTransform
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
.scaledBy(x: scale, y: scale)
} else {
sourceToDestTransform = nil
}
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
container.insertSubview(content.view, belowSubview: to.view)
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
let dimmingView = UIView()
dimmingView.backgroundColor = .black
dimmingView.frame = container.bounds
dimmingView.layer.opacity = 0
container.insertSubview(dimmingView, belowSubview: content.view)
to.view.backgroundColor = nil
to.view.layer.opacity = 0
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
container.layoutIfNeeded()
// This needs to take place after the layout, so that the transform is correct.
itemViewController.setControlsVisible(false, animated: false)
let duration = self.transitionDuration(using: transitionContext)
// rougly equivalent to duration: 0.35, bounce: 0.3
let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
animator.addAnimations {
dimmingView.layer.opacity = 1
to.view.layer.opacity = 1
content.view.frame = destFrameInContainer
content.view.layer.opacity = 1
itemViewController.setControlsVisible(true, animated: false)
if let sourceToDestTransform {
self.sourceView.transform = sourceToDestTransform
}
}
animator.addCompletion { _ in
dimmingView.removeFromSuperview()
to.view.backgroundColor = .black
if sourceToDestTransform != nil {
self.sourceView.transform = origSourceTransform
}
itemViewController.addContent()
transitionContext.completeTransition(true)
}
animator.startAnimation()
}
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else {
return
}
to.view.alpha = 0
to.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(to.view)
let duration = transitionDuration(using: transitionContext)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
animator.addAnimations {
to.view.alpha = 1
}
animator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
animator.startAnimation()
}
}

View File

@ -0,0 +1,179 @@
//
// GalleryViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
public class GalleryViewController: UIPageViewController {
let galleryDataSource: GalleryDataSource
let initialItemIndex: Int
private let _itemsCount: Int
private var itemsCount: Int {
get {
precondition(_itemsCount == galleryDataSource.galleryItemsCount(), "GalleryDataSource item count cannot change")
return _itemsCount
}
}
var currentItemViewController: GalleryItemViewController {
viewControllers![0] as! GalleryItemViewController
}
private var dismissInteraction: GalleryDismissInteraction!
private var presentationAnimationCompletionHandlers: [() -> Void] = []
override public var prefersStatusBarHidden: Bool {
true
}
override public var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
.none
}
override public var childForHomeIndicatorAutoHidden: UIViewController? {
currentItemViewController
}
public init(dataSource: GalleryDataSource, initialItemIndex: Int) {
self.galleryDataSource = dataSource
self.initialItemIndex = initialItemIndex
self._itemsCount = dataSource.galleryItemsCount()
precondition(initialItemIndex >= 0 && initialItemIndex < _itemsCount, "initialItemIndex is out of bounds")
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [
.interPageSpacing: 50
])
modalPresentationStyle = .fullScreen
transitioningDelegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
dismissInteraction = GalleryDismissInteraction(viewController: self)
view.backgroundColor = .black
overrideUserInterfaceStyle = .dark
dataSource = self
delegate = self
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) {
super.viewWillDisappear(animated)
if isBeingDismissed {
currentItemViewController.content.galleryContentWillDisappear()
}
}
private func makeItemVC(index: Int) -> GalleryItemViewController {
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
return GalleryItemViewController(delegate: self, itemIndex: index, content: content)
}
func presentationAnimationCompleted() {
for block in presentationAnimationCompletionHandlers {
block()
}
currentItemViewController.content.galleryContentDidAppear()
}
}
extension GalleryViewController: UIPageViewControllerDataSource {
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let viewController = viewController as? GalleryItemViewController else {
preconditionFailure("VC must be GalleryItemViewController")
}
guard viewController.itemIndex > 0 else {
return nil
}
return makeItemVC(index: viewController.itemIndex - 1)
}
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let viewController = viewController as? GalleryItemViewController else {
preconditionFailure("VC must be GalleryItemViewController")
}
guard viewController.itemIndex < itemsCount - 1 else {
return nil
}
return makeItemVC(index: viewController.itemIndex + 1)
}
}
extension GalleryViewController: UIPageViewControllerDelegate {
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
currentItemViewController.content.galleryContentWillDisappear()
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
currentItemViewController.content.galleryContentDidAppear()
}
}
extension GalleryViewController: GalleryItemViewControllerDelegate {
func isGalleryBeingPresented() -> Bool {
isBeingPresented
}
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
presentationAnimationCompletionHandlers.append(block)
}
func galleryItemClose(_ item: GalleryItemViewController) {
dismiss(animated: true)
}
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]? {
galleryDataSource.galleryApplicationActivities(forItemAt: item.itemIndex)
}
}
extension GalleryViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
return GalleryPresentationAnimationController(sourceView: sourceView)
} else {
return nil
}
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
let translation: CGPoint?
let velocity: CGPoint?
if let dismissInteraction,
dismissInteraction.isActive {
translation = dismissInteraction.dismissTranslation
velocity = dismissInteraction.dismissVelocity
} else {
translation = nil
velocity = nil
}
return GalleryDismissAnimationController(sourceView: sourceView, interactiveTranslation: translation, interactiveVelocity: velocity)
} else {
return nil
}
}
}

View File

@ -0,0 +1,12 @@
import XCTest
@testable import GalleryVC
final class GalleryVCTests: 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

@ -10,9 +10,8 @@ import Foundation
import Combine
import Pachyderm
public class InstanceFeatures: ObservableObject {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive)
public final class InstanceFeatures: ObservableObject {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|akkoma) (.*)\\)", options: .caseInsensitive)
private let _featuresUpdated = PassthroughSubject<Void, Never>()
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
@ -22,16 +21,29 @@ public class InstanceFeatures: ObservableObject {
@Published public private(set) var charsReservedPerURL = 23
@Published public private(set) var maxPollOptionChars: Int?
@Published public private(set) var maxPollOptionsCount: Int?
@Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration?
@Published public private(set) var translation: Bool = false
public var localOnlyPosts: Bool {
switch instanceType {
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
return true
case .pleroma(.akkoma(_)):
return true
default:
return false
}
}
/// Instance types that use a separate visibility to indicate local-only posts.
public var localOnlyPostsVisibility: Bool {
if case .pleroma(.akkoma(_)) = instanceType {
return true
} else {
return false
}
}
public var mastodonAttachmentRestrictions: Bool {
instanceType.isMastodon
}
@ -72,7 +84,7 @@ public class InstanceFeatures: ObservableObject {
public var probablySupportsMarkdown: Bool {
switch instanceType {
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _):
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .firefish(_):
return true
default:
return false
@ -88,7 +100,7 @@ public class InstanceFeatures: ObservableObject {
}
public var needsWideColorGamutHack: Bool {
if case .mastodon(_, .some(let version)) = instanceType {
if case .mastodon(_, let version) = instanceType {
return version < Version(4, 0, 0)
} else {
return true
@ -96,7 +108,13 @@ public class InstanceFeatures: ObservableObject {
}
public var canFollowHashtags: Bool {
hasMastodonVersion(4, 0, 0)
if case .mastodon(_, let version) = instanceType {
return version >= Version(4, 0, 0)
} else if case .pleroma(.akkoma(let version)) = instanceType {
return version >= Version(3, 4, 0)
} else {
return false
}
}
public var filtersV2: Bool {
@ -116,18 +134,93 @@ public class InstanceFeatures: ObservableObject {
}
public var editStatuses: Bool {
// todo: does this require a particular akkoma version?
hasMastodonVersion(3, 5, 0) || instanceType.isPleroma(.akkoma(nil))
switch instanceType {
case .mastodon(_, let v) where v >= Version(3, 5, 0):
return true
case .pleroma(.vanilla(let v)) where v >= Version(2, 5, 0):
return true
case .pleroma(.akkoma(_)):
return true
default:
return false
}
}
public var statusEditNotifications: Bool {
// pleroma doesn't seem to support 'update' type notifications, even though it supports edits
hasMastodonVersion(3, 5, 0)
}
public var statusNotifications: Bool {
// pleroma doesn't support notifications for new posts from an account
hasMastodonVersion(3, 3, 0)
}
public var needsEditAttachmentsInSeparateRequest: Bool {
instanceType.isPleroma(.akkoma(nil))
instanceType.isPleroma
}
public var composeDirectStatuses: Bool {
if case .pixelfed = instanceType {
return false
} else {
return true
}
}
public var searchOperators: Bool {
hasMastodonVersion(4, 2, 0)
}
public var hasServerPreferences: Bool {
hasMastodonVersion(2, 8, 0)
}
public var listRepliesPolicy: Bool {
hasMastodonVersion(3, 3, 0)
}
public var exclusiveLists: Bool {
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 init() {
}
public func update(instance: Instance, nodeInfo: NodeInfo?) {
public func update(instance: InstanceInfo, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased()
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
if ver.contains("glitch") {
@ -155,24 +248,21 @@ public class InstanceFeatures: ObservableObject {
mastoVersion = Version(string: ver)
}
instanceType = .mastodon(.hometown(hometownVersion), mastoVersion)
} else if ver.contains("pleroma") {
} else if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
var pleromaVersion: Version?
if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
let type = (ver as NSString).substring(with: match.range(at: 1))
pleromaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 2)))
if type == "akkoma" {
instanceType = .pleroma(.akkoma(pleromaVersion))
} else {
instanceType = .pleroma(.vanilla(pleromaVersion))
}
instanceType = .pleroma(.vanilla(pleromaVersion))
} else if ver.contains("akkoma") {
var akkomaVersion: Version?
if let match = InstanceFeatures.akkomaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
akkomaVersion = Version(string: (ver as NSString).substring(with: match.range(at: 1)))
}
instanceType = .pleroma(.akkoma(akkomaVersion))
} else if ver.contains("pixelfed") {
instanceType = .pixelfed
} else if nodeInfo?.software.name == "gotosocial" {
instanceType = .gotosocial
} else if ver.contains("calckey") {
instanceType = .calckey(nodeInfo?.software.version)
} else if ver.contains("firefish") || ver.contains("iceshrimp") || ver.contains("calckey") {
instanceType = .firefish(nodeInfo?.software.version)
} else {
instanceType = .mastodon(.vanilla, Version(string: ver))
}
@ -183,12 +273,14 @@ public class InstanceFeatures: ObservableObject {
maxPollOptionChars = pollsConfig.maxCharactersPerOption
maxPollOptionsCount = pollsConfig.maxOptions
}
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
translation = instance.translation
_featuresUpdated.send()
}
public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if case .mastodon(_, .some(let version)) = instanceType {
if case .mastodon(_, let version) = instanceType {
return version >= Version(major, minor, patch)
} else {
return false
@ -197,7 +289,7 @@ public class InstanceFeatures: ObservableObject {
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
switch instanceType {
case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))):
case .pleroma(.vanilla(let version)), .pleroma(.akkoma(let version)):
return version >= Version(major, minor, patch)
default:
return false
@ -211,7 +303,7 @@ extension InstanceFeatures {
case pleroma(PleromaType)
case pixelfed
case gotosocial
case calckey(String?)
case firefish(String?)
var isMastodon: Bool {
if case .mastodon(_, _) = self {
@ -283,61 +375,3 @@ extension InstanceFeatures {
}
}
}
extension InstanceFeatures {
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
let major: Int
let minor: Int
let patch: Int
init(_ major: Int, _ minor: Int, _ patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}
init?(string: String) {
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
match.numberOfRanges == 4 else {
return nil
}
let majorStr = (string as NSString).substring(with: match.range(at: 1))
let minorStr = (string as NSString).substring(with: match.range(at: 2))
let patchStr = (string as NSString).substring(with: match.range(at: 3))
guard let major = Int(majorStr),
let minor = Int(minorStr),
let patch = Int(patchStr) else {
return nil
}
self.major = major
self.minor = minor
self.patch = patch
}
public var description: String {
"\(major).\(minor).\(patch)"
}
public static func ==(lhs: Version, rhs: Version) -> Bool {
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
}
public static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool {
if lhs.major < rhs.major {
return true
} else if lhs.major > rhs.major {
return false
} else if lhs.minor < rhs.minor {
return true
} else if lhs.minor > rhs.minor {
return false
} else if lhs.patch < rhs.patch {
return true
} else {
return false
}
}
}
}

View File

@ -0,0 +1,47 @@
//
// InstanceInfo.swift
// InstanceFeatures
//
// Created by Shadowfacts on 5/28/23.
//
import Foundation
import Pachyderm
public struct InstanceInfo {
public var version: String
public var maxStatusCharacters: Int?
public var configuration: InstanceV1.Configuration?
public var pollsConfiguration: InstanceV1.PollsConfiguration?
public var translation: Bool
public init(
version: String,
maxStatusCharacters: Int?,
configuration: InstanceV1.Configuration?,
pollsConfiguration: InstanceV1.PollsConfiguration?,
translation: Bool
) {
self.version = version
self.maxStatusCharacters = maxStatusCharacters
self.configuration = configuration
self.pollsConfiguration = pollsConfiguration
self.translation = translation
}
}
extension InstanceInfo {
public init(v1 instance: InstanceV1) {
self.init(
version: instance.version,
maxStatusCharacters: instance.maxStatusCharacters,
configuration: instance.configuration,
pollsConfiguration: instance.pollsConfiguration,
translation: false
)
}
public mutating func update(v2: InstanceV2) {
translation = v2.configuration.translation.enabled
}
}

View File

@ -0,0 +1,80 @@
//
// Version.swift
// InstanceFeatures
//
// Created by Shadowfacts on 5/14/23.
//
import Foundation
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
let major: Int
let minor: Int
let patch: Int
init(_ major: Int, _ minor: Int, _ patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}
init?(string: String) {
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
match.numberOfRanges == 4 else {
return nil
}
let majorStr = (string as NSString).substring(with: match.range(at: 1))
let minorStr = (string as NSString).substring(with: match.range(at: 2))
let patchStr = (string as NSString).substring(with: match.range(at: 3))
guard let major = Int(majorStr),
let minor = Int(minorStr),
let patch = Int(patchStr) else {
return nil
}
self.major = major
self.minor = minor
self.patch = patch
}
public var description: String {
"\(major).\(minor).\(patch)"
}
public static func ==(lhs: Version, rhs: Version) -> Bool {
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
}
public static func <(lhs: Version, rhs: Version) -> Bool {
if lhs.major < rhs.major {
return true
} else if lhs.major > rhs.major {
return false
} else if lhs.minor < rhs.minor {
return true
} else if lhs.minor > rhs.minor {
return false
} else if lhs.patch < rhs.patch {
return true
} else {
return false
}
}
}
func <(lhs: Version?, rhs: Version) -> Bool {
guard let lhs else {
// nil is less than or equal to everything
return true
}
return lhs < rhs
}
func >=(lhs: Version?, rhs: Version) -> Bool {
guard let lhs else {
// nil is less than or equal to everything
return false
}
return lhs >= rhs
}

View File

@ -225,6 +225,7 @@ class MatchedGeometryDismissAnimationController<Content: View>: NSObject, UIView
animator.addCompletion { _ in
transitionContext.completeTransition(true)
matchedGeomVC.state.animating = false
matchedGeomVC.state.mode = .idle
}
animator.startAnimation()
}

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
LastUpgradeVersion = "1500"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "Pachyderm",
platforms: [
.iOS(.v14),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -12,7 +12,7 @@ import WebURL
/**
The base Mastodon API client.
*/
public class Client {
public struct Client: Sendable {
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
@ -20,8 +20,6 @@ public class Client {
let session: URLSession
public var accessToken: String?
public var appID: String?
public var clientID: String?
public var clientSecret: String?
@ -44,7 +42,8 @@ public class Client {
} else if let date = iso8601.date(from: str) {
return date
} 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)"))
return Date(timeIntervalSinceReferenceDate: 0)
}
})
@ -61,9 +60,11 @@ public class Client {
return encoder
}()
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
public init(baseURL: URL, accessToken: String? = nil, clientID: String? = nil, clientSecret: String? = nil, session: URLSession = .shared) {
self.baseURL = baseURL
self.accessToken = accessToken
self.clientID = clientID
self.clientSecret = clientSecret
self.session = session
}
@ -105,6 +106,20 @@ public class Client {
return task
}
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in
run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
}
}
}
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.endpoint.path
@ -113,11 +128,17 @@ public class Client {
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
urlRequest.httpMethod = request.method.name
urlRequest.httpBody = request.body.data
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name)
}
if let mimeType = request.body.mimeType {
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
}
if let accessToken = accessToken {
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
// We consider authenticated requests to be user-initiated.
urlRequest.attribution = .user
}
return urlRequest
}
@ -130,14 +151,7 @@ public class Client {
"scopes" => scopes.scopeString,
"website" => website?.absoluteString
]))
run(request) { result in
defer { completion(result) }
guard case let .success(application, _) = result else { return }
self.appID = application.id
self.clientID = application.clientID
self.clientSecret = application.clientSecret
}
run(request, completion: completion)
}
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
@ -149,12 +163,7 @@ public class Client {
"redirect_uri" => redirectURI,
"scope" => scopes.scopeString,
]))
run(request) { result in
defer { completion(result) }
guard case let .success(loginSettings, _) = result else { return }
self.accessToken = loginSettings.accessToken
}
run(request, completion: completion)
}
public func revokeAccessToken() async throws {
@ -178,21 +187,16 @@ public class Client {
})
}
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
public func nodeInfo() async throws -> NodeInfo {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
run(wellKnown) { result in
switch result {
case let .failure(error):
completion(.failure(error))
case let .success(wellKnown, _):
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let href = WebURL(url.href),
href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
self.run(nodeInfo, completion: completion)
}
}
let wellKnownResults = try await run(wellKnown).0
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let href = WebURL(url.href),
href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
return try await run(nodeInfo).0
} else {
throw NodeInfoError.noWellKnownLink
}
}
@ -211,14 +215,22 @@ public class Client {
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
}
public static func getInstance() -> Request<Instance> {
return Request<Instance>(method: .get, path: "/api/v1/instance")
public static func getInstanceV1() -> Request<InstanceV1> {
return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
}
public static func getInstanceV2() -> Request<InstanceV2> {
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
}
public static func getCustomEmoji() -> Request<[Emoji]> {
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
}
public static func getPreferences() -> Request<Preferences> {
return Request(method: .get, path: "/api/v1/preferences")
}
// MARK: - Accounts
public static func getAccount(id: String) -> Request<Account> {
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
@ -330,6 +342,10 @@ public class Client {
}
// 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]> {
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
"types" => allowedTypes.map { $0.rawValue }
@ -392,24 +408,27 @@ public class Client {
mediaIDs: [String]? = nil,
sensitive: Bool? = nil,
spoilerText: String? = nil,
visibility: Visibility? = nil,
visibility: String? = nil,
language: String? = nil, // language supported by mastodon and akkoma
pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil,
pollMultiple: Bool? = nil,
localOnly: Bool? = nil /* hometown only, not glitch */) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text,
"content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo,
"sensitive" => sensitive,
"spoiler_text" => spoilerText,
"visibility" => visibility?.rawValue,
"language" => language,
"poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple,
"local_only" => localOnly,
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
localOnly: Bool? = nil, /* hometown only, not glitch */
idempotencyKey: String) -> Request<Status> {
var req = Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text,
"content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo,
"sensitive" => sensitive,
"spoiler_text" => spoilerText,
"visibility" => visibility,
"language" => language,
"poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple,
"local_only" => localOnly,
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
req.headers["Idempotency-Key"] = idempotencyKey
return req
}
public static func editStatus(
@ -539,7 +558,7 @@ extension Client {
self.type = type
}
public var localizedDescription: String {
public var errorDescription: String? {
switch type {
case .networkError(let error):
return "Network Error: \(error.localizedDescription)"
@ -569,4 +588,15 @@ extension Client {
case invalidModel(Swift.Error)
case mastodonError(Int, String)
}
enum NodeInfoError: LocalizedError {
case noWellKnownLink
var errorDescription: String? {
switch self {
case .noWellKnownLink:
return "No well-known link"
}
}
}
}

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))
}
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 {
let container = try decoder.container(keyedBy: CodingKeys.self)
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
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 {
let container = try decoder.container(keyedBy: CodingKeys.self)

View File

@ -22,7 +22,7 @@ struct EditStatusParameters: Encodable, Sendable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.text, forKey: .text)
try container.encode(self.contentType, forKey: .contentType)
try container.encode(self.contentType.mimeType, forKey: .contentType)
try container.encodeIfPresent(self.spoilerText, forKey: .spoilerText)
try container.encode(self.sensitive, forKey: .sensitive)
try container.encodeIfPresent(self.language, forKey: .language)

View File

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

View File

@ -1,5 +1,5 @@
//
// Instance.swift
// InstanceV1.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation
public struct Instance: Decodable, Sendable {
public struct InstanceV1: Decodable, Sendable {
public let uri: String
public let title: String
public let description: String
@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable {
}
}
extension Instance {
extension InstanceV1 {
public struct Stats: Decodable, Sendable {
public let domainCount: Int?
public let statusCount: Int?
@ -106,8 +106,8 @@ extension Instance {
}
}
extension Instance {
public struct Configuration: Decodable, Sendable {
extension InstanceV1 {
public struct Configuration: Codable, Sendable {
public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
@ -121,8 +121,9 @@ extension Instance {
}
}
extension Instance {
public struct StatusesConfiguration: Decodable, Sendable {
extension InstanceV1 {
// note: also used by InstanceV2
public struct StatusesConfiguration: Codable, Sendable {
public let maxCharacters: Int
public let maxMediaAttachments: Int
public let charactersReservedPerURL: Int
@ -135,8 +136,9 @@ extension Instance {
}
}
extension Instance {
public struct MediaAttachmentsConfiguration: Decodable, Sendable {
extension InstanceV1 {
// note: also used by InstanceV2
public struct MediaAttachmentsConfiguration: Codable, Sendable {
public let supportedMIMETypes: [String]
public let imageSizeLimit: Int
public let imageMatrixLimit: Int
@ -155,8 +157,9 @@ extension Instance {
}
}
extension Instance {
public struct PollsConfiguration: Decodable, Sendable {
extension InstanceV1 {
// note: also used by InstanceV2
public struct PollsConfiguration: Codable, Sendable {
public let maxOptions: Int
public let maxCharactersPerOption: Int
public let minExpiration: TimeInterval
@ -171,7 +174,8 @@ extension Instance {
}
}
extension Instance {
extension InstanceV1 {
// note: also used by InstanceV2
public struct Rule: Decodable, Identifiable, Sendable {
public let id: String
public let text: String

View File

@ -0,0 +1,125 @@
//
// InstanceV2.swift
// Pachyderm
//
// Created by Shadowfacts on 12/4/23.
//
import Foundation
public struct InstanceV2: Decodable, Sendable {
public let domain: String
public let title: String
public let version: String
public let sourceURL: String
public let description: String
public let usage: Usage
public let thumbnail: Thumbnail
public let languages: [String]
public let configuration: Configuration
public let registrations: Registrations
public let contact: Contact
public let rules: [InstanceV1.Rule]
private enum CodingKeys: String, CodingKey {
case domain
case title
case version
case sourceURL = "source_url"
case description
case usage
case thumbnail
case languages
case configuration
case registrations
case contact
case rules
}
}
extension InstanceV2 {
public struct Usage: Decodable, Sendable {
public let users: Users
}
public struct Users: Decodable, Sendable {
public let activeMonth: Int
private enum CodingKeys: String, CodingKey {
case activeMonth = "active_month"
}
}
}
extension InstanceV2 {
public struct Thumbnail: Decodable, Sendable {
public let url: String
public let blurhash: String?
public let versions: ThumbnailVersions?
}
public struct ThumbnailVersions: Decodable, Sendable {
public let oneX: String?
public let twoX: String?
private enum CodingKeys: String, CodingKey {
case oneX = "@1x"
case twoX = "@2x"
}
}
}
extension InstanceV2 {
public struct Configuration: Decodable, Sendable {
public let urls: URLs
public let accounts: Accounts
public let statuses: InstanceV1.StatusesConfiguration
public let mediaAttachments: InstanceV1.MediaAttachmentsConfiguration
public let polls: InstanceV1.PollsConfiguration
public let translation: Translation
private enum CodingKeys: String, CodingKey {
case urls
case accounts
case statuses
case mediaAttachments = "media_attachments"
case polls
case translation
}
}
public struct URLs: Decodable, Sendable {
// the docs incorrectly say the key for this is "streaming_api"
public let streaming: String
}
public struct Accounts: Decodable, Sendable {
public let maxFeaturedTags: Int
private enum CodingKeys: String, CodingKey {
case maxFeaturedTags = "max_featured_tags"
}
}
public struct Translation: Decodable, Sendable {
public let enabled: Bool
}
}
extension InstanceV2 {
public struct Registrations: Decodable, Sendable {
public let enabled: Bool
public let approvalRequired: Bool
public let message: String?
private enum CodingKeys: String, CodingKey {
case enabled
case approvalRequired = "approval_required"
case message
}
}
}
extension InstanceV2 {
public struct Contact: Decodable, Sendable {
public let email: String
public let account: Account?
}
}

View File

@ -11,14 +11,18 @@ import Foundation
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
public let id: String
public let title: String
public let replyPolicy: ReplyPolicy?
public let exclusive: Bool?
public var timeline: Timeline {
return .list(id: id)
}
public init(id: String, title: String) {
public init(id: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) {
self.id = id
self.title = title
self.replyPolicy = replyPolicy
self.exclusive = exclusive
}
public static func ==(lhs: List, rhs: List) -> Bool {
@ -36,8 +40,15 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
return request
}
public static func update(_ listID: String, title: String) -> Request<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title]))
public static func update(_ listID: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) -> Request<List> {
var params = ["title" => title]
if let replyPolicy {
params.append("replies_policy" => replyPolicy.rawValue)
}
if let exclusive {
params.append("exclusive" => exclusive)
}
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(params))
}
public static func delete(_ listID: String) -> Request<Empty> {
@ -59,5 +70,13 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
private enum CodingKeys: String, CodingKey {
case id
case title
case replyPolicy = "replies_policy"
case exclusive
}
}
extension List {
public enum ReplyPolicy: String, Codable, Hashable, CaseIterable, Sendable {
case followed, list, none
}
}

View File

@ -11,7 +11,20 @@ import Foundation
struct MastodonError: Decodable, CustomStringConvertible {
var description: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let error = try container.decodeIfPresent(String.self, forKey: .error) {
self.description = error
} else if let message = try container.decodeIfPresent(String.self, forKey: .message) {
self.description = message
} else {
throw DecodingError.keyNotFound(CodingKeys.error, .init(codingPath: container.codingPath, debugDescription: "Missing error or message key"))
}
}
private enum CodingKeys: String, CodingKey {
case description = "error"
case error
// used by pixelfed
case message
}
}

View File

@ -21,7 +21,12 @@ public struct Mention: Codable, Sendable {
self.username = try container.decode(String.self, forKey: .username)
self.acct = try container.decode(String.self, forKey: .acct)
self.id = try container.decode(String.self, forKey: .id)
self.url = try container.decode(WebURL.self, forKey: .url)
do {
self.url = try container.decode(WebURL.self, forKey: .url)
} catch {
let s = try? container.decode(String.self, forKey: .url)
throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'")
}
}
public init(url: WebURL, username: String, acct: String, id: String) {

View File

@ -8,11 +8,11 @@
import Foundation
public struct NodeInfo: Decodable, Sendable {
public struct NodeInfo: Decodable, Sendable, Equatable {
public let version: String
public let software: Software
public struct Software: Decodable, Sendable {
public struct Software: Decodable, Sendable, Equatable {
public let name: String
public let version: String
}

View File

@ -7,6 +7,7 @@
//
import Foundation
import WebURL
public struct Notification: Decodable, Sendable {
public let id: String
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
public let createdAt: Date
public let account: Account
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 {
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.account = try container.decode(Account.self, forKey: .account)
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> {
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
case createdAt = "created_at"
case account
case status
case emoji
case emojiURL = "emoji_url"
}
}
@ -52,6 +61,7 @@ extension Notification {
case poll
case update
case status
case emojiReaction = "pleroma:emoji_reaction"
case unknown
}
}

View File

@ -0,0 +1,37 @@
//
// Preferences.swift
// Pachyderm
//
// Created by Shadowfacts on 10/26/23.
//
import Foundation
public struct Preferences: Codable, Sendable {
public let postingDefaultVisibility: Visibility
public let postingDefaultSensitive: Bool
public let postingDefaultLanguage: String
// Whether posts federate or not (local-only) on Hometown
public let postingDefaultFederation: Bool?
public let readingExpandMedia: ExpandMedia
public let readingExpandSpoilers: Bool
public let readingAutoplayGifs: Bool
enum CodingKeys: String, CodingKey {
case postingDefaultVisibility = "posting:default:visibility"
case postingDefaultSensitive = "posting:default:sensitive"
case postingDefaultLanguage = "posting:default:language"
case postingDefaultFederation = "posting:default:federation"
case readingExpandMedia = "reading:expand:media"
case readingExpandSpoilers = "reading:expand:spoilers"
case readingAutoplayGifs = "reading:autoplay:gifs"
}
}
extension Preferences {
public enum ExpandMedia: String, Codable, Sendable {
case `default`
case always = "show_all"
case never = "hide_all"
}
}

View File

@ -10,4 +10,6 @@ import Foundation
public protocol ListProtocol {
var id: String { get }
var title: String { get }
var replyPolicy: List.ReplyPolicy? { get }
var exclusive: Bool? { get }
}

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
public struct PushSubscription: Decodable, Sendable {
public let id: String
public let endpoint: URL
public let serverKey: String
// TODO: WTF is this?
// public let alerts
public var id: String
public var endpoint: URL
public var serverKey: String
public var alerts: 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 {
case id
case endpoint
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

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

View File

@ -0,0 +1,19 @@
//
// SearchOperatorType.swift
// Pachyderm
//
// Created by Shadowfacts on 10/1/23.
//
import Foundation
public enum SearchOperatorType: String, CaseIterable, Equatable, Sendable {
case has
case `is`
case language
case from
case before
case during
case after
case `in`
}

View File

@ -10,6 +10,9 @@ import Foundation
import WebURL
public final class Status: StatusProtocol, Decodable, Sendable {
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts.
public static let localPostVisibility: String = "local"
public let id: String
public let uri: String
public let url: WebURL?
@ -43,6 +46,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
public let localOnly: Bool?
public let editedAt: Date?
public let pleromaExtras: PleromaExtras?
public var applicationName: String? { application?.name }
public init(from decoder: Decoder) throws {
@ -63,7 +68,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
self.content = try container.decode(String.self, forKey: .content)
// pixelfed statuses may have null content
self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
@ -77,7 +83,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility),
s == "local" {
s == Status.localPostVisibility {
// hacky workaround for #332, akkoma describes local posts with a separate visibility
self.visibility = .public
self.localOnly = true
@ -94,6 +100,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
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> {
@ -116,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable {
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> {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
}
@ -173,6 +187,10 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
}
public static func translate(_ statusID: String) -> Request<Translation> {
return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate")
}
private enum CodingKeys: String, CodingKey {
case id
case uri
@ -204,7 +222,15 @@ public final class Status: StatusProtocol, Decodable, Sendable {
case poll
case localOnly = "local_only"
case editedAt = "edited_at"
case pleromaExtras = "pleroma"
}
}
extension Status: Identifiable {}
extension Status {
public struct PleromaExtras: Decodable, Sendable {
public let context: String?
}
}

View File

@ -7,7 +7,7 @@
import Foundation
public struct StatusEdit: Decodable {
public struct StatusEdit: Decodable, Sendable {
public let content: String
public let spoilerText: String
public let sensitive: Bool
@ -28,10 +28,10 @@ public struct StatusEdit: Decodable {
case emojis
}
public struct Poll: Decodable {
public struct Poll: Decodable, Sendable {
public let options: [Option]
public struct Option: Decodable {
public struct Option: Decodable, Sendable {
public let title: String
}
}

View File

@ -7,7 +7,7 @@
import Foundation
public struct StatusSource: Decodable {
public struct StatusSource: Decodable, Sendable {
public let id: String
public let text: String
public let spoilerText: String

View File

@ -7,34 +7,83 @@
import Foundation
public struct TimelineMarkers: Decodable, Sendable {
public let home: Marker?
public let notifications: Marker?
public struct TimelineMarkers {
private init() {}
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue))
public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
}
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
"\(timeline.rawValue)[last_read_id]" => lastReadID,
public static func update<T: TimelineMarkerType>(timeline: T, lastReadID: String) -> Request<Empty> {
Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
"\(T.name)[last_read_id]" => lastReadID
]))
}
}
public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
let payload: Payload
public enum Timeline: String {
case home
case notifications
public var lastReadID: String {
payload.payload.lastReadID
}
public struct Marker: Decodable, Sendable {
public let lastReadID: String
public let version: Int
public let updatedAt: Date
enum CodingKeys: String, CodingKey {
case lastReadID = "last_read_id"
case version
case updatedAt = "updated_at"
}
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 version: Int
public let updatedAt: Date
enum CodingKeys: String, CodingKey {
case lastReadID = "last_read_id"
case version
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

@ -0,0 +1,22 @@
//
// Translation.swift
// Pachyderm
//
// Created by Shadowfacts on 12/4/23.
//
import Foundation
public struct Translation: Decodable, Sendable {
public let content: String
public let spoilerText: String?
public let detectedSourceLanguage: String
public let provider: String
private enum CodingKeys: String, CodingKey {
case content
case spoilerText
case detectedSourceLanguage = "detected_source_language"
case provider
}
}

View File

@ -13,6 +13,7 @@ public struct Request<ResultType: Decodable>: Sendable {
let endpoint: Endpoint
let body: Body
var queryParameters: [Parameter]
var headers: [String: String] = [:]
var additionalAcceptableHTTPCodes: [Int] = []
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {

View File

@ -24,7 +24,9 @@ public final class CollapseState: Sendable {
}
public func copy() -> CollapseState {
return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
let new = CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
new.statusPropertiesHash = self.statusPropertiesHash
return new
}
public func hash(into hasher: inout Hasher) {

View File

@ -49,7 +49,7 @@ public class InstanceSelector {
}
public extension InstanceSelector {
struct Instance: Codable {
struct Instance: Codable, Sendable {
public let domain: String
public let description: String
public let proxiedThumbnailURL: URL

View File

@ -7,25 +7,18 @@
//
import Foundation
import WebURL
public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification]
public let id: String
public let kind: Notification.Kind
public let statusState: CollapseState?
public let kind: Kind
@MainActor
public init?(notifications: [Notification]) {
public init?(notifications: [Notification], kind: Kind) {
guard !notifications.isEmpty else { return nil }
self.notifications = notifications
self.id = notifications.first!.id
self.kind = notifications.first!.kind
switch kind {
case .mention, .status:
self.statusState = .unknown
default:
self.statusState = nil
}
self.kind = kind
}
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
@ -51,31 +44,62 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
private mutating func append(group: NotificationGroup) {
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
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [NotificationGroup]()
for notification in notifications {
let groupKind = groupKind(for: notification)
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)
continue
} else if 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)
continue
}
}
}
groups.append(NotificationGroup(notifications: [notification])!)
groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
}
return groups
}
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
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] {
@ -90,21 +114,21 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
var second = second
merged.reserveCapacity(second.count)
while let firstGroupFromSecond = second.first,
allowedTypes.contains(firstGroupFromSecond.kind) {
allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
second.removeFirst()
guard let lastGroup = merged.last,
allowedTypes.contains(lastGroup.kind) else {
allowedTypes.contains(lastGroup.kind.notificationKind) else {
merged.append(firstGroupFromSecond)
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)
} else if 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)
} else {
merged.append(firstGroupFromSecond)
@ -117,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
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
}
}
}
}

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,32 @@
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "PushNotifications",
platforms: [
.iOS(.v15),
],
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"]
),
.testTarget(
name: "PushNotificationsTests",
dependencies: ["PushNotifications"]),
]
)

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

@ -0,0 +1,93 @@
//
// AsyncPicker.swift
// TuskerComponents
//
// Created by Shadowfacts on 4/9/24.
//
import SwiftUI
public struct AsyncPicker<V: Hashable, Content: View>: View {
let titleKey: LocalizedStringKey
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool
#endif
let alignment: Alignment
@Binding var value: V
let onChange: (V) async -> Bool
let content: Content
@State private var isLoading = false
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
self.titleKey = titleKey
#if !os(visionOS)
self.labelHidden = labelHidden
#endif
self.alignment = alignment
self._value = value
self.onChange = onChange
self.content = content()
}
public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) {
picker
}
#else
if #available(iOS 16.0, *) {
LabeledContent(titleKey) {
picker
}
} else if labelHidden {
picker
} else {
HStack {
Text(titleKey)
Spacer()
picker
}
}
#endif
}
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,89 @@
//
// 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
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool
#endif
@Binding var mode: Mode
let onChange: (Bool) async -> Bool
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
self.titleKey = titleKey
#if !os(visionOS)
self.labelHidden = labelHidden
#endif
self._mode = mode
self.onChange = onChange
}
public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) {
toggleOrSpinner
}
#else
if #available(iOS 16.0, *) {
LabeledContent(titleKey) {
toggleOrSpinner
}
} else if labelHidden {
toggleOrSpinner
} else {
HStack {
Text(titleKey)
Spacer()
toggleOrSpinner
}
}
#endif
}
@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
// ComposeUI
// TuskerComponents
//
// Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
@ -8,7 +8,7 @@
import Foundation
struct FuzzyMatcher {
public struct FuzzyMatcher {
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 does not occur in `str` sequentially
/// -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 str = str.lowercased()

View File

@ -26,13 +26,26 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
}
public func makeUIView(context: Context) -> UIButton {
let button = UIButton()
let button = UIButton(configuration: makeConfiguration())
button.showsMenuAsPrimaryAction = true
button.setContentHuggingPriority(.required, for: .horizontal)
return button
}
public func updateUIView(_ button: UIButton, context: Context) {
button.configuration = makeConfiguration()
button.menu = UIMenu(children: options.map { opt in
let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
selection = opt.value
}
action.accessibilityLabel = opt.accessibilityLabel
return action
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
}
private func makeConfiguration() -> UIButton.Configuration {
var config = UIButton.Configuration.borderless()
if #available(iOS 16.0, *) {
config.indicator = .popup
@ -43,16 +56,10 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
if buttonStyle.hasLabel {
config.title = selectedOption.title
}
button.configuration = config
button.menu = UIMenu(children: options.map { opt in
let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
selection = opt.value
}
action.accessibilityLabel = opt.accessibilityLabel
return action
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
#if targetEnvironment(macCatalyst)
config.macIdiomStyle = .bordered
#endif
return config
}
public struct Option {

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

@ -24,5 +24,9 @@ let package = Package(
name: "TuskerPreferences",
dependencies: ["Pachyderm"]
),
.testTarget(
name: "TuskerPreferencesTests",
dependencies: ["TuskerPreferences"]
)
]
)

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

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