Compare commits

...

673 Commits

Author SHA1 Message Date
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
Shadowfacts 6261318df1 Bump build number and update changelog 2023-05-11 23:15:52 -04:00
Shadowfacts bff7585fa9 Move remote change processing to separate context to avoid blocking background context 2023-05-11 23:03:41 -04:00
Shadowfacts 4dbc4ebeb2 Include fractional seconds in log timestamps 2023-05-11 20:46:25 -04:00
Shadowfacts fc391cc18c Bump build number and update changelog 2023-05-11 20:24:21 -04:00
Shadowfacts 35b390d3c1 Fix MultiSourceEmojiLabel 2023-05-11 18:38:49 -04:00
Shadowfacts b21703f6d9 Fix decoding polls on Calckey
See #362
2023-05-11 16:15:36 -04:00
Shadowfacts d003098146 Better TimelineLikeController logging 2023-05-11 15:11:43 -04:00
Shadowfacts db7c183d06 Add status edit history view 2023-05-11 14:57:47 -04:00
Shadowfacts 7d3c82f4b7 Fix collapsible state not changing when post edited 2023-05-11 14:46:45 -04:00
Shadowfacts 13ec3366d3 Fix content warning not being removed by edit 2023-05-11 14:39:49 -04:00
Shadowfacts f9a41fd4f3 Show edit timestamps on statuses 2023-05-11 13:10:45 -04:00
Shadowfacts 2157126332 Unseparate out updateStatusState method 2023-05-11 10:03:09 -04:00
Shadowfacts e87dcfe48e Add support for editing posts
Closes #321
2023-05-11 10:03:09 -04:00
Shadowfacts 566c3d474d Don't show Show Reblogs action for non-followed people 2023-05-10 22:22:37 -04:00
Shadowfacts ca03cf3b08 Shorten hashtag action titles 2023-05-10 11:55:23 -04:00
Shadowfacts f0e530722f FIx hashtag timelines opened in new window not having save/follow actions 2023-05-10 11:54:36 -04:00
Shadowfacts dcd1b4ad94 Fix being able to scroll to top while fast account switcher is active 2023-05-10 11:41:59 -04:00
Shadowfacts 3394c2126c Fix list timelines opened in new window not showing Edit button 2023-05-10 11:32:08 -04:00
Shadowfacts 85765928b4 Fix crash when trying to remove popped view controller that doesn't exist 2023-05-10 11:04:56 -04:00
Shadowfacts f13874ee01 Improve rate limit exceeded error message 2023-05-10 10:59:22 -04:00
Shadowfacts bac272a2db Detect gotosocial and calckey instances 2023-05-10 10:48:52 -04:00
Shadowfacts 48bd957276 Fix nodeinfo not being fetched on punycode domains 2023-05-10 10:40:27 -04:00
Shadowfacts d4d42e7856 Report instance type/version in Sentry events 2023-05-10 10:34:48 -04:00
Shadowfacts 671a8e0cb3 Fix error decoding statuses on Calckey lacking emojis 2023-05-10 10:13:34 -04:00
Shadowfacts 822c2e0fa2 Bump build number and update changelog 2023-05-10 10:13:00 -04:00
Shadowfacts ee651ae96a Fix assorted issues collapsing/expanding split VC 2023-05-09 16:42:16 -04:00
Shadowfacts 9fc4aa8a40 Make various corners continuously rounded 2023-05-09 14:56:48 -04:00
Shadowfacts 8f6a012538 Fix decoding statuses on GtS with empty strings for urls
Closes #373
See #129
2023-05-08 17:05:06 -04:00
Shadowfacts 91d6430815 Fix various tests 2023-05-08 16:58:50 -04:00
Shadowfacts eac5a4c9a6 Fix notifications scrolling to top when refreshing 2023-05-07 19:46:15 -04:00
Shadowfacts 7449688bfe Bump build number and update changelog 2023-05-07 19:44:04 -04:00
Shadowfacts 63612b2fb0 Make notification cells subclasses of UICollectionViewListCell 2023-05-07 16:35:01 -04:00
Shadowfacts 8e010c7fa5 Remove unused notifications and status table view code 2023-05-07 15:11:35 -04:00
Shadowfacts 3181c47fde Convert rest of notifications screen to collection view 2023-05-07 15:11:35 -04:00
Shadowfacts a133955489 Fix using removed dismiss notification API endpoint 2023-05-07 15:11:35 -04:00
Shadowfacts 7551c79715 Convert status updated notification to collection view cell 2023-05-07 15:11:35 -04:00
Shadowfacts 5a4e387026 Convert poll finished notification to collection view cell 2023-05-07 15:11:35 -04:00
Shadowfacts 00945a0028 Convert follow request notification to collection view cell 2023-05-07 13:44:55 -04:00
Shadowfacts 2b9d384f8f Convert follow notification to collection view cell 2023-05-07 11:02:37 -04:00
Shadowfacts 90efee3f20 Convert action group notification to collection view cell 2023-05-07 11:02:06 -04:00
Shadowfacts 574d1f9134 Initial notifications collection view implementatioan 2023-05-06 20:32:48 -04:00
Shadowfacts 25e82d828f Fix presented VC getting dismissed after closing expanded attachment view 2023-05-06 14:33:05 -04:00
Shadowfacts 2eb9e63724 Make language picker sheet half-height, fix appearance in non-pure-black dark mode 2023-05-06 14:28:12 -04:00
Shadowfacts d85f74f365 Fix crash due to layout loop when laying out fields on certain profiles
Closes #378

Also make field layout more consistent, and tweak appearance
2023-05-06 14:16:43 -04:00
Shadowfacts f775527d63 Bump build number and update changelog 2023-05-05 10:33:17 -04:00
Shadowfacts a6d64282c0 Add language picker to Compose screen
Closes #236
2023-05-05 10:13:20 -04:00
Shadowfacts 24fb0e0e7b Remove automatically save drafts preference
Closes #369
2023-05-04 21:40:59 -04:00
Shadowfacts b6a5a60066 Fix full size image not being loaded on first appearance of focused attachment view 2023-05-04 21:06:59 -04:00
Shadowfacts f68d1009e5 Fix focused attachment view being incorrect size on iPad 2023-05-04 21:03:11 -04:00
Shadowfacts 99b74559da Don't duck Compose screen when the draft is empty
See #369
2023-05-04 18:40:00 -04:00
Shadowfacts 346888db41 Fix deadlock when drafts persistent container is initialized simultaneously on background and main threads
Fixes #374
2023-05-04 18:33:06 -04:00
Shadowfacts 7b218bfd75 Fix spinner on Send Report button being misplaced
Closes #377
2023-05-04 10:16:15 -04:00
Shadowfacts 098c4254d4 Fix deleted attachments reappearing 2023-05-04 10:12:44 -04:00
Shadowfacts bbdb7fe41f Fix crash on deleting draft with attachments in share extension 2023-05-04 10:11:04 -04:00
Shadowfacts 3c13d2083b Fix double nav controller in share extension 2023-05-04 10:01:32 -04:00
Shadowfacts ad55851090 Play back videos in focused attachment view 2023-05-03 23:30:33 -04:00
Shadowfacts a37423a119 Fix gifs being converted to images on upload
Closes #376
2023-05-03 23:20:58 -04:00
Shadowfacts 02daf88db3 Support gifs in new thumbnail controller and focused attachment view 2023-05-03 23:20:58 -04:00
Shadowfacts ce3b8ba4b3 Add zooming to focused attachment view 2023-05-03 23:20:58 -04:00
Shadowfacts 891fd3826b Add expanded attachment description view to Compose screen
Closes #365
2023-05-03 23:20:58 -04:00
Shadowfacts e0eba95b48 Remove double navigation controllers from compose screen 2023-04-25 18:51:46 -04:00
Shadowfacts 2febb37a8e Let duckable VCs prevent ducking 2023-04-23 21:55:14 -04:00
Shadowfacts a20e8b2f48 Don't require DuckableContainer to manage navigation controller 2023-04-23 20:08:57 -04:00
Shadowfacts b3d5ed8505 Delete local files when DraftAttachment deleted 2023-04-23 14:44:11 -04:00
Shadowfacts 4401503b85 More detailed error when status URL decoding fails
See #373
2023-04-23 14:38:51 -04:00
Shadowfacts 6c5909c800 Fix error when reloading empty profile
Closes #366
2023-04-23 14:30:56 -04:00
Shadowfacts af5109f86c Fix restored, ducked Compose screen lacking title 2023-04-23 14:27:18 -04:00
Shadowfacts b782e66a45 Fix draft being deleted when Compose screen ducked 2023-04-23 14:27:00 -04:00
Shadowfacts a1ffb23f0d Align link verification checkmark to link rather than screen edge
Closes #368
2023-04-23 14:01:51 -04:00
Shadowfacts ea5afeeb88 Persist sidebar visibility across app launches
Closes #372
2023-04-23 13:57:24 -04:00
Shadowfacts 49334766ef Fix crash when inserting poll from draft created in share sheet 2023-04-23 10:22:53 -04:00
Shadowfacts 3bba4edb45 Fix sharing extension not being available on iOS 15 2023-04-23 10:19:20 -04:00
Shadowfacts bda8fdb1b9 Bump build number and update changelog 2023-04-22 23:31:27 -04:00
Shadowfacts f361517a92 Fix crash on first launch after updating from build 77 2023-04-22 23:22:38 -04:00
Shadowfacts a12afb8dc2 Fix sharing extension only using first attachment 2023-04-22 22:43:00 -04:00
Shadowfacts de1a97d357 Use actual activation rule for sharing extension 2023-04-22 22:34:47 -04:00
Shadowfacts c17cf460d7 Fix post error messages not being displayed correctly 2023-04-22 22:30:27 -04:00
Shadowfacts 8ff20bf7aa Disable unused test targets 2023-04-22 22:23:43 -04:00
Shadowfacts 205056f636 Fix draft being deleted too early causing empty UI during dismiss compose animation 2023-04-22 22:18:46 -04:00
Shadowfacts 40197e04cf Fix attachment description observation trying to access properties of deleted object 2023-04-22 22:18:21 -04:00
Shadowfacts 2249e5a315 Fix DraftAttachment being accessed off main thread 2023-04-22 22:03:52 -04:00
Shadowfacts bff1ea8b9d Merge branch 'share-sheet-extension' into develop 2023-04-22 21:59:14 -04:00
Shadowfacts b614226871 Fix avatars in share sheet being blurry 2023-04-22 21:48:12 -04:00
Shadowfacts f51f3c8a94 Use CoreData for drafts store 2023-04-22 21:40:29 -04:00
Shadowfacts 074a296a68 Fix Post button always being disabled when require attachment descriptions is enabled
Also fix post button state not updating when description edited

Closes #371
2023-04-21 18:02:30 -04:00
Shadowfacts 2874e4bfd3 Coordinate DraftsManager reading writing between processes 2023-04-21 17:24:40 -04:00
Shadowfacts 74a157d26c Fix drafts from share sheet not being saved 2023-04-19 22:27:25 -04:00
Shadowfacts 3d3fc3f515 Allow switching accounts from share sheet 2023-04-19 22:20:05 -04:00
Shadowfacts 6c371f868f Initial share extension implementation 2023-04-18 21:55:14 -04:00
Shadowfacts 06855420da Move preferences to shared package 2023-04-18 19:47:49 -04:00
Shadowfacts 0d7cc69947 Fix not being able to close draft when automatic save preference is off 2023-04-18 15:17:42 -04:00
Shadowfacts cfc69627e5 Fix crash when creating menu actions for status w/o URL 2023-04-18 10:19:53 -04:00
Shadowfacts 160f48679b Handle HTTP 206 responses from timelines endpoint 2023-04-18 10:16:38 -04:00
Shadowfacts 4931665b45 Log Sentry installation ID
So when the user taps Get Support and logs are sent we can cross-ref
with recent crashes
2023-04-18 10:10:15 -04:00
Shadowfacts 849882287f Fix crash when pasting screenshots, not being able to paste gifs 2023-04-17 20:14:59 -04:00
Shadowfacts 436159bd46 Show reblogger's avatar on reblogged posts 2023-04-17 11:19:37 -04:00
Shadowfacts 2224dbebb8 Remove old code 2023-04-17 10:08:18 -04:00
Shadowfacts 9882250a9b Bump build number and update changelog 2023-04-16 19:06:45 -04:00
Shadowfacts bb22a6bf9e Remove more old asset picker code 2023-04-16 18:47:03 -04:00
Shadowfacts 15c83f8332 Fix keyboard focus background on list cells not showing correctly 2023-04-16 18:46:47 -04:00
Shadowfacts 5ec35b6009 Fix reblogged statuses appearing Bookmarks
Closes #359
2023-04-16 18:20:16 -04:00
Shadowfacts 22fe1e8ab1 Don't redact api endpoints in debug 2023-04-16 15:11:59 -04:00
Shadowfacts 813d0433d6 Fix profile no content cell not using non-pure-black background color 2023-04-16 15:11:47 -04:00
Shadowfacts cd9d64410f Add hashtag pinned timeline search improvements
Closes #348
2023-04-16 14:50:54 -04:00
Shadowfacts 2b66f98832 Remove old asset picker 2023-04-16 14:28:09 -04:00
Shadowfacts 6ebcc162e6 Add icons to About screen links 2023-04-16 14:12:27 -04:00
Shadowfacts 8b7c78e3b1 Log errors that result in showing a toast to the user 2023-04-16 14:07:30 -04:00
Shadowfacts ab8ccbb408 Exclude notifications that are missing statuses
It's still unclear why this ever happens, but crashing is untenable

See #274
2023-04-16 13:59:38 -04:00
Shadowfacts f89d2c1cca Merge branch 'compose-package' into develop 2023-04-16 13:50:23 -04:00
Shadowfacts 30449a2875 Rename NewComposeHostingController to ComposeHostingController 2023-04-16 13:47:48 -04:00
Shadowfacts afed157f29 Remove old compose screen code 2023-04-16 13:47:06 -04:00
Shadowfacts 6b4223a9d6 Migrate drafts to new file 2023-04-16 13:31:10 -04:00
Shadowfacts 0746e12737 Extract compose UI into separate package 2023-04-16 13:23:13 -04:00
Shadowfacts 350e331eb2 Move GIFImageView to TuskerComponents 2023-04-16 13:17:39 -04:00
Shadowfacts bb3f353dbc Fix Compose window title not being set initially 2023-04-13 10:04:48 -04:00
Shadowfacts 6bd2eacb88 Fix replied-to account not being first mention 2023-04-13 10:02:05 -04:00
Shadowfacts 29b594207c Fix crash when comments present in html 2023-04-03 23:39:22 -04:00
Shadowfacts e5363b2e21 Fix sidebar key commands not working on macOS
Closes #253
2023-04-03 23:25:33 -04:00
Shadowfacts d04259b253 Fix scroll to top not working in presented VCs
Closes #363
2023-04-03 22:45:15 -04:00
Shadowfacts f50c219f95 Send scopes in /oauth/token request
Closes #360
2023-04-03 22:43:01 -04:00
Shadowfacts b2fe2fdf9a Move Visibility to top-level type and move extensions to Pachyderm 2023-03-07 10:14:35 -05:00
Shadowfacts 850a0e90ce Move MenuPicker to separate package 2023-03-07 10:07:45 -05:00
Shadowfacts 391ea1b46a Move InstanceFeatures to separate package 2023-03-05 14:52:19 -05:00
Shadowfacts 247bb31c56 Move local user accounts to separate package 2023-03-05 14:35:25 -05:00
Shadowfacts 5471d810c8 Fix reblog error toast title 2023-03-01 21:09:56 -05:00
Shadowfacts ad0a9ecafe Fix crash when setting SegmentedPageViewController pages to [] after failing to decode pinned timelines 2023-02-28 22:42:28 -05:00
Shadowfacts ee630cf9df Bump build number and update changelog 2023-02-28 20:27:06 -05:00
Shadowfacts c786c022b8 Fix crash when trying to apply invalid game model update
Not sure how this could happen but w/e
2023-02-28 14:40:48 -05:00
Shadowfacts 33649cc5c0 Bump build number and update changelog 2023-02-28 14:40:27 -05:00
Shadowfacts 71a10f8514 Don't report 502 errors 2023-02-27 10:34:37 -05:00
Shadowfacts a864f4e344 Tweak timeline marker error reporting 2023-02-27 10:34:37 -05:00
Shadowfacts 007d5d6791 Don't report 404 errors 2023-02-26 14:09:29 -05:00
Shadowfacts f176a6c8eb Bump build number and update changelog 2023-02-25 18:38:52 -05:00
Shadowfacts 104981f3d3 Fix iPad Explore screen not restoring search state 2023-02-25 18:30:05 -05:00
Shadowfacts 2ba6b64485 Tweak marker API preference description 2023-02-25 18:28:19 -05:00
Shadowfacts 81ac3708a3 Tweak compose placeholders 2023-02-25 18:22:41 -05:00
Shadowfacts 8e9e0fa346 Persist state when switching accounts 2023-02-25 18:00:17 -05:00
Shadowfacts b6f32ca6be Make timeline load more button more prominent 2023-02-25 16:59:48 -05:00
Shadowfacts e042754be1 Fix crash when restoring state for timeline VC 2023-02-25 16:44:36 -05:00
Shadowfacts 38ac5858a9 Don't check present when refreshing timeline 2023-02-25 16:39:00 -05:00
Shadowfacts 0c0180264e Fix no content message incorrectly appearing on profiles
Caused by a spurious appearance transition from embedChild
2023-02-25 15:30:30 -05:00
Shadowfacts 3d9477f0c9 Hide card description label when it doesn't fit
Closes #336
2023-02-25 15:23:13 -05:00
Shadowfacts 6f51f321f6 Fix VC restored to secondary split nav missing Close button 2023-02-25 15:15:00 -05:00
Shadowfacts ab17a688cf Fix TrendHistoryView trying to create shape layers with NaNs 2023-02-25 15:11:17 -05:00
Shadowfacts 18bc6ce61e Don't use readable content inset for search results 2023-02-25 15:10:21 -05:00
Shadowfacts 765b5e1a7c Don't use KVO for updating timeline gap cell 2023-02-25 15:02:55 -05:00
Shadowfacts a3e64703ab Transfer timeline position in handoff user activity
Closes #315
2023-02-25 15:01:19 -05:00
Shadowfacts d74be9d81d Add handoff to various user activities 2023-02-25 15:00:55 -05:00
Shadowfacts 6ca5bb0c74 Unify state restoration with user activity handling code 2023-02-25 14:08:54 -05:00
Shadowfacts 76550d8fb8 Fix crash when ReportView opened before instance loaded 2023-02-24 18:32:29 -05:00
Shadowfacts daf3741c9a Hide placeholder image from link card when none provided
Closes #358
2023-02-24 18:27:31 -05:00
Shadowfacts b2977540e0 Add profile moved banner
Closes #284
2023-02-24 18:27:31 -05:00
Shadowfacts bcc70e9f8c Fix crash when data nodes present in converted HTML 2023-02-23 10:05:33 -05:00
Shadowfacts 2252b6d09e Fix crash duplicate main status and crash when conversation context is preloaded
For expand thread cells, the main status needs to be the one above the selected cell
2023-02-23 10:02:05 -05:00
Shadowfacts 8deb502140 Show message on remote profiles with no statuses
Closes #279
2023-02-22 22:23:18 -05:00
Shadowfacts 2582907919 Only show fav/reblog inaccurate count warning for remote posts 2023-02-22 22:00:12 -05:00
Shadowfacts 266868376d Allow refreshing conversations
Closes #157
2023-02-22 21:52:45 -05:00
Shadowfacts 71fa3910a1 Simplify NSUserActivity construction code 2023-02-22 21:42:09 -05:00
Shadowfacts 75f290ae8f Tab state restoration
Closes #32
2023-02-22 21:38:12 -05:00
Shadowfacts 073a1afbde Show percentage of voters for multi-choice polls 2023-02-19 18:21:20 -05:00
Shadowfacts aaa031f212 First pass at strict sendability checking 2023-02-19 15:23:25 -05:00
Shadowfacts 762d298c06 Report caught NSExceptions to Sentry 2023-02-19 14:19:39 -05:00
Shadowfacts 2a892fa6ec Disable custom status link previews on iOS 16.4 2023-02-19 13:49:56 -05:00
Shadowfacts cb82826fcf Catch NSExceptions when doing objc runtime shenanigans 2023-02-15 19:34:23 -05:00
Shadowfacts 6e5498430f Fix poll option tracking unselecting options when location moves in between views 2023-02-15 18:57:05 -05:00
Shadowfacts 57fb921573 Fix non-pure-black dark mode not applying to ohter scenes 2023-02-14 22:52:27 -05:00
Shadowfacts d1b5126288 Fix status action account list not adjusting to non-pure-black dark mode 2023-02-14 22:47:56 -05:00
Shadowfacts 9d2324b587 Add preference to use timeline marker API
Closes #40
2023-02-14 21:56:15 -05:00
Shadowfacts 60921cb95f Fix tapping reblog count in conv main status showing favorites list 2023-02-14 21:37:54 -05:00
Shadowfacts 9e76879ce6 Add preference to hide attachment badges
Closes #354
2023-02-14 21:37:54 -05:00
Shadowfacts 1992a4c60b Make search results VC dismiss keyboard interactively 2023-02-13 20:29:15 -05:00
Shadowfacts f833bc3a6f Apply accessibility labels to MenuPicker actions 2023-02-13 20:27:05 -05:00
Shadowfacts 4731801893 Bump build number and update changelog 2023-02-12 10:22:33 -05:00
Shadowfacts 4293b51c31 Add extended suggested profiles screen
Closes #355
2023-02-11 19:05:12 -05:00
Shadowfacts ecadb83c6d Add infinite scrolling to trending statuses
See #355
2023-02-11 18:47:39 -05:00
Shadowfacts 205bdffebd Add loading indicator to Trends screen 2023-02-11 18:32:37 -05:00
Shadowfacts ae7ca9c91c Fix wrong cells on trending links screen being selectable 2023-02-11 18:29:33 -05:00
Shadowfacts 841119949b Add infinite scrolling to trending hashtags screen
See #355
2023-02-11 18:29:33 -05:00
Shadowfacts b63f663947 Handle errors when loading trending links 2023-02-11 18:13:37 -05:00
Shadowfacts 00a23b525f Add share to trending link actions 2023-02-11 10:21:09 -05:00
Shadowfacts ea85b11945 Use cards for trending links screen, and add pagination
See #355
2023-02-11 10:09:56 -05:00
Shadowfacts d8c7eb5cf5 Add buttons to Explore screen 2023-02-10 18:19:00 -05:00
Shadowfacts 8bc185ecf9 Add jump to present button to timelines 2023-02-07 23:52:23 -05:00
Shadowfacts 1832e64ad7 Remove now-unused hashtag table view cell 2023-02-06 21:47:47 -05:00
Shadowfacts 87bc1f5f75 Rewrite search results VC using UICollectionView 2023-02-06 21:47:47 -05:00
Shadowfacts 6e2f6bb8e9 Apply non-pure black dark mode to Drafts screen 2023-02-06 19:53:15 -05:00
Shadowfacts 74d8adfffe Fix Compose background color not going under nav bar 2023-02-06 19:51:01 -05:00
Shadowfacts 99127b617b Tweak non-pure-black dark mode colors 2023-02-06 18:47:50 -05:00
Shadowfacts 65ea72c07f Don't show pure-black dark mode preference on Mac 2023-02-06 18:45:34 -05:00
Shadowfacts 04ca932a01 Mode non-pure-black dark mode stuff to dedicated modifiers 2023-02-06 18:43:00 -05:00
Shadowfacts 4ea2dff8f1 Merge branch 'develop' into non-pure-black-mode 2023-02-06 18:15:23 -05:00
Shadowfacts 9f0176350c Cleanup TuskerNavigationDelegate 2023-02-06 18:10:38 -05:00
Shadowfacts dac1e1fe3f Fix icon in suggested profile reason popover not adjusting to dark mode 2023-02-05 19:56:37 -05:00
Shadowfacts afed69e43e Bump build number and update changelog 2023-02-05 19:50:21 -05:00
Shadowfacts b2096f22c3 Rename Hide Discover Section pref to Hide Trends 2023-02-05 14:43:04 -05:00
Shadowfacts 14c456df22 Tweak trends orthogonal scroll behavior 2023-02-05 14:41:10 -05:00
Shadowfacts 3f34357692 Fix discover section sometimes appearing on non-Mastodon instances 2023-02-05 14:36:09 -05:00
Shadowfacts 429dcefa88 Use consolidated trends screen on iPhone 2023-02-05 14:34:01 -05:00
Shadowfacts d1a35620c9 Remove profile directory
The code remains for now, in case it needs to return
2023-02-05 14:27:26 -05:00
Shadowfacts ce741d6e1f Extract trends to separate VC 2023-02-05 14:23:29 -05:00
Shadowfacts 5a82851fe9 Fix custom emoji picker buttons not having accessibility labels
Closes #286
2023-02-05 14:00:08 -05:00
Shadowfacts 92ff900bc0 Improve VoiceOver labels for notifications
Closes #350
2023-02-05 13:56:48 -05:00
Shadowfacts 2a1deb8d7d Fix follow request accept/reject buttons not matching accent color 2023-02-05 13:55:31 -05:00
Shadowfacts 38eea44a8b VoiceOver improvements on fast account switcher
Closes #310
2023-02-05 13:33:42 -05:00
Shadowfacts 2d45fbbd91 Apply Mastodon poll limits in Compose view 2023-02-05 12:43:51 -05:00
Shadowfacts 32382c4783 Fix crash when previewing status cell that doesn't have delegate
Not sure how this is possible, but w/e
2023-02-05 11:27:03 -05:00
Shadowfacts 521c46c0be Don't capture certain error types 2023-02-05 11:23:10 -05:00
Shadowfacts c114749519 Handle 401 errors on instance timelines 2023-02-05 11:18:23 -05:00
Shadowfacts 825424cfba Fix crash when tapping My Profile tab before view is loaded
Closes #352
2023-02-05 11:09:08 -05:00
Shadowfacts 985eb24e88 Remove workaround for compiler bug breaking constrained existential types on iOS 15 release builds
Closes #178
2023-02-05 11:04:11 -05:00
Shadowfacts 7cadcf1e86 Reuse conversation tree where possible when selecting a status in a conversation 2023-02-04 15:15:41 -05:00
Shadowfacts a314521b96 Extract out conversation tree-building code 2023-02-04 13:49:20 -05:00
Shadowfacts ab3bad0e16 Fix trending statuses not being deselected on navigation back 2023-02-04 13:48:24 -05:00
Shadowfacts ec75906bc1 Add favorites screen
Closes #327
2023-02-04 13:21:58 -05:00
Shadowfacts 137a537f68 Extract loading and local updating handling code from bookmarks VC into separate VC 2023-02-04 13:14:08 -05:00
Shadowfacts 91123fd24a Make username label on profile copyable 2023-02-04 11:10:01 -05:00
Shadowfacts 597dd56032 Fix crash on split VC collapse/expand while in explore tab
Also fix iPad explore VC resetting when leaving/reopening the app

Closes #351
2023-02-03 18:30:37 -05:00
Shadowfacts 37847a2f9f Fix accent color circles not showing on iOS 15 2023-02-02 23:36:47 -05:00
Shadowfacts 471d3459a6 Apply non-pure-black dark mode to preferences screen 2023-02-02 23:29:44 -05:00
Shadowfacts 512eec09a8 Merge branch 'develop' into non-pure-black-mode 2023-02-02 23:14:27 -05:00
Shadowfacts af8a9faaeb Cleanup PreferencesView 2023-02-02 23:14:19 -05:00
Shadowfacts 20c4c4bb2f Start adding non-pure-black dark mode 2023-02-02 23:02:11 -05:00
Shadowfacts 76268e7a14 Make attachment description selectable in gallery 2023-01-31 14:17:59 -05:00
Shadowfacts 29596180a1 Using async/await for ImageCache implementation 2023-01-31 09:56:13 -05:00
Shadowfacts ebfd8b3efd Fix bookmarks VC sometimes going haywire 2023-01-30 10:07:34 -05:00
Shadowfacts 509acbde19 Fix status action account list VC not resizing on rotation 2023-01-29 16:02:47 -05:00
Shadowfacts 474064669d Bump build number and update changelog 2023-01-29 10:26:20 -05:00
Shadowfacts 1940368c43 Load account lists in pages of 40 2023-01-28 23:07:38 -05:00
Shadowfacts 49c9c69b5a Fix flicker when opening status action account list in split nav
The container VC background needs to match the content VC
2023-01-28 22:59:30 -05:00
Shadowfacts ff29f2768b Tweak follow count button colors
Try to make it clearer that it's a button
2023-01-28 18:18:53 -05:00
Shadowfacts 942df433b3 Allow refreshing bookmarks list 2023-01-28 15:30:41 -05:00
Shadowfacts 5e2b551045 Update bookmarks VC on bookmarked state changes
Closes #318
2023-01-28 15:30:41 -05:00
Shadowfacts 2e64500c35 Rewrite bookmarks VC using UICollectionView 2023-01-28 15:30:41 -05:00
Shadowfacts 7b7c05ff68 Fix timeline position sync not working due to LazilyDecoding cache not being invalidated upon remote change 2023-01-28 13:41:22 -05:00
Shadowfacts aec5c0b787 Update Sentry SDK 2023-01-28 00:16:11 -05:00
Shadowfacts d8901b38f5 Load timeline posts in pages of 40 2023-01-28 00:16:11 -05:00
Shadowfacts 9d7c876e3c Remove old sleeps 2023-01-27 21:48:47 -05:00
Shadowfacts 455273f322 Show more posts in report status screen 2023-01-27 21:45:02 -05:00
Shadowfacts 16347b2ad0 Automatic retry during onboarding, better UI while waiting 2023-01-27 20:52:34 -05:00
Shadowfacts 0e1cbce10d Revoke token and destroy stores when logging out 2023-01-27 18:53:20 -05:00
Shadowfacts 8bd6f53f01 Allow pinning instance public timelines 2023-01-27 18:12:54 -05:00
Shadowfacts fe32356bce Bump build number and update changelog 2023-01-27 10:38:56 -05:00
Shadowfacts 1f337613be Add animation when compose toolbar buttons (dis)appear 2023-01-26 22:33:47 -05:00
Shadowfacts 3f4a62f5f9 Fix changes being published during SwiftUI view update 2023-01-26 22:18:03 -05:00
Shadowfacts b506704716 Move Drafts button to nav bar when current composed post doesn't have any content 2023-01-26 22:17:49 -05:00
Shadowfacts 6a3dcca9ee Workaround for local-only posts not being decodable on Akkoma
See #332
2023-01-26 22:10:20 -05:00
Shadowfacts edd1e55cbb Unify haptic feedback
Closes #154
2023-01-26 21:52:12 -05:00
Shadowfacts f1facea929 Fix status URLs with fragments not being resolved 2023-01-26 21:15:02 -05:00
Shadowfacts d638ea054b Add gif/alt badges to attachments
Closes #255, #338
2023-01-26 19:16:34 -05:00
Shadowfacts e11784904b Add menu action to hide/show reblogs
Closes #206
2023-01-26 18:50:05 -05:00
Shadowfacts 9f1d3804d9 Apply Mastodon's link truncation
Closes #344
2023-01-26 18:38:31 -05:00
Shadowfacts 333295367a Add preference to hide link preview cards
Closes #329
2023-01-26 17:18:27 -05:00
Shadowfacts e9d14c6cbf Tweak status card background color in dark mode 2023-01-26 15:17:17 -05:00
Shadowfacts 8fc915d6a0 Bump build number and update changelog 2023-01-26 00:23:10 -05:00
Fahim Farook 2b4898329f Duckable Issue
* IF you have the .testTarget enabled, on Xcode 14.2 you get an error about the test target source needing to be under the "Tests" folder or something similar
2023-01-26 05:01:04 +00:00
Shadowfacts 5a9513bb30 Add tip jar 2023-01-25 23:58:51 -05:00
Shadowfacts e45459e556 Add support link to about screen 2023-01-25 18:54:09 -05:00
Shadowfacts 8b546daeaa Workaround for issues signing in to m.s 2023-01-25 09:56:24 -05:00
Shadowfacts 125f91257a Fix status notifications not being shown 2023-01-25 09:56:24 -05:00
Fahim Farook 507d9c23e7 xcconfig Fixes
* Ensure that all bundle prefixes are replaced correctly for all targets and in entitlements files too
2023-01-25 09:42:36 +04:00
561 changed files with 35006 additions and 17227 deletions

205
CHANGELOG-release.md Normal file
View File

@ -0,0 +1,205 @@
## 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
- Add Jump to Present button to timelines on the home tab
- Consolidate Trends into a single screen
- Allow pinning instance public timelines to the Home tab
- Add GIF/ALT badges to attachments (and preference to hide them)
- Add action to show hide/show reblogs from specific accounts
- Add preference to hide link preview cards
- Hide placeholder image in link preview card for previews without images
- Truncate links in posts
- Move Drafts button in Compose screen to nav bar to reduce accidental presses
- Load more posts/notifications on each page
- Update Bookmarks screen when posts are bookmarked/unbookmarked
- Add infinite scrolling to Bookmarks screen
- Add Favorites screen to the Explore tab
- Make attachment description text selectable in gallery
- Add long press to copy username on profile screens
- Optimize conversation loading
- Apply server-configured poll limits in Compose screen
- Add infinite scrolling to trending links/hashtags/posts
- Add state restoration for more screens
- Persist state when switching between accounts
- Add Handoff support for various screens
- Add preference to sync timeline position using Mastodon API, rather than iCloud
- Show percentage of voters for multi-choice polls, rather than percentage of votes
- Display message on remote profiles with no posts
- Indicate moved profiles
- Make Load More button on timelines more prominent
- VoiceOver: Make fast account switcher accessible
- VoiceOver: Improve labels for notifications
- VoiceOver: Fix custom emoji picker not having labels
Bugfixes:
- Workaround for not being able to sign in to certain instances
- Fix timeline position sync not working in certain circumstances
- Fix local-only posts not being decodable when logged in to Akkoma instances
- Fix Trends sometimes appearing in Explore/sidebar on non-Mastodon instances
- Fix favoriters/rebloggers list not resizing on screen rotation
- Fix crash when tapping My Profile tab immediately after app launch
- Handle authentication required errors on instance public timelines
- Fix follow request accept/reject buttons not matching accent color preference
- Fix tapping reblog count in conversation main status showing favorites list
- Fix crash when certain tags are present in post HTML
- Fix crash when opening Report screen in certain circumstances
- iPadOS: Fix crash when resizing window while on the Explore screen
- iOS 15: Fix accent colors not being displayed in Preferences

View File

@ -1,5 +1,484 @@
# Changelog
## 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.
## 2023.5 (85)
This build adds support for editing posts and showing edit timestamps and history.
Features/Improvements:
- Post editing
- Show post edit history
- Improve rate limit exceeded error message
- Shorten hashtag save/follow action subtitles so they fit in the context menu
- Remove Hide/Show Reblogs action for accounts the user isn't following
Bugfixes:
- Fix nodeinfo not being fetched on instances with punycode domains
- Fix potential crash with interactive push gesture
- Fix list timelines opened in new window lacking Edit button
- Fix hashtag timelines opened in new window lacking save/follow actions
- Fix being able to scroll to top while fast account switcher is active
- Fix decoding statuses lacking emojis on Calckey
- Fix decoding polls on Calckey
## 2023.5 (84)
Bugfixes:
- Fix notifications scrolling to top when refreshing
- Fix decoding statuses failing on GoToSocial
- Fix assorted issues when collapsing/expanding between sidebar and tab bar modes
## 2023.5 (83)
This build contains significant refactors to the notifications screen, please report any issues you encounter.
Features/Improvements:
- Tweak appearance of profile fields
- Make language picker sheet half-height
Bugfixes:
- Fix crash when laying out profile fields on certain accounts
- Fix other presented screens getting dismissed when opened after closing expanded attachment view
- Fix janky status collapse/expand animation on notifications screen
- Fix link previews not appearing in notifications
## 2023.5 (81)
Further improvements and fixes to the Compose screen, see below. Features are frozen for the upcoming release, please report any bugs you encounter!
Features/Improvements:
- Add expanded attachment view on Compose screen
- Add an attachment, select the description text field, and tap on the expand button on the attachment thumbnail
- Expanded attachment view allows you to view the attachment larger while writing the description
- Plays back videos while writing the description
- iOS 16: Allow zooming in to expanded attachment view
- Add language picker to Compose screen
- Persist sidebar visibility across app launches
- Align link verification checkmarks to link rather than creen edge
- Fully dismiss, rather than ducking, the Compose screen when swiped down with no content
- Remove Automatically Save Drafts preference
- Drafts are always saved automatically, and the save/delete sheet is now always shown on dismiss
Bugfixes:
- Fix share sheet extension being unavailable on iOS 15
- Fix crash when loading draft with poll from share sheet extension
- Fix active draft being deleted when Compose screen ducked
- Fix restored, ducked Compose screen lacking title
- Fix error when reloading empty profile
- Fix local attachments not being deleted upon draft deletion
- Fix GIFs being converted to still images on upload
- Fix crash on deleting draft with attachments in share extension
- Fix deleted attachments in Compose screen reappearing
- Fix spinner on Send Report button being misplaced
- Fix crash on launch loop when migrating from previous version in certain circumstances
## 2023.5 (80)
This build adds a Share Sheet extension and introduces further Compose screen refactors.
Features/Improvements:
- Add Share Sheet extension
- Show reblogger's avatar on reblogged posts
Bugfixes:
- Fix not being able to close Compose screen when Automatically Save Drafts preference is off
- Fix Post button always being disabled when Require Attachment Descriptions preference is on
- Fix crash when pasting screenshots
- Fix not being able to paste gifs
- Don't consider HTTP 206 responses to timeline requests to be errors
- Fix crash when displaying menu for statuses missing URLs
- Fix errors while posting not displaying useful error messages
## 2023.5 (77)
The Compose screen has been substantially refactored in this build, in preparation for upcoming features, so please report any issues you encounter!
Features/Improvements:
- Use system photo picker instead of custom interface
- Improve Customize Timelines hashtag search UI
Bugfixes:
- Fix scroll-to-top not working in in-app Safari
- Fix crash when decoding pinned timelines fails
- Fix inaccurate titles in certain error popups
- Fix crash when comments present in status HTML
- Fix replied-to account not being the first mention
- Fix Compose window not having title set initially
- Fix crash when the API returns notifications that are missing statuses
- Fix "No Content" cell on profiles not using non-pure-black background
- Fix reblogged statuses appearing in the Bookmarks list
- Fix keyboard focus highlight not showing
- macOS: Fix sidebar item keyboard shortcuts not working
## 2023.4 (76)
App Store release
## 2023.4 (75)
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.
## 2023.4 (74)
Features/Improvements:
- Add state restoration for more screens
- Persist state when switching between accounts
- Add handoff for various screens
- Add preference to hide GIF/ALT badges on attachments
- Add preference to use Mastodon timeline marker API for syncing Home timeline position
- Show percentage of voters for multi-choice poll results, rather than percentage of votes
- Change search results view controller to dismiss keyboard on scroll
- Only show inaccurate favorite/reblog count warning for posts from remote instances
- Show message on remote profiles with no statuses
- Add banner to profiles that have moved
- Hide placeholder image for link cards without images
- Don't check for present statuses when refreshing timeline
- Make timeline Load More button more prominent
- iOS 16.4: Use iOS-provided link previews in Share Sheet
Bugfixes:
- Fix tapping reblog count in conversation main status showing favorites list
- Fix status favorite/reblog list not adjusting to non-pure-black dark mode
- Fix non-pure-black dark mode not applying to auxiliary windows
- Fix poll option tracking gesture unselecting options when touch location moves between options
- Fix crash when tapping conversation "More Replies" cell
- Fix crash when script/style tags are present in post HTML
- Fix crash when opening Report screen in certain circumstances
## 2023.4 (73)
Features/Improvements:
- Add preference for non-pure-black dark mode
- Add Jump to Present button to timelines
- Improve status collapse animation in search results screen
- Add more trending links/hashtags/profiles buttons to Trends screen
- Add infinite scrolling to trending links/hashtags screens
- Add Share action to trending link context menu
Bugfixes:
- Fix icon in suggested profile popover not adjusting to dark mode
## 2023.4 (72)
Features/Improvements:
- Consolidate Trends into a single screen
- Make attachment description text selectable in gallery
- Add long press to copy usernames on profile screen
- Add Favorites screen to Explore tab
- Optimize conversation loading when opening a conversation that is already fully-loaded
- Apply Mastodon poll limits in Compose screen
- VoiceOver: Fast account switcher improvements (make the screen modal, select the first account upon opening the switcher, make each account a single item)
- VoiceOver: Improve labels for notifications
- VoiceOver: Fix custom emoji picker buttons not having labels
Bugfixes:
- Fix trends sometimes appearing in Explore/sidebar on non-Mastodon instances
- Fix status favorite/reblog accounts list not resizing on device rotation
- Fix bookmarks screen sometimes going haywire
- Fix trending statuses not being deselected upon navigating back
- Fix crash when tapping My Profile tab too early in app lifecycle
- Handle 401 errors on instance timelines properly
- Fix potential crash when showing context menu previews for status
- Fix follow request accept/reject buttons not matching accent color preference
- iPadOS: Fix crash when switching between sidebar and tab bar while on the Explore screen
- iOS 15: Fix accent colors not being disaplyed in Preferences
## 2023.4 (71)
Features/Improvements:
- Allow pinning instance public timelines to the Home tab
- Improve UI and retry mechanism when adding account
- Increase page size to 40 on a bunch of screens
- Update bookmarks screen when posts are bookmarked/unbookmarked
- Allow loading older and refreshing bookmarks screen
- Tweak follow count button color
Bugfixes:
- Fix timeline position sync not working in certain circumstances
- iPadOS: Fix flicker when opening favorite/reblog list in notificationss
## 2023.4 (70)
Features/Improvements:
- Add GIF/ALT badges to attachments
- Add menu action to hide/show reblogs from specific accounts
- Apply Mastodon's link truncation
- Add preference to hide link preview cards
- Tweak link preview card border color in dark mode
- Unify haptic feedback across the app
- Move Drafts button to the nav bar when the post doesn't have any content, to reduce accidental presses
Bugfixes:
- Fix status URLs with fragments not being resolved
- Workaround for local-only posts not being decodable when logged in to Akkoma instances
## 2023.3 (69)
Features/Improvements:
- Add Tip Jar under Preferences
- Add scopes to search screen
- Workarounds for logging in to mastodon.social being unreliable
- Handle instance timeline authentication errors more gracefully
- iPadOS: Add Trending Posts and Profile Suggestions to Explore screen
Bugfixes:
- Fix Open in Safari action not working
- Fix notifications for posts from subscribed accounts not being shown
- Detect Misskey status links
- Fix trending hashtag cells not adjusting to Dynamic Type
- Fix raw HTML being shown in link preview cards
- iPadOS: Fix crash when expanding window size while showing trending statuses/hashtags/links screen
- iPadOS: Fix preview actions not working on Explore screen
- macOS: Fix not being able to right-click to remove pinned timelines
## 2023.2 (68)
Bugfixes:
- Fix crash when inserting present items in empty timeline

View File

@ -0,0 +1,7 @@
# Haptic Feedback
## Selection changed
`UISelectionFeedbackGenerator`
## Actions
On success, `UIImpactFeedbackGenerator` with the `.light` style. On error, `UINotificationFeedbackGenerator` with the `.error` type.

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)

9
Packages/ComposeUI/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

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" : "6f45f3cd6606f39c3753b302fe30aea980067b30"
}
}
],
"version" : 2
}

View File

@ -0,0 +1,34 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "ComposeUI",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "ComposeUI",
targets: ["ComposeUI"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(path: "../Pachyderm"),
.package(path: "../InstanceFeatures"),
.package(path: "../TuskerComponents"),
.package(path: "../MatchedGeometryPresentation"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "ComposeUI",
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]),
.testTarget(
name: "ComposeUITests",
dependencies: ["ComposeUI"]),
]
)

View File

@ -0,0 +1,3 @@
# ComposeUI
A description of this package.

View File

@ -0,0 +1,197 @@
//
// PostService.swift
// ComposeUI
//
// Created by Shadowfacts on 4/27/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
import UniformTypeIdentifiers
@MainActor
class PostService: ObservableObject {
private let mastodonController: ComposeMastodonContext
private let config: ComposeUIConfig
private let draft: Draft
@Published var currentStep = 1
@Published private(set) var totalSteps = 2
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
self.mastodonController = mastodonController
self.config = config
self.draft = draft
}
func post() async throws {
guard draft.hasContent || draft.editedStatusID != nil else {
return
}
// save before posting, so if a crash occurs during network request, the status won't be lost
DraftsPersistentContainer.shared.save()
let uploadedAttachments = try await uploadAttachments()
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : ""
let sensitive = !contentWarning.isEmpty
let request: Request<Status>
if let editedStatusID = draft.editedStatusID {
if mastodonController.instanceFeatures.needsEditAttachmentsInSeparateRequest {
await updateEditedAttachments()
}
request = Client.editStatus(
id: editedStatusID,
text: textForPosting(),
contentType: config.contentType,
spoilerText: contentWarning,
sensitive: sensitive,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
mediaIDs: uploadedAttachments,
mediaAttributes: draft.draftAttachments.compactMap {
if let id = $0.editedAttachmentID {
return EditStatusMediaAttributes(id: id, description: $0.attachmentDescription, focus: nil)
} else {
return nil
}
},
poll: draft.poll.map {
EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple)
}
)
} else {
request = Client.createStatus(
text: textForPosting(),
contentType: config.contentType,
inReplyTo: draft.inReplyToID,
mediaIDs: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
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 && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil,
idempotencyKey: draft.id.uuidString
)
}
do {
let (status, _) = try await mastodonController.run(request)
currentStep += 1
mastodonController.storeCreatedStatus(status)
} catch let error as Client.Error {
throw Error.posting(error)
}
}
private func uploadAttachments() async throws -> [String] {
// 2 steps (request data, then upload) for each attachment
self.totalSteps += 2 * draft.attachments.count
var attachments: [String] = []
attachments.reserveCapacity(draft.attachments.count)
for (index, attachment) in draft.draftAttachments.enumerated() {
// if this attachment already exists and is being edited, we don't do anything
// edits to the description are handled as part of the edit status request
if let editedAttachmentID = attachment.editedAttachmentID {
attachments.append(editedAttachmentID)
currentStep += 2
continue
}
let data: Data
let utType: UTType
do {
(data, utType) = try await getData(for: attachment)
currentStep += 1
} catch let error as DraftAttachment.ExportError {
throw Error.attachmentData(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
}
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
return try await withCheckedThrowingContinuation { continuation in
attachment.getData(features: mastodonController.instanceFeatures) { result in
switch result {
case let .success(res):
continuation.resume(returning: res)
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}
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)
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 {
var text = draft.text
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
// which we want to strip out before actually posting the status
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack {
text += " 👁"
}
return text
}
// only needed for akkoma, not used on regular mastodon
private func updateEditedAttachments() async {
for attachment in draft.draftAttachments {
guard let id = attachment.editedAttachmentID else {
continue
}
let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil)
_ = try? await mastodonController.run(req)
}
}
enum Error: Swift.Error, LocalizedError {
case attachmentData(index: Int, cause: DraftAttachment.ExportError)
case attachmentMissingMimeType(index: Int, type: UTType)
case attachmentUpload(index: Int, cause: Client.Error)
case posting(Client.Error)
var localizedDescription: String {
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):
return error.localizedDescription
}
}
}
}

View File

@ -1,24 +1,25 @@
//
// CharacterCounter.swift
// Pachyderm
// ComposeUI
//
// Created by Shadowfacts on 9/29/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import Foundation
import InstanceFeatures
public struct CharacterCounter {
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
public static func count(text: String, for instance: Instance? = nil) -> Int {
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
let mentionsRemoved = removeMentions(in: text)
var count = mentionsRemoved.count
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
count -= match.range.length
count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
count += instanceFeatures.charsReservedPerURL
}
return count
}

View File

@ -0,0 +1,29 @@
//
// ComposeInput.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import Foundation
import Combine
import UIKit
protocol ComposeInput: AnyObject, ObservableObject {
var toolbarElements: [ToolbarElement] { get }
var textInputMode: UITextInputMode? { get }
var autocompleteState: AutocompleteState? { get }
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get }
func autocomplete(with string: String)
func applyFormat(_ format: StatusFormat)
func beginAutocompletingEmoji()
}
enum ToolbarElement {
case emojiPicker
case formattingButtons
}

View File

@ -0,0 +1,29 @@
//
// ComposeMastodonContext.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import Foundation
import Pachyderm
import InstanceFeatures
import UserAccounts
public protocol ComposeMastodonContext {
var accountInfo: UserAccountInfo? { get }
var instanceFeatures: InstanceFeatures { get }
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
func getCustomEmojis() async -> [Emoji]
@MainActor
func searchCachedAccounts(query: String) -> [AccountProtocol]
@MainActor
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
@MainActor
func searchCachedHashtags(query: String) -> [Hashtag]
func storeCreatedStatus(_ status: Status)
}

View File

@ -0,0 +1,42 @@
//
// ComposeUIConfig.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Pachyderm
import PhotosUI
import PencilKit
import TuskerComponents
public struct ComposeUIConfig {
// Config
public var allowSwitchingDrafts = true
public var textSelectionStartsAtBeginning = false
// Style
public var backgroundColor = Color(uiColor: .systemBackground)
public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground)
public var groupedCellBackgroundColor = Color(uiColor: .systemBackground)
public var fillColor = Color(uiColor: .systemFill)
public var avatarStyle = AvatarImageView.Style.roundRect
// Preferences
public var useTwitterKeyboard = false
public var contentType = StatusContentType.plain
public var requireAttachmentDescriptions = false
// Host callbacks
public var dismiss: @MainActor (DismissMode) -> Void = { _ in }
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?
public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil }
public init() {
}
}
extension ComposeUIConfig {
}

View File

@ -0,0 +1,236 @@
//
// AttachmentRowController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/12/23.
//
import SwiftUI
import TuskerComponents
import Vision
import MatchedGeometryPresentation
class AttachmentRowController: ViewController {
let parent: ComposeController
let attachment: DraftAttachment
@Published var descriptionMode: DescriptionMode = .allowEntry
@Published var textRecognitionError: Error?
@Published var focusAttachmentOnTextEditorUnfocus = false
let thumbnailController: AttachmentThumbnailController
private var descriptionObservation: NSKeyValueObservation?
init(parent: ComposeController, attachment: DraftAttachment) {
self.parent = parent
self.attachment = attachment
self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent)
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
if attachment.faultingState == 0 {
self.updateAttachmentDescriptionState()
}
})
}
private func updateAttachmentDescriptionState() {
if attachment.attachmentDescription.isEmpty {
parent.attachmentsMissingDescriptions.insert(attachment.id)
} else {
parent.attachmentsMissingDescriptions.remove(attachment.id)
}
}
var view: some View {
AttachmentView(attachment: attachment)
}
private func removeAttachment() {
withAnimation {
var newAttachments = parent.draft.draftAttachments
newAttachments.removeAll(where: { $0.id == attachment.id })
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
}
}
private func editDrawing() {
guard case .drawing(let drawing) = attachment.data else {
return
}
parent.config.presentDrawing?(drawing) { newDrawing in
self.attachment.drawing = newDrawing
}
}
private func focusAttachment() {
focusAttachmentOnTextEditorUnfocus = false
parent.focusedAttachment = (attachment, thumbnailController)
}
private func recognizeText() {
descriptionMode = .recognizingText
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
DispatchQueue.main.async {
let data: Data
switch result {
case .success((let d, _)):
data = d
case .failure(let error):
self.descriptionMode = .allowEntry
self.textRecognitionError = error
return
}
let handler = VNImageRequestHandler(data: data)
let request = VNRecognizeTextRequest { request, error in
DispatchQueue.main.async {
if let results = request.results as? [VNRecognizedTextObservation] {
var text = ""
for observation in results {
let result = observation.topCandidates(1).first!
text.append(result.string)
text.append("\n")
}
self.attachment.attachmentDescription = text
}
self.descriptionMode = .allowEntry
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform([request])
} catch let error as NSError where error.code == 1 {
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
return
} catch {
DispatchQueue.main.async {
self.descriptionMode = .allowEntry
self.textRecognitionError = error
}
}
}
}
}
}
struct AttachmentView: View {
@ObservedObject private var attachment: DraftAttachment
@EnvironmentObject private var controller: AttachmentRowController
@FocusState private var textEditorFocused: Bool
init(attachment: DraftAttachment) {
self.attachment = attachment
}
var body: some View {
HStack(alignment: .center, spacing: 4) {
ControllerView(controller: { controller.thumbnailController })
.clipShape(RoundedRectangle(cornerRadius: 8))
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false))
.matchedGeometrySource(id: attachment.id, presentationID: attachment.id)
.overlay {
thumbnailFocusedOverlay
}
.frame(width: thumbnailSize, height: thumbnailSize)
.onTapGesture {
textEditorFocused = false
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
controller.focusAttachmentOnTextEditorUnfocus = true
}
.contextMenu {
if attachment.drawingData != nil {
Button(action: controller.editDrawing) {
Label("Edit Drawing", systemImage: "hand.draw")
}
} else if attachment.type == .image {
Button(action: controller.recognizeText) {
Label("Recognize Text", systemImage: "doc.text.viewfinder")
}
}
Button(role: .destructive, action: controller.removeAttachment) {
Label("Delete", systemImage: "trash")
}
} previewIfAvailable: {
ControllerView(controller: { controller.thumbnailController })
}
switch controller.descriptionMode {
case .allowEntry:
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
.focused($textEditorFocused)
case .recognizingText:
ProgressView()
.progressViewStyle(.circular)
}
}
.alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in
Button("OK") {}
} message: { error in
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
private var thumbnailFocusedOverlay: some View {
Image(systemName: "arrow.up.backward.and.arrow.down.forward")
.foregroundColor(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
// use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState
.opacity(textEditorFocused ? 1 : 0)
.animation(.linear(duration: 0.1), value: textEditorFocused)
}
}
}
extension AttachmentRowController {
enum DescriptionMode {
case allowEntry, recognizingText
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
@ViewBuilder
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
if #available(iOS 16.0, *) {
self.contextMenu(menuItems: menuItems, preview: preview)
} else {
self.contextMenu(menuItems: menuItems)
}
}
}

View File

@ -0,0 +1,199 @@
//
// AttachmentThumbnailController.swift
// ComposeUI
//
// Created by Shadowfacts on 11/10/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import SwiftUI
import Photos
import TuskerComponents
class AttachmentThumbnailController: ViewController {
unowned let parent: ComposeController
let attachment: DraftAttachment
@Published private var image: UIImage?
@Published private var gifController: GIFController?
@Published private var fullSize: Bool = false
init(attachment: DraftAttachment, parent: ComposeController) {
self.attachment = attachment
self.parent = parent
}
func loadImageIfNecessary(fullSize: Bool) {
if (gifController != nil) || (image != nil && self.fullSize) {
return
}
self.fullSize = fullSize
switch attachment.data {
case .editing(_, let kind, let url):
switch kind {
case .image:
Task { @MainActor in
self.image = await parent.fetchAttachment(url)
}
case .video, .gifv:
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
#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
}
case .asset(let id):
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
return
}
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier })
if isGIF {
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
guard let data else { return }
if typeIdentifier == UTType.gif.identifier {
self.gifController = GIFController(gifData: data)
} else {
let image = UIImage(data: data)
DispatchQueue.main.async {
self.image = image
}
}
}
} else {
let size: CGSize
if fullSize {
size = PHImageManagerMaximumSize
} else {
// currently only used as thumbnail in ComposeAttachmentRow
size = CGSize(width: 80, height: 80)
}
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
DispatchQueue.main.async {
self.image = image
}
}
}
case .drawing(let drawing):
image = drawing.imageInLightMode(from: drawing.bounds)
case .file(let url, let type):
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
#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) {
// 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 = image
}
}
// } else {
// image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
// DispatchQueue.main.async {
// self.image = prepared
// }
// }
// }
}
}
case .none:
break
}
}
var view: some SwiftUI.View {
View()
}
struct View: SwiftUI.View {
@EnvironmentObject private var controller: AttachmentThumbnailController
@Environment(\.attachmentThumbnailConfiguration) private var config
var body: some SwiftUI.View {
content
.onAppear {
controller.loadImageIfNecessary(fullSize: config.fullSize)
}
}
@ViewBuilder
private var content: some SwiftUI.View {
if let gifController = controller.gifController {
GIFViewWrapper(controller: gifController)
} else if let image = controller.image {
Image(uiImage: image)
.resizable()
.aspectRatio(config.aspectRatio, contentMode: config.contentMode)
} else {
Image(systemName: "photo")
}
}
}
}
struct AttachmentThumbnailConfiguration {
let aspectRatio: CGFloat?
let contentMode: ContentMode
let fullSize: Bool
init(aspectRatio: CGFloat? = nil, contentMode: ContentMode = .fit, fullSize: Bool = false) {
self.aspectRatio = aspectRatio
self.contentMode = contentMode
self.fullSize = fullSize
}
}
private struct AttachmentThumbnailConfigurationEnvironmentKey: EnvironmentKey {
static let defaultValue = AttachmentThumbnailConfiguration()
}
extension EnvironmentValues {
var attachmentThumbnailConfiguration: AttachmentThumbnailConfiguration {
get { self[AttachmentThumbnailConfigurationEnvironmentKey.self] }
set { self[AttachmentThumbnailConfigurationEnvironmentKey.self] = newValue }
}
}
private struct GIFViewWrapper: UIViewRepresentable {
typealias UIViewType = GIFImageView
@State var controller: GIFController
func makeUIView(context: Context) -> GIFImageView {
let view = GIFImageView()
controller.attach(to: view)
controller.startAnimating()
view.contentMode = .scaleAspectFit
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return view
}
func updateUIView(_ uiView: GIFImageView, context: Context) {
}
}

View File

@ -0,0 +1,263 @@
//
// AttachmentsListController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/8/23.
//
import SwiftUI
import PhotosUI
import PencilKit
class AttachmentsListController: ViewController {
unowned let parent: ComposeController
var draft: Draft { parent.draft }
var isValid: Bool {
!requiresAttachmentDescriptions && validAttachmentCombination
}
private var requiresAttachmentDescriptions: Bool {
if parent.config.requireAttachmentDescriptions {
if draft.attachments.count == 0 {
return false
} else {
return !parent.attachmentsMissingDescriptions.isEmpty
}
} else {
return false
}
}
var validAttachmentCombination: Bool {
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
return true
} else if draft.attachments.count > 1,
draft.draftAttachments.contains(where: { $0.type == .video }) {
return false
} else if draft.attachments.count > 4 {
return false
}
return true
}
init(parent: ComposeController) {
self.parent = parent
}
var canAddAttachment: Bool {
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
} else {
return true
}
}
private var canAddPoll: Bool {
if parent.mastodonController.instanceFeatures.pollsAndAttachments {
return true
} else {
return draft.attachments.count == 0
}
}
var view: some View {
AttachmentsList()
}
private func moveAttachments(from source: IndexSet, to destination: Int) {
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
// results in the order switching back to the previous order and then to the correct one
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
var array = draft.draftAttachments
array.move(fromOffsets: source, toOffset: destination)
draft.attachments = NSMutableOrderedSet(array: array)
}
private func deleteAttachments(at indices: IndexSet) {
var array = draft.draftAttachments
array.remove(atOffsets: indices)
draft.attachments = NSMutableOrderedSet(array: array)
}
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
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 { [weak self] in
guard let self,
self.canAddAttachment else {
return
}
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
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)
}
}
private func togglePoll() {
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
withAnimation {
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
}
}
struct AttachmentsList: View {
private let cellHeight: CGFloat = 80
private let cellPadding: CGFloat = 12
@EnvironmentObject private var controller: AttachmentsListController
@EnvironmentObject private var draft: Draft
@Environment(\.colorScheme) private var colorScheme
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
attachmentsList
Group {
if controller.parent.config.presentAssetPicker != nil {
addImageButton
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
if controller.parent.config.presentDrawing != nil {
addDrawingButton
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
}
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 {
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
.id(attachment.id)
}
.onMove(perform: controller.moveAttachments)
.onDelete(perform: controller.deleteAttachments)
.conditionally(controller.canAddAttachment) {
$0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in
controller.insertAttachments(at: offset, itemProviders: providers)
})
}
// only sort of works, see #240
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, isTargeted: nil) { providers in
controller.insertAttachments(at: 0, itemProviders: providers)
return true
}
}
private var addImageButton: some View {
Button(action: controller.addImage) {
Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
}
.disabled(!controller.canAddAttachment)
.foregroundColor(.accentColor)
.frame(height: cellHeight / 2)
}
private var addDrawingButton: some View {
Button(action: controller.addDrawing) {
Label("Draw something", systemImage: "hand.draw")
}
.disabled(!controller.canAddAttachment)
.foregroundColor(.accentColor)
.frame(height: cellHeight / 2)
}
private var togglePollButton: some View {
Button(action: controller.togglePoll) {
Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
}
.disabled(!controller.canAddPoll)
.foregroundColor(.accentColor)
.frame(height: cellHeight / 2)
}
}
}
fileprivate extension View {
@ViewBuilder
func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View {
if condition {
body(self)
} else {
self
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
if #available(iOS 16.0, *) {
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
} else {
self.popover(isPresented: isPresented, content: content)
}
}
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func withSheetDetentsIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
self
}
}
}
@available(iOS 16.0, *)
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
@Binding var isPresented: Bool
@ViewBuilder let view: () -> V
@Environment(\.horizontalSizeClass) var sizeClass
func body(content: Content) -> some View {
if sizeClass == .compact {
content.sheet(isPresented: $isPresented, content: view)
} else {
content.popover(isPresented: $isPresented, content: view)
}
}
}
@available(visionOS 1.0, *)
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
DefaultLabelStyle().makeBody(configuration: configuration)
.foregroundStyle(.white)
}
}

View File

@ -0,0 +1,83 @@
//
// AutocompleteController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
import Combine
class AutocompleteController: ViewController {
unowned let parent: ComposeController
@Published var mode: Mode?
init(parent: ComposeController) {
self.parent = parent
parent.$currentInput
.compactMap { $0 }
.flatMap { $0.autocompleteStatePublisher }
.map {
switch $0 {
case .mention(_):
return Mode.mention
case .emoji(_):
return Mode.emoji
case .hashtag(_):
return Mode.hashtag
case nil:
return nil
}
}
.assign(to: &$mode)
}
var view: some View {
AutocompleteView()
}
struct AutocompleteView: View {
@EnvironmentObject private var parent: ComposeController
@EnvironmentObject private var controller: AutocompleteController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
var body: some View {
if let mode = controller.mode {
VStack(spacing: 0) {
Divider()
suggestionsView(mode: mode)
}
.background(backgroundColor)
}
}
@ViewBuilder
private func suggestionsView(mode: Mode) -> some View {
switch mode {
case .mention:
ControllerView(controller: { AutocompleteMentionsController(composeController: parent) })
case .emoji:
ControllerView(controller: { AutocompleteEmojisController(composeController: parent) })
case .hashtag:
ControllerView(controller: { AutocompleteHashtagsController(composeController: parent) })
}
}
private var backgroundColor: Color {
Color(white: colorScheme == .light ? 0.98 : 0.15)
}
private var borderColor: Color {
Color(white: colorScheme == .light ? 0.85 : 0.25)
}
}
enum Mode {
case mention
case emoji
case hashtag
}
}

View File

@ -0,0 +1,188 @@
//
// AutocompleteEmojisController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/26/23.
//
import SwiftUI
import Pachyderm
import Combine
import TuskerComponents
class AutocompleteEmojisController: ViewController {
unowned let composeController: ComposeController
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
private var stateCancellable: AnyCancellable?
private var searchTask: Task<Void, Never>?
@Published var expanded = false
@Published var emojis: [Emoji] = []
@Published var emojisBySection: [String: [Emoji]] = [:]
init(composeController: ComposeController) {
self.composeController = composeController
stateCancellable = composeController.$currentInput
.compactMap { $0 }
.flatMap { $0.autocompleteStatePublisher }
.compactMap {
if case .emoji(let s) = $0 {
return s
} else {
return nil
}
}
.removeDuplicates()
.sink { [unowned self] query in
self.searchTask?.cancel()
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
var emojis = await composeController.mastodonController.getCustomEmojis()
guard !Task.isCancelled else {
return
}
if !query.isEmpty {
emojis =
emojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
}
.filter(\.1.matched)
.sorted { $0.1.score > $1.1.score }
.map(\.0)
}
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() {
withAnimation {
expanded.toggle()
}
}
private func autocomplete(with emoji: Emoji) {
guard let input = composeController.currentInput else { return }
input.autocomplete(with: ":\(emoji.shortcode):")
}
var view: some View {
AutocompleteEmojisView()
}
struct AutocompleteEmojisView: View {
@EnvironmentObject private var composeController: ComposeController
@EnvironmentObject private var controller: AutocompleteEmojisController
@ScaledMetric private var emojiSize = 30
var body: some View {
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
HStack(alignment: controller.expanded ? .top : .center, spacing: 0) {
emojiList
.transition(.move(edge: .bottom))
toggleExpandedButton
.padding(.trailing, 8)
.padding(.top, controller.expanded ? 8 : 0)
}
}
@ViewBuilder
private var emojiList: some View {
if controller.expanded {
verticalGrid
.frame(height: 150)
} else {
horizontalScrollView
}
}
private var verticalGrid: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in
Section {
ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in
Button(action: { controller.autocomplete(with: emoji) }) {
composeController.emojiImageView(emoji)
.frame(height: emojiSize)
}
.accessibilityLabel(emoji.shortcode)
}
} header: {
if !section.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text(section)
.font(.caption)
Divider()
}
.padding(.top, 4)
}
}
}
}
.padding(.all, 8)
// the spacing between the grid sections doesn't seem to be taken into account by the ScrollView?
.padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4)
}
.frame(maxWidth: .infinity)
}
private var horizontalScrollView: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 8) {
ForEach(controller.emojis, id: \.shortcode) { emoji in
Button(action: { controller.autocomplete(with: emoji) }) {
HStack(spacing: 4) {
composeController.emojiImageView(emoji)
.frame(height: emojiSize)
Text(verbatim: ":\(emoji.shortcode):")
.foregroundColor(.primary)
}
}
.accessibilityLabel(emoji.shortcode)
.frame(height: emojiSize)
}
.animation(.linear(duration: 0.2), value: controller.emojis)
}
.padding(.horizontal, 8)
.frame(height: emojiSize + 16)
}
}
private var toggleExpandedButton: some View {
Button(action: controller.toggleExpanded) {
Image(systemName: "chevron.down")
.resizable()
.aspectRatio(contentMode: .fit)
.rotationEffect(controller.expanded ? .zero : .degrees(180))
}
.accessibilityLabel(controller.expanded ? "Collapse" : "Expand")
.frame(width: 20, height: 20)
}
}
}

View File

@ -0,0 +1,125 @@
//
// AutocompleteHashtagsController.swift
// ComposeUI
//
// Created by Shadowfacts on 4/1/23.
//
import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
class AutocompleteHashtagsController: ViewController {
unowned let composeController: ComposeController
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
private var stateCancellable: AnyCancellable?
private var searchTask: Task<Void, Never>?
@Published var hashtags: [Hashtag] = []
init(composeController: ComposeController) {
self.composeController = composeController
stateCancellable = composeController.$currentInput
.compactMap { $0 }
.flatMap { $0.autocompleteStatePublisher }
.compactMap {
if case .hashtag(let s) = $0 {
return s
} else {
return nil
}
}
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
.sink { [unowned self] query in
self.searchTask?.cancel()
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
guard !query.isEmpty else {
hashtags = []
return
}
let localHashtags = mastodonController.searchCachedHashtags(query: query)
var onlyLocalTagsTask: Task<Void, any Error>?
if !localHashtags.isEmpty {
onlyLocalTagsTask = Task {
// we only want to do the local-only search if the trends API call takes more than .25sec or it fails
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
self.updateHashtags(searchResults: [], trendingTags: [], localHashtags: localHashtags, query: query)
}
}
async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0
async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags
let trends = await trendingTags ?? []
let search = await searchResults ?? []
onlyLocalTagsTask?.cancel()
guard !Task.isCancelled else { return }
updateHashtags(searchResults: search, trendingTags: trends, localHashtags: localHashtags, query: query)
}
@MainActor
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], localHashtags: [Hashtag], query: String) {
var addedHashtags = Set<String>()
var hashtags = [(Hashtag, Int)]()
for group in [searchResults, trendingTags, localHashtags] {
for tag in group where !addedHashtags.contains(tag.name) {
let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name)
if matched {
hashtags.append((tag, score))
addedHashtags.insert(tag.name)
}
}
}
self.hashtags = hashtags
.sorted { $0.1 > $1.1 }
.map(\.0)
}
private func autocomplete(with hashtag: Hashtag) {
guard let currentInput = composeController.currentInput else { return }
currentInput.autocomplete(with: "#\(hashtag.name)")
}
var view: some View {
AutocompleteHashtagsView()
}
struct AutocompleteHashtagsView: View {
@EnvironmentObject private var controller: AutocompleteHashtagsController
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
ForEach(controller.hashtags, id: \.name) { hashtag in
Button(action: { controller.autocomplete(with: hashtag) }) {
Text(verbatim: "#\(hashtag.name)")
.foregroundColor(Color(uiColor: .label))
}
.frame(height: 30)
.padding(.vertical, 8)
}
Spacer()
}
.padding(.horizontal, 8)
.animation(.linear(duration: 0.2), value: controller.hashtags)
}
}
}
}

View File

@ -0,0 +1,179 @@
//
// AutocompleteMentionsController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
class AutocompleteMentionsController: ViewController {
unowned let composeController: ComposeController
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
private var stateCancellable: AnyCancellable?
@Published private var accounts: [AnyAccount] = []
private var searchTask: Task<Void, Never>?
init(composeController: ComposeController) {
self.composeController = composeController
stateCancellable = composeController.$currentInput
.compactMap { $0 }
.flatMap { $0.autocompleteStatePublisher }
.compactMap {
if case .mention(let s) = $0 {
return s
} else {
return nil
}
}
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
.sink { [unowned self] query in
self.searchTask?.cancel()
// weak in case the autocomplete controller is dealloc'd racing with the task starting
self.searchTask = Task { [weak self] in
await self?.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
guard !query.isEmpty else {
accounts = []
return
}
let localSearchTask = Task {
// we only want to search locally if the search API call takes more than .25sec or it fails
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
let results = self.mastodonController.searchCachedAccounts(query: query)
try Task.checkCancellation()
if !results.isEmpty {
self.loadAccounts(results.map { .init(value: $0) }, query: query)
}
}
let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0
guard let accounts,
!Task.isCancelled else {
return
}
localSearchTask.cancel()
loadAccounts(accounts.map { .init(value: $0) }, query: query)
}
@MainActor
private func loadAccounts(_ accounts: [AnyAccount], query: String) {
guard case .mention(query) = composeController.currentInput?.autocompleteState else {
return
}
// when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
let ignoreDomain = !query.contains("@")
self.accounts =
accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
return res
}
.filter(\.1.matched)
.map { (account, res) -> (AnyAccount, Int) in
// give higher weight to accounts that the user follows or is followed by
var score = res.score
if let relationship = mastodonController.cachedRelationship(for: account.value.id) {
if relationship.following {
score += 3
}
if relationship.followedBy {
score += 2
}
}
return (account, score)
}
.sorted { $0.1 > $1.1 }
.map(\.0)
}
private func autocomplete(with account: AnyAccount) {
guard let input = composeController.currentInput else {
return
}
input.autocomplete(with: "@\(account.value.acct)")
}
var view: some View {
AutocompleteMentionsView()
}
struct AutocompleteMentionsView: View {
@EnvironmentObject private var controller: AutocompleteMentionsController
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
ForEach(controller.accounts) { account in
AutocompleteMentionButton(account: account)
}
Spacer()
}
.padding(.horizontal, 8)
.animation(.linear(duration: 0.2), value: controller.accounts)
}
.onDisappear {
controller.searchTask?.cancel()
}
}
}
private struct AutocompleteMentionButton: View {
@EnvironmentObject private var composeController: ComposeController
@EnvironmentObject private var controller: AutocompleteMentionsController
let account: AnyAccount
var body: some View {
Button(action: { controller.autocomplete(with: account) }) {
HStack(spacing: 4) {
AvatarImageView(
url: account.value.avatar,
size: 30,
style: composeController.config.avatarStyle,
fetchAvatar: composeController.fetchAvatar
)
VStack(alignment: .leading) {
controller.composeController.displayNameLabel(account.value, .subheadline, 14)
.foregroundColor(.primary)
Text(verbatim: "@\(account.value.acct)")
.font(.caption)
.foregroundColor(.primary)
}
}
}
.frame(height: 30)
.padding(.vertical, 8)
}
}
}
fileprivate struct AnyAccount: Equatable, Identifiable {
let value: any AccountProtocol
var id: String { value.id }
static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
return lhs.value.id == rhs.value.id
}
}

View File

@ -0,0 +1,520 @@
//
// ComposeController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
import MatchedGeometryPresentation
import CoreData
public final class ComposeController: ViewController {
public typealias FetchAttachment = (URL) async -> UIImage?
public typealias FetchStatus = (String) -> (any StatusProtocol)?
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
public typealias EmojiImageView = (Emoji) -> AnyView
@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
let fetchAttachment: FetchAttachment
let fetchStatus: FetchStatus
let displayNameLabel: DisplayNameLabel
let currentAccountContainerView: CurrentAccountContainerView
let replyContentView: ReplyContentView
let emojiImageView: EmojiImageView
@Published public var currentAccount: (any AccountProtocol)?
@Published public var showToolbar = true
@Published public var deleteDraftOnDisappear = true
@Published var autocompleteController: AutocompleteController!
@Published var toolbarController: ToolbarController!
@Published var attachmentsListController: AttachmentsListController!
// this property is here rather than on the AttachmentsListController so that the ComposeView
// updates when it changes, because changes to it may alter postButtonEnabled
@Published var attachmentsMissingDescriptions = Set<UUID>()
@Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
let scrollToAttachment = PassthroughSubject<UUID, Never>()
@Published var contentWarningBecomeFirstResponder = false
@Published var mainComposeTextViewBecomeFirstResponder = false
@Published var currentInput: (any ComposeInput)? = nil
@Published var shouldEmojiAutocompletionBeginExpanded = false
@Published var isShowingSaveDraftSheet = false
@Published var isShowingDraftsList = false
@Published var poster: PostService?
@Published var postError: PostService.Error?
@Published public private(set) var didPostSuccessfully = false
@Published var hasChangedLanguageSelection = false
private var isDisappearing = false
private var userConfirmedDelete = false
public var isPosting: Bool {
poster != nil
}
var charactersRemaining: Int {
let instanceFeatures = mastodonController.instanceFeatures
let limit = instanceFeatures.maxStatusChars
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
}
var postButtonEnabled: Bool {
draft.editedStatusID != nil ||
(draft.hasContent
&& charactersRemaining >= 0
&& !isPosting
&& attachmentsListController.isValid
&& isPollValid)
}
private var isPollValid: Bool {
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
}
public var navigationTitle: String {
if let id = draft.inReplyToID,
let status = fetchStatus(id) {
return "Reply to @\(status.account.acct)"
} else if draft.editedStatusID != nil {
return "Edit Post"
} else {
return "New Post"
}
}
public init(
draft: Draft,
config: ComposeUIConfig,
mastodonController: ComposeMastodonContext,
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
fetchAttachment: @escaping FetchAttachment,
fetchStatus: @escaping FetchStatus,
displayNameLabel: @escaping DisplayNameLabel,
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
replyContentView: @escaping ReplyContentView,
emojiImageView: @escaping EmojiImageView
) {
self.draft = draft
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
self.config = config
self.mastodonController = mastodonController
self.fetchAvatar = fetchAvatar
self.fetchAttachment = fetchAttachment
self.fetchStatus = fetchStatus
self.displayNameLabel = displayNameLabel
self.currentAccountContainerView = currentAccountContainerView
self.replyContentView = replyContentView
self.emojiImageView = emojiImageView
self.autocompleteController = AutocompleteController(parent: self)
self.toolbarController = ToolbarController(parent: self)
self.attachmentsListController = AttachmentsListController(parent: self)
if #available(iOS 16.0, *) {
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
}
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
}
public var view: some View {
ComposeView(poster: poster)
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
.environmentObject(draft)
.environmentObject(mastodonController.instanceFeatures)
.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
}
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
// if providers are videos, this technically allows invalid video/image combinations
return itemProviders.count + draft.attachments.count <= 4
} else {
return false
}
} else {
return true
}
}
public func paste(itemProviders: [NSItemProvider]) {
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.attachmentsListController.canAddAttachment else { return }
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)
}
}
}
}
@MainActor
func cancel() {
if draft.hasContent {
isShowingSaveDraftSheet = true
} else {
deleteDraftOnDisappear = true
config.dismiss(.cancel)
}
}
@MainActor
func cancel(deleteDraft: Bool) {
deleteDraftOnDisappear = true
userConfirmedDelete = deleteDraft
config.dismiss(.cancel)
}
func postStatus() {
guard !isPosting,
draft.editedStatusID != nil || draft.hasContent else {
return
}
Task { @MainActor in
let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
self.poster = poster
// try to resign the first responder, if there is one.
// otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide
// and the first responder to change during a view update, which in turn triggers a bunch of state changes
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
do {
try await poster.post()
deleteDraftOnDisappear = true
didPostSuccessfully = true
// wait .25 seconds so the user can see the progress bar has completed
try? await Task.sleep(nanoseconds: 250_000_000)
// don't unset the poster, so the ui remains disabled while dismissing
config.dismiss(.post)
} catch let error as PostService.Error {
self.postError = error
self.poster = nil
} catch {
fatalError("unreachable")
}
}
}
func showDrafts() {
isShowingDraftsList = true
}
func selectDraft(_ newDraft: Draft) {
let oldDraft = self.draft
self.draft = newDraft
if !oldDraft.hasContent {
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
}
DraftsPersistentContainer.shared.save()
}
func onDisappear() {
isDisappearing = true
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) {
DraftsPersistentContainer.shared.viewContext.delete(draft)
}
DraftsPersistentContainer.shared.save()
}
func toggleContentWarning() {
draft.contentWarningEnabled.toggle()
if draft.contentWarningEnabled {
contentWarningBecomeFirstResponder = true
}
}
@available(iOS 16.0, *)
@objc private func currentInputModeChanged() {
guard let mode = currentInput?.textInputMode,
let code = LanguagePicker.codeFromInputMode(mode),
!hasChangedLanguageSelection && !draft.hasContent else {
return
}
draft.language = code.identifier
}
struct ComposeView: View {
@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?) {
self.poster = poster
}
var config: ComposeUIConfig {
controller.config
}
var body: some View {
NavigationView {
navRoot
}
.navigationViewStyle(.stack)
}
private var navRoot: some View {
ZStack(alignment: .top) {
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
config.backgroundColor
.edgesIgnoringSafeArea(.all)
ScrollViewReader { proxy in
mainList
.onReceive(controller.scrollToAttachment) { id in
proxy.scrollTo(id, anchor: .center)
}
}
if let poster = poster {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if controller.showToolbar {
VStack(spacing: 0) {
ControllerView(controller: { controller.autocompleteController })
.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
.preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
.onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in
globalFrameOutsideList = newValue
}
})
.sheet(isPresented: $controller.isShowingDraftsList) {
ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) })
}
.alertWithData("Error Posting", data: $controller.postError, actions: { _ in
Button("OK") {}
}, message: { error in
Text(error.localizedDescription)
})
.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
} else {
fatalError()
}
}), backgroundColor: .black) {
ControllerView(controller: {
FocusedAttachmentController(
parent: controller,
attachment: controller.focusedAttachment!.0,
thumbnailController: controller.focusedAttachment!.1
)
})
}
.onDisappear(perform: controller.onDisappear)
.navigationTitle(controller.navigationTitle)
.navigationBarTitleDisplayMode(.inline)
}
private var mainList: some View {
List {
if let id = draft.inReplyToID,
let status = controller.fetchStatus(id) {
ReplyStatusView(
status: status,
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)
}
HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
if draft.contentWarningEnabled {
EmojiTextField(
text: $draft.contentWarning,
placeholder: "Write your warning here",
maxLength: nil,
becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
}
MainTextView()
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
if let poll = draft.poll {
ControllerView(controller: { PollController(parent: controller, poll: poll) })
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor)
}
ControllerView(controller: { controller.attachmentsListController })
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
.listRowBackground(config.backgroundColor)
}
.listStyle(.plain)
#if !os(visionOS)
.scrollDismissesKeyboardInteractivelyIfAvailable()
#endif
.disabled(controller.isPosting)
}
private var cancelButton: some View {
Button(action: controller.cancel) {
Text("Cancel")
// 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 {
Button(action: { controller.cancel(deleteDraft: false) }) {
Text("Save Draft")
}
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Delete Draft")
}
} else {
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
Text("Cancel Edit")
}
}
}
}
@ViewBuilder
private var postButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
} else {
Button(action: controller.showDrafts) {
Text("Drafts")
}
}
}
#if !os(visionOS)
@available(iOS, obsoleted: 16.0)
private var keyboardInset: CGFloat {
if #unavailable(iOS 16.0),
UIDevice.current.userInterfaceIdiom == .pad,
keyboardReader.isVisible {
return ToolbarController.height
} else {
return 0
}
}
#endif
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.scrollDismissesKeyboard(.interactively)
} else {
self
}
}
}
private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
static let defaultValue = ComposeUIConfig()
}
extension EnvironmentValues {
var composeUIConfig: ComposeUIConfig {
get { self[ComposeUIConfigEnvironmentKey.self] }
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
}
}

View File

@ -0,0 +1,174 @@
//
// DraftsController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import SwiftUI
import TuskerComponents
import CoreData
class DraftsController: ViewController {
unowned let parent: ComposeController
@Binding var isPresented: Bool
@Published var draftForDifferentReply: Draft?
init(parent: ComposeController, isPresented: Binding<Bool>) {
self.parent = parent
self._isPresented = isPresented
}
var view: some View {
DraftsRepresentable()
}
func maybeSelectDraft(_ draft: Draft) {
if draft.inReplyToID != parent.draft.inReplyToID,
parent.draft.hasContent {
draftForDifferentReply = draft
} else {
confirmSelectDraft(draft)
}
}
func cancelSelectingDraft() {
draftForDifferentReply = nil
}
func confirmSelectDraft(_ draft: Draft) {
parent.selectDraft(draft)
closeDrafts()
}
func deleteDraft(_ draft: Draft) {
DraftsPersistentContainer.shared.viewContext.delete(draft)
}
func closeDrafts() {
isPresented = false
DraftsPersistentContainer.shared.save()
}
struct DraftsRepresentable: UIViewControllerRepresentable {
typealias UIViewControllerType = UIHostingController<DraftsView>
func makeUIViewController(context: Context) -> UIHostingController<DraftsController.DraftsView> {
return UIHostingController(rootView: DraftsView())
}
func updateUIViewController(_ uiViewController: UIHostingController<DraftsController.DraftsView>, context: Context) {
}
}
struct DraftsView: View {
@EnvironmentObject private var controller: DraftsController
@EnvironmentObject private var currentDraft: Draft
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
var body: some View {
NavigationView {
List {
ForEach(drafts) { draft in
Button(action: { controller.maybeSelectDraft(draft) }) {
DraftRow(draft: draft)
}
.contextMenu {
Button(role: .destructive, action: { controller.deleteDraft(draft) }) {
Label("Delete Draft", systemImage: "trash")
}
}
.ifLet(controller.parent.config.userActivityForDraft(draft), modify: { view, activity in
view.onDrag { activity }
})
}
.onDelete { indices in
indices.map { drafts[$0] }.forEach(controller.deleteDraft)
}
}
.listStyle(.plain)
.navigationTitle("Drafts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
}
}
.alertWithData("Different Reply", data: $controller.draftForDifferentReply) { draft in
Button(role: .cancel, action: controller.cancelSelectingDraft) {
Text("Cancel")
}
Button(action: { controller.confirmSelectDraft(draft) }) {
Text("Restore Draft")
}
} message: { _ in
Text("The selected draft is a reply to a different post, do you wish to use it?")
}
.onAppear {
drafts.nsPredicate = NSPredicate(format: "accountID == %@ AND id != %@ AND lastModified != nil", controller.parent.mastodonController.accountInfo!.id, currentDraft.id as NSUUID)
}
}
private var cancelButton: some View {
Button(action: controller.closeDrafts) {
Text("Cancel")
}
}
}
}
private struct DraftRow: View {
@ObservedObject var draft: Draft
@EnvironmentObject private var controller: DraftsController
var body: some View {
HStack {
VStack(alignment: .leading) {
if draft.editedStatusID != nil {
// shouldn't happen unless the app crashed/was killed during an edit
Text("Edit")
.font(.body.bold())
.foregroundColor(.orange)
}
if draft.contentWarningEnabled {
Text(draft.contentWarning)
.font(.body.bold())
.foregroundColor(.secondary)
}
Text(draft.text)
.font(.body)
HStack(spacing: 8) {
ForEach(draft.draftAttachments) { attachment in
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) })
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 5))
.frame(height: 50)
}
}
}
Spacer()
if let lastModified = draft.lastModified {
Text(lastModified.formatted(.abbreviatedTimeAgo))
.font(.body)
.foregroundColor(.secondary)
}
}
}
}
private extension View {
@ViewBuilder
func ifLet<T, V: View>(_ value: T?, modify: (Self, T) -> V) -> some View {
if let value {
modify(self, value)
} else {
self
}
}
}

View File

@ -0,0 +1,122 @@
//
// FocusedAttachmentController.swift
// ComposeUI
//
// Created by Shadowfacts on 4/29/23.
//
import SwiftUI
import MatchedGeometryPresentation
import AVKit
class FocusedAttachmentController: ViewController {
unowned let parent: ComposeController
let attachment: DraftAttachment
let thumbnailController: AttachmentThumbnailController
private let player: AVPlayer?
init(parent: ComposeController, attachment: DraftAttachment, thumbnailController: AttachmentThumbnailController) {
self.parent = parent
self.attachment = attachment
self.thumbnailController = thumbnailController
if case let .file(url, type) = attachment.data,
type.conforms(to: .movie) {
self.player = AVPlayer(url: url)
self.player!.isMuted = true
} else {
self.player = nil
}
}
var view: some View {
FocusedAttachmentView(attachment: attachment)
}
struct FocusedAttachmentView: View {
@ObservedObject var attachment: DraftAttachment
@EnvironmentObject private var controller: FocusedAttachmentController
@Environment(\.dismiss) private var dismiss
@FocusState private var textEditorFocused: Bool
@EnvironmentObject private var matchedGeomState: MatchedGeometryState
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 0)
if let player = controller.player {
VideoPlayer(player: player)
.matchedGeometryDestination(id: attachment.id)
.onAppear {
player.play()
}
} else if #available(iOS 16.0, *) {
ZoomableScrollView {
attachmentView
.matchedGeometryDestination(id: attachment.id)
}
} else {
attachmentView
.matchedGeometryDestination(id: attachment.id)
}
Spacer(minLength: 0)
FocusedAttachmentDescriptionView(attachment: attachment)
.environment(\.colorScheme, .dark)
.matchedGeometryDestination(id: AttachmentDescriptionTextViewID(attachment))
.frame(height: 150)
.focused($textEditorFocused)
}
.background(.black)
.overlay(alignment: .topLeading, content: {
Button {
// set the mode to dismissing immediately, so that layout changes due to the keyboard hiding
// (which happens before the dismiss animation controller starts running) don't alter the destination frames
if textEditorFocused {
matchedGeomState.mode = .dismissing
}
dismiss()
} label: {
Image(systemName: "arrow.down.forward.and.arrow.up.backward")
}
.buttonStyle(DismissFocusedAttachmentButtonStyle())
.padding([.top, .leading], 4)
})
}
private var attachmentView: some View {
ControllerView(controller: { controller.thumbnailController })
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: true))
}
}
}
private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(.black.opacity(0.5))
configuration.label
.foregroundColor(.white)
.imageScale(.large)
}
.frame(width: 40, height: 40)
}
}
struct AttachmentDescriptionTextViewID: Hashable {
let attachmentID: UUID!
init(_ attachment: DraftAttachment) {
self.attachmentID = attachment.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(attachmentID)
hasher.combine("descriptionTextView")
}
}

View File

@ -0,0 +1,48 @@
//
// PlaceholderController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/6/23.
//
import SwiftUI
final class PlaceholderController: ViewController, PlaceholderViewProvider {
private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
static func makePlaceholderView() -> some View {
let components = Calendar.current.dateComponents([.month, .day], from: Date())
if components.month == 3 && components.day == 14,
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
Text("Happy π day!")
} else if components.month == 4 && components.day == 1 {
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
} else if components.month == 9 && components.day == 5 {
// https://weirder.earth/@noracodes/109276419847254552
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
} else if components.month == 9 && components.day == 21 {
Text("Do you remember?")
} else if components.month == 10 && components.day == 31 {
if .random() {
Text("Post something spooky!")
} else {
Text("Any questions?")
}
} else {
Text("What's on your mind?")
}
}
var view: some View {
placeholderView
}
}
// exists to provide access to the type alias since the @State property needs it to be explicit
private protocol PlaceholderViewProvider {
associatedtype PlaceholderView: View
@ViewBuilder
static func makePlaceholderView() -> PlaceholderView
}

View File

@ -0,0 +1,195 @@
//
// PollController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
import TuskerComponents
class PollController: ViewController {
unowned let parent: ComposeController
var draft: Draft { parent.draft }
let poll: Poll
@Published var duration: Duration
init(parent: ComposeController, poll: Poll) {
self.parent = parent
self.poll = poll
self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
}
var view: some View {
PollView()
.environmentObject(poll)
}
private func removePoll() {
withAnimation {
draft.poll = nil
}
}
private func moveOptions(indices: IndexSet, newIndex: Int) {
// see AttachmentsListController.moveAttachments
var array = poll.pollOptions
array.move(fromOffsets: indices, toOffset: newIndex)
poll.options = NSMutableOrderedSet(array: array)
}
private func removeOption(_ option: PollOption) {
var array = poll.pollOptions
array.remove(at: poll.options.index(of: option))
poll.options = NSMutableOrderedSet(array: array)
}
private var canAddOption: Bool {
if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount {
return poll.options.count < max
} else {
return true
}
}
private func addOption() {
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
option.poll = poll
poll.options.add(option)
}
struct PollView: View {
@EnvironmentObject private var controller: PollController
@EnvironmentObject private var poll: Poll
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack {
HStack {
Text("Poll")
.font(.headline)
Spacer()
Button(action: controller.removePoll) {
Image(systemName: "xmark")
.imageScale(.small)
.padding(4)
}
.accessibilityLabel("Remove poll")
.buttonStyle(.plain)
.accentColor(buttonForegroundColor)
.background(Circle().foregroundColor(buttonBackgroundColor))
.hoverEffect()
}
List {
ForEach($poll.pollOptions) { $option in
PollOptionView(option: option, remove: { controller.removeOption(option) })
.frame(height: 36)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onMove(perform: controller.moveOptions)
}
.listStyle(.plain)
.scrollDisabledIfAvailable(true)
.frame(height: 44 * CGFloat(poll.options.count))
Button(action: controller.addOption) {
Label {
Text("Add Option")
} icon: {
Image(systemName: "plus")
.foregroundColor(.accentColor)
}
}
.buttonStyle(.borderless)
.disabled(!controller.canAddOption)
HStack {
MenuPicker(selection: $poll.multiple, options: [
.init(value: true, title: "Allow multiple"),
.init(value: false, title: "Single choice"),
])
.frame(maxWidth: .infinity)
MenuPicker(selection: $controller.duration, options: Duration.allCases.map {
.init(value: $0, title: Duration.formatter.string(from: $0.timeInterval)!)
})
.frame(maxWidth: .infinity)
}
}
.padding(8)
.background(
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 {
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
colorScheme == .dark ? controller.parent.config.fillColor : Color(white: 0.95)
}
private var buttonForegroundColor: Color {
Color(uiColor: .label)
}
private var buttonBackgroundColor: Color {
Color(white: colorScheme == .dark ? 0.1 : 0.8)
}
}
}
extension PollController {
enum Duration: Hashable, Equatable, CaseIterable {
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
static let formatter: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
return f
}()
static func fromTimeInterval(_ ti: TimeInterval) -> Duration? {
for it in allCases where it.timeInterval == ti {
return it
}
return nil
}
var timeInterval: TimeInterval {
switch self {
case .fiveMinutes:
return 5 * 60
case .thirtyMinutes:
return 30 * 60
case .oneHour:
return 60 * 60
case .sixHours:
return 6 * 60 * 60
case .oneDay:
return 24 * 60 * 60
case .threeDays:
return 3 * 24 * 60 * 60
case .sevenDays:
return 7 * 24 * 60 * 60
}
}
}
}

View File

@ -0,0 +1,201 @@
//
// ToolbarController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import SwiftUI
import Pachyderm
import TuskerComponents
class ToolbarController: ViewController {
static let height: CGFloat = 44
unowned let parent: ComposeController
@Published var minWidth: CGFloat?
@Published var realWidth: CGFloat?
init(parent: ComposeController) {
self.parent = parent
}
var view: some View {
ToolbarView()
}
func showEmojiPicker() {
guard parent.currentInput?.autocompleteState == nil else {
return
}
parent.shouldEmojiAutocompletionBeginExpanded = true
parent.currentInput?.beginAutocompletingEmoji()
}
func formatAction(_ format: StatusFormat) -> () -> Void {
{ [weak self] in
self?.parent.currentInput?.applyFormat(format)
}
}
struct ToolbarView: View {
@EnvironmentObject private var draft: Draft
@EnvironmentObject private var controller: ToolbarController
@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) {
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, .leading, .trailing])
.overlay(alignment: .top) {
Divider()
.edgesIgnoringSafeArea([.leading, .trailing])
}
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
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 {
Button("CW", action: controller.parent.toggleContentWarning)
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
.padding(5)
.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: [
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
], buttonStyle: .iconOnly)
}
private var customEmojiButton: some View {
Button(action: controller.showEmojiPicker) {
Label("Insert custom emoji", systemImage: "face.smiling")
}
.labelStyle(.iconOnly)
.font(.system(size: imageSize))
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
private var formatButtons: some View {
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
Button(action: controller.formatAction(format)) {
Image(systemName: format.imageName)
.font(.system(size: imageSize))
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
}
}
}
private struct ToolbarWidthPrefKey: PreferenceKey {
static var defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = nextValue()
}
}

View File

@ -0,0 +1,73 @@
//
// Draft.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
import Pachyderm
@objc
public class Draft: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Draft> {
return NSFetchRequest<Draft>(entityName: "Draft")
}
@nonobjc public class func fetchRequest(id: UUID) -> NSFetchRequest<Draft> {
let req = NSFetchRequest<Draft>(entityName: "Draft")
req.predicate = NSPredicate(format: "id = %@", id as NSUUID)
return req
}
@NSManaged public var accountID: String
@NSManaged public var contentWarning: String
@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 localOnly: Bool
@NSManaged public var text: String
@NSManaged private var visibilityStr: String
@NSManaged internal var attachments: NSMutableOrderedSet
@NSManaged public var poll: Poll?
public var visibility: Visibility {
get {
Visibility(rawValue: visibilityStr) ?? .public
}
set {
visibilityStr = newValue.rawValue
}
}
public var draftAttachments: [DraftAttachment] {
get {
attachments.array as! [DraftAttachment]
}
set {
attachments = NSMutableOrderedSet(array: newValue)
}
}
public override func awakeFromInsert() {
super.awakeFromInsert()
id = UUID()
lastModified = Date()
}
}
extension Draft {
public var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) ||
attachments.count > 0 ||
poll?.hasContent == true
}
}

View File

@ -0,0 +1,329 @@
//
// DraftAttachment.swift
// CoreData
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
import PencilKit
import UniformTypeIdentifiers
import Photos
import InstanceFeatures
import Pachyderm
private let decoder = PropertyListDecoder()
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?
@NSManaged public var editedAttachmentID: String?
@NSManaged private var editedAttachmentKindString: String?
@NSManaged public var editedAttachmentURL: URL?
@NSManaged public var fileURL: URL?
@NSManaged internal var fileType: String?
@NSManaged public var id: UUID!
@NSManaged internal var draft: Draft
public var drawing: PKDrawing? {
get {
if let drawingData,
let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) {
return drawing
} else {
return nil
}
}
set {
drawingData = try! encoder.encode(newValue)
}
}
public var data: AttachmentData {
if let editedAttachmentID {
return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!)
} else if let assetID {
return .asset(assetID)
} else if let drawing {
return .drawing(drawing)
} else if let fileURL, let fileType {
return .file(fileURL, UTType(fileType)!)
} else {
return .none
}
}
public var editedAttachmentKind: Attachment.Kind? {
get {
editedAttachmentKindString.flatMap(Attachment.Kind.init(rawValue:))
}
set {
editedAttachmentKindString = newValue?.rawValue
}
}
public enum AttachmentData {
case asset(String)
case drawing(PKDrawing)
case file(URL, UTType)
case editing(String, Attachment.Kind, URL)
case none
}
public override func prepareForDeletion() {
super.prepareForDeletion()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
}
}
extension DraftAttachment {
var type: AttachmentType {
if let editedAttachmentKind {
switch editedAttachmentKind {
case .image:
return .image
case .video:
return .video
case .gifv:
return .video
case .audio, .unknown:
return .unknown
}
} else if let assetID {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
return .unknown
}
switch asset.mediaType {
case .image:
return .image
case .video:
return .video
default:
return .unknown
}
} else if drawingData != nil {
return .image
} else if let fileType,
let type = UTType(fileType) {
if type.conforms(to: .image) {
return .image
} else if type.conforms(to: .movie) {
return .video
} else {
return .unknown
}
} else {
return .unknown
}
}
enum AttachmentType {
case image, video, unknown
}
}
//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
private let quickTimeType = UTType.quickTimeMovie.identifier
private let gifType = UTType.gif.identifier
extension DraftAttachment: NSItemProviderReading {
public static var readableTypeIdentifiersForItemProvider: [String] {
// 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, 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: type)
attachment.fileType = type.identifier
attachment.attachmentDescription = ""
return attachment
}
static var attachmentsDirectory: URL {
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
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)
return attachmentURL
}
}
// MARK: Exporting
extension DraftAttachment {
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
if let assetID {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
completion(.failure(.noAsset))
return
}
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, let dataUTI else {
completion(.failure(.missingAssetData))
return
}
let processed = Self.processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
}
} else if asset.mediaType == .video {
let options = PHVideoRequestOptions()
options.version = .current
options.deliveryMode = .automatic
options.isNetworkAccessAllowed = true
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
if let exportSession {
Self.exportVideoData(session: exportSession, features: features, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
completion(.failure(.noVideoExportSession))
}
}
} else {
completion(.failure(.unknownAssetType))
}
} else if let drawingData {
guard let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) else {
completion(.failure(.loadingDrawing))
return
}
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(.success((image.pngData()!, .png)))
} else if let fileURL, let fileType {
let type = UTType(fileType)!
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: fileURL)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
completion(.failure(.noVideoExportSession))
return
}
Self.exportVideoData(session: session, features: features, completion: completion)
} else {
let fileData: Data
do {
fileData = try Data(contentsOf: fileURL)
} catch {
completion(.failure(.loadingData))
return
}
if type != .gif,
type.conforms(to: .image) {
let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion)
completion(.success(result))
} else {
completion(.success((fileData, type)))
}
}
} else {
completion(.failure(.noData))
}
}
private static 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 || type == .heif {
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, 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!)))
return
}
do {
let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, .mpeg4Movie)))
} catch {
completion(.failure(.videoExport(error)))
}
}
}
enum ExportError: Error {
case noAsset
case unknownAssetType
case missingAssetData
case videoExport(Error)
case noVideoExportSession
case loadingDrawing
case loadingData
case noData
}
}

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<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"/>
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="text" attributeType="String" defaultValueString=""/>
<attribute name="visibilityStr" optional="YES" attributeType="String"/>
<relationship name="attachments" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="DraftAttachment" inverseName="draft" inverseEntity="DraftAttachment"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="draft" inverseEntity="Poll"/>
</entity>
<entity name="DraftAttachment" representedClassName="ComposeUI.DraftAttachment" syncable="YES">
<attribute name="assetID" optional="YES" attributeType="String"/>
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
<attribute name="editedAttachmentID" optional="YES" attributeType="String"/>
<attribute name="editedAttachmentKindString" optional="YES" attributeType="String"/>
<attribute name="editedAttachmentURL" optional="YES" attributeType="URI"/>
<attribute name="fileType" optional="YES" attributeType="String"/>
<attribute name="fileURL" optional="YES" attributeType="URI"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="attachments" inverseEntity="Draft"/>
</entity>
<entity name="Poll" representedClassName="ComposeUI.Poll" syncable="YES">
<attribute name="duration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="poll" inverseEntity="Draft"/>
<relationship name="options" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
</entity>
<entity name="PollOption" representedClassName="ComposeUI.PollOption" syncable="YES">
<attribute name="text" attributeType="String" defaultValueString=""/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
</entity>
</model>

View File

@ -0,0 +1,214 @@
//
// DraftsPersistentContainer.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import Foundation
import CoreData
import OSLog
import Pachyderm
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer")
public class DraftsPersistentContainer: NSPersistentContainer {
public static let shared = DraftsPersistentContainer()
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private var lastHistoryToken: NSPersistentHistoryToken!
init() {
super.init(name: "Drafts", managedObjectModel: DraftsPersistentContainer.managedObjectModel)
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
let documentsURL = containerURL.appendingPathComponent("Documents")
let storeDesc = NSPersistentStoreDescription(url: documentsURL.appendingPathComponent("drafts").appendingPathExtension("sqlite"))
storeDesc.type = NSSQLiteStoreType
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
persistentStoreDescriptions = [
storeDesc
]
loadPersistentStores { _, error in
if let error {
fatalError("Loading persistent store: \(error)")
}
}
viewContext.automaticallyMergesChangesFromParent = true
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
lastHistoryToken = persistentStoreCoordinator.currentPersistentHistoryToken(fromStores: nil)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges(_:)), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
}
public func save() {
guard viewContext.hasChanges else {
return
}
do {
try viewContext.save()
} catch {
logger.error("Failed to save: \(String(describing: error))")
}
}
public func migrate(from url: URL, completion: @escaping (Result<(), any Error>) -> Void) {
performBackgroundTask { context in
let result = DraftsMigrator.migrate(from: url, to: context)
completion(result)
try! context.save()
}
}
public func getDraft(id: UUID) -> Draft? {
let req = Draft.fetchRequest(id: id)
return try? viewContext.fetch(req).first
}
public func createDraft(
accountID: String,
text: String,
contentWarning: String,
inReplyToID: String?,
visibility: Visibility,
language: String?,
localOnly: Bool
) -> Draft {
let draft = Draft(context: viewContext)
draft.accountID = accountID
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
}
public func createEditDraft(
accountID: String,
source: StatusSource,
inReplyToID: String?,
visibility: Visibility,
localOnly: Bool,
attachments: [Attachment],
poll: Pachyderm.Poll?
) -> Draft {
let draft = Draft(context: viewContext)
draft.accountID = accountID
draft.editedStatusID = source.id
draft.text = source.text
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
for attachment in attachments {
createEditDraftAttachment(attachment, in: draft)
}
if let existingPoll = poll {
let poll = Poll(context: viewContext)
poll.draft = draft
draft.poll = poll
if let expiresAt = existingPoll.expiresAt,
!existingPoll.effectiveExpired {
poll.duration = PollController.Duration.allCases.max(by: {
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
})!.timeInterval
} else {
poll.duration = PollController.Duration.oneDay.timeInterval
}
poll.multiple = existingPoll.multiple
// rmeove default empty options
for opt in poll.pollOptions {
viewContext.delete(opt)
}
for existingOpt in existingPoll.options {
let opt = PollOption(context: viewContext)
opt.poll = poll
poll.options.add(opt)
opt.text = existingOpt.title
}
}
save()
return draft
}
private func createEditDraftAttachment(_ attachment: Attachment, in draft: Draft) {
let draftAttachment = DraftAttachment(context: viewContext)
draftAttachment.id = UUID()
draftAttachment.attachmentDescription = attachment.description ?? ""
draftAttachment.editedAttachmentID = attachment.id
draftAttachment.editedAttachmentKind = attachment.kind
draftAttachment.editedAttachmentURL = attachment.url
draftAttachment.draft = draft
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
}
// todo: should this be on a background context?
let context = viewContext
context.perform {
let predicate = NSPredicate(format: "(%@ < token) AND (token <= %@)", self.lastHistoryToken, newHistoryToken)
let historyRequest = NSPersistentHistoryTransaction.fetchRequest!
historyRequest.predicate = predicate
let request = NSPersistentHistoryChangeRequest.fetchHistory(withFetch: historyRequest)
if let result = try? context.execute(request) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction] {
for transaction in transactions {
guard let userInfo = transaction.objectIDNotification().userInfo else {
continue
}
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context])
}
}
self.lastHistoryToken = newHistoryToken
}
}
}

View File

@ -0,0 +1,46 @@
//
// Poll.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
@objc
public class Poll: NSManagedObject {
@NSManaged public var duration: TimeInterval
@NSManaged public var multiple: Bool
@NSManaged public var draft: Draft
@NSManaged public var options: NSMutableOrderedSet
public var pollOptions: [PollOption] {
get {
options.array as! [PollOption]
}
set {
options = NSMutableOrderedSet(array: newValue)
}
}
public override func awakeFromInsert() {
super.awakeFromInsert()
self.multiple = false
self.duration = 24 * 60 * 60 // 1 day
if let managedObjectContext {
self.options = [
PollOption(context: managedObjectContext),
PollOption(context: managedObjectContext),
]
}
}
}
extension Poll {
public var hasContent: Bool {
pollOptions.allSatisfy { !$0.text.isEmpty }
}
}

View File

@ -0,0 +1,21 @@
//
// PollOption.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import CoreData
@objc
public class PollOption: NSManagedObject, Identifiable {
public var id: NSManagedObjectID {
objectID
}
@NSManaged public var text: String
@NSManaged public var poll: Poll
}

View File

@ -0,0 +1,255 @@
//
// DraftsMigrator.swift
// ComposeUI
//
// Created by Shadowfacts on 4/22/23.
//
import Foundation
import OSLog
import UniformTypeIdentifiers
import Pachyderm
import PencilKit
import CoreData
struct DraftsMigrator {
private init() {}
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsMigrator")
private static let decoder = PropertyListDecoder()
static func migrate(from url: URL, to context: NSManagedObjectContext) -> Result<(), any Error> {
do {
let data = try Data(contentsOf: url)
let container = try decoder.decode(DraftsContainer.self, from: data)
for old in container.drafts.values {
let new = Draft(context: context)
new.id = old.id
new.lastModified = old.lastModified
new.accountID = old.accountID
new.text = old.text
new.contentWarningEnabled = old.contentWarningEnabled
new.contentWarning = old.contentWarning
new.inReplyToID = old.inReplyToID
new.visibility = old.visibility
new.localOnly = old.localOnly
new.initialText = old.initialText
if let oldPoll = old.poll {
let newPoll = Poll(context: context)
newPoll.draft = new
new.poll = newPoll
newPoll.multiple = oldPoll.multiple
newPoll.duration = oldPoll.duration
for oldOption in oldPoll.options {
let newOption = PollOption(context: context)
newOption.text = oldOption.text
newOption.poll = newPoll
newPoll.options.add(newOption)
}
}
for oldAttachment in old.attachments {
let newAttachment = DraftAttachment(context: context)
newAttachment.draft = new
new.attachments.add(newAttachment)
newAttachment.id = oldAttachment.id
newAttachment.attachmentDescription = oldAttachment.attachmentDescription
switch oldAttachment.data {
case .asset(let assetID):
newAttachment.assetID = assetID
case .image(let data, originalType: let type):
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: type)
newAttachment.fileType = type.identifier
case .video(_):
fatalError("unreachable, video attachments weren't encodable")
case .drawing(let drawing):
newAttachment.drawing = drawing
case .gif(let data):
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: .gif)
newAttachment.fileType = UTType.gif.identifier
}
}
}
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Error migrating: \(String(describing: error))")
return .failure(error)
}
return .success(())
}
// MARK: Supporting Types
struct DraftsContainer: Decodable {
let drafts: [UUID: OldDraft]
init(drafts: [UUID: OldDraft]) {
self.drafts = drafts
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft)
}
enum CodingKeys: CodingKey {
case drafts
}
}
// a container that always succeeds at decoding
// so if a single draft can't be decoded, we don't lose all drafts
struct SafeDraft: Decodable {
let draft: OldDraft?
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.draft = try? container.decode(OldDraft.self)
}
}
struct OldDraft: Decodable {
let id: UUID
let lastModified: Date
let accountID: String
let text: String
let contentWarningEnabled: Bool
let contentWarning: String
let attachments: [OldDraftAttachment]
let inReplyToID: String?
let visibility: Visibility
let poll: OldPoll?
let localOnly: Bool
let initialText: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
self.accountID = try container.decode(String.self, forKey: .accountID)
self.text = try container.decode(String.self, forKey: .text)
self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled)
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
self.attachments = try container.decode([OldDraftAttachment].self, forKey: .attachments)
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
self.visibility = try container.decode(Visibility.self, forKey: .visibility)
self.poll = try container.decode(OldPoll?.self, forKey: .poll)
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
self.initialText = try container.decode(String.self, forKey: .initialText)
}
enum CodingKeys: String, CodingKey {
case id
case lastModified
case accountID
case text
case contentWarningEnabled
case contentWarning
case attachments
case inReplyToID
case visibility
case poll
case localOnly
case initialText
}
}
struct OldDraftAttachment: Decodable {
let id: UUID
let data: OldDraftAttachmentData
let attachmentDescription: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.data = try container.decode(OldDraftAttachmentData.self, forKey: .data)
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
}
enum CodingKeys: String, CodingKey {
case id
case data
case attachmentDescription
}
}
enum OldDraftAttachmentData: Decodable {
case asset(String)
case image(Data, originalType: UTType)
case video(URL)
case drawing(PKDrawing)
case gif(Data)
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)
self = .asset(identifier)
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
}
}
struct OldPoll: Decodable {
let options: [OldPollOption]
let multiple: Bool
let duration: TimeInterval
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.options = try container.decode([OldPollOption].self, forKey: .options)
self.multiple = try container.decode(Bool.self, forKey: .multiple)
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
}
enum CodingKeys: String, CodingKey {
case options
case multiple
case duration
}
}
struct OldPollOption: Decodable {
let text: String
init(from decoder: Decoder) throws {
self.text = try decoder.singleValueContainer().decode(String.self)
}
}
}

View File

@ -0,0 +1,43 @@
//
// KeyboardReader.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
#if !os(visionOS)
import UIKit
import Combine
@available(iOS, obsoleted: 16.0)
class KeyboardReader: ObservableObject {
// @Published var isVisible = false
@Published var keyboardHeight: CGFloat = 0
var isVisible: Bool {
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
keyboardHeight > 72
}
init() {
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func willShow(_ notification: Foundation.Notification) {
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
// isVisible = endFrame.height > 72
keyboardHeight = endFrame.height
}
@objc func willHide() {
// sometimes willHide is called during a SwiftUI view update
DispatchQueue.main.async {
// self.isVisible = false
self.keyboardHeight = 0
}
}
}
#endif

View File

@ -0,0 +1,12 @@
//
// DismissMode.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import Foundation
public enum DismissMode {
case cancel, post
}

View File

@ -1,6 +1,6 @@
//
// StatusFormat.swift
// Tusker
// ComposeUI
//
// Created by Shadowfacts on 1/12/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
@ -12,8 +12,8 @@ import Pachyderm
enum StatusFormat: Int, CaseIterable {
case bold, italics, strikethrough, code
var insertionResult: FormatInsertionResult? {
switch Preferences.shared.statusContentType {
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
switch contentType {
case .plain:
return nil
case .markdown:
@ -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"
}
}
@ -60,7 +52,7 @@ enum StatusFormat: Int, CaseIterable {
typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int)
protocol FormatType {
fileprivate protocol FormatType {
static func format(_ format: StatusFormat) -> FormatInsertionResult
}

View File

@ -0,0 +1,33 @@
//
// OptionalObservedObject.swift
// ComposeUI
//
// Created by Shadowfacts on 4/15/23.
//
import SwiftUI
import Combine
@propertyWrapper
struct OptionalObservedObject<T: ObservableObject>: DynamicProperty {
private class Republisher: ObservableObject {
var cancellable: AnyCancellable?
var wrapped: T? {
didSet {
cancellable?.cancel()
cancellable = wrapped?.objectWillChange
.receive(on: RunLoop.main)
.sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}
@StateObject private var republisher = Republisher()
var wrappedValue: T?
func update() {
republisher.wrapped = wrappedValue
}
}

View File

@ -1,6 +1,6 @@
//
// PKDrawing+Render.swift
// Tusker
// ComposeUI
//
// Created by Shadowfacts on 5/9/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
@ -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,5 +1,5 @@
//
// ComposeTextViewCaretScrolling.swift
// TextViewCaretScrolling.swift
// Tusker
//
// Created by Shadowfacts on 11/11/20.
@ -8,11 +8,11 @@
import UIKit
protocol ComposeTextViewCaretScrolling: AnyObject {
protocol TextViewCaretScrolling: AnyObject {
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
}
extension ComposeTextViewCaretScrolling {
extension TextViewCaretScrolling {
func ensureCursorVisible(textView: UITextView) {
guard textView.isFirstResponder,
let range = textView.selectedTextRange,

View File

@ -0,0 +1,183 @@
//
// UITextInput+Autocomplete.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import UIKit
import SwiftUI
extension UITextInput {
func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) {
guard let selectedTextRange,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
return
}
let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument)
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
let insertSpace: Bool
if distanceToEnd > 0 {
let charAfterCursor = text[characterBeforeCursorIndex]
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
} else {
insertSpace = true
}
let string = insertSpace ? string + " " : string
let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))!
let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)!
replace(lastWordRange, withText: string)
autocompleteState = updateAutocompleteState(permittedModes: permittedModes)
// keep the cursor at the same position in the text, immediately after what was inserted
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
let insertSpaceOffset = insertSpace ? 0 : 1
let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)!
self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition)
}
func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? {
guard let selectedTextRange,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
!text.isEmpty,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
return nil
}
let triggerChars = permittedModes.triggerChars
if lastWordStartIndex > text.startIndex {
// if the character before the "word" beginning is a valid part of a "word",
// we aren't able to autocomplete
let c = text[text.index(before: lastWordStartIndex)]
if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
return nil
}
}
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start))
if lastWordStartIndex >= text.startIndex {
let lastWord = text[lastWordStartIndex..<characterBeforeCursorIndex]
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
// periods are only allowed in mentions in the domain part
if lastWord.contains(".") {
if lastWord.first == "@" && foundFirstAtSign && permittedModes.contains(.mentions) {
return .mention(String(exceptFirst))
} else {
return nil
}
}
switch lastWord.first {
case "@" where permittedModes.contains(.mentions):
return .mention(String(exceptFirst))
case ":" where permittedModes.contains(.emojis):
return .emoji(String(exceptFirst))
case "#" where permittedModes.contains(.hashtags):
return .hashtag(String(exceptFirst))
default:
return nil
}
} else {
return nil
}
}
private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? {
guard (self as? UIView)?.isFirstResponder == true,
let selectedTextRange,
selectedTextRange.isEmpty,
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
let text = self.text(in: wholeDocumentRange),
!text.isEmpty else {
return nil
}
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
guard cursorIndex != text.startIndex else {
return nil
}
var lastWordStartIndex = text.index(before: cursorIndex)
var foundFirstAtSign = false
while true {
let c = text[lastWordStartIndex]
if !isPermittedForAutocomplete(c) {
if foundFirstAtSign {
if c != "@" {
// move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
lastWordStartIndex = text.index(after: lastWordStartIndex)
}
break
} else {
if c == "@" {
foundFirstAtSign = true
} else if c != "." {
// periods are allowed for domain names in mentions
break
}
}
}
guard lastWordStartIndex > text.startIndex else {
break
}
lastWordStartIndex = text.index(before: lastWordStartIndex)
}
return (lastWordStartIndex, foundFirstAtSign)
}
}
enum AutocompleteState: Equatable {
case mention(String)
case emoji(String)
case hashtag(String)
}
struct AutocompleteModes: OptionSet {
static let mentions = AutocompleteModes(rawValue: 1 << 0)
static let hashtags = AutocompleteModes(rawValue: 1 << 2)
static let emojis = AutocompleteModes(rawValue: 1 << 3)
static let all: AutocompleteModes = [
.mentions,
.hashtags,
.emojis,
]
let rawValue: Int
var triggerChars: [Character] {
var chars: [Character] = []
if contains(.mentions) {
chars.append("@")
}
if contains(.hashtags) {
chars.append("#")
}
if contains(.emojis) {
chars.append(":")
}
return chars
}
}
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
}

View File

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

View File

@ -0,0 +1,29 @@
//
// ViewController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Combine
public protocol ViewController: ObservableObject {
associatedtype ContentView: View
@ViewBuilder
var view: ContentView { get }
}
public struct ControllerView<Controller: ViewController>: View {
@StateObject private var controller: Controller
public init(controller: @escaping () -> Controller) {
self._controller = StateObject(wrappedValue: controller())
}
public var body: some View {
controller.view
.environmentObject(controller)
}
}

View File

@ -0,0 +1,151 @@
//
// AttachmentDescriptionTextView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/12/23.
//
import SwiftUI
private var placeholder: some View {
Text("Describe for the visually impaired…")
}
struct InlineAttachmentDescriptionView: View {
@ObservedObject private var attachment: DraftAttachment
private let minHeight: CGFloat
@State private var height: CGFloat?
init(attachment: DraftAttachment, minHeight: CGFloat) {
self.attachment = attachment
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(placeholderOffset)
}
WrappedTextView(
text: $attachment.attachmentDescription,
backgroundColor: .clear,
textDidChange: self.textDidChange
)
.frame(height: height ?? minHeight)
}
}
private func textDidChange(_ textView: UITextView) {
height = max(minHeight, textView.contentSize.height)
}
}
struct FocusedAttachmentDescriptionView: View {
@ObservedObject var attachment: DraftAttachment
var body: some View {
ZStack(alignment: .topLeading) {
WrappedTextView(
text: $attachment.attachmentDescription,
backgroundColor: .secondarySystemBackground,
textDidChange: nil
)
.edgesIgnoringSafeArea([.bottom, .leading, .trailing])
if attachment.attachmentDescription.isEmpty {
placeholder
.font(.body)
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
.allowsHitTesting(false)
}
}
}
}
private struct WrappedTextView: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
let backgroundColor: UIColor
let textDidChange: (((UITextView) -> Void))?
@Environment(\.isEnabled) private var isEnabled
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.delegate = context.coordinator
view.backgroundColor = backgroundColor
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
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.text = text
uiView.isEditable = isEnabled
context.coordinator.textView = uiView
context.coordinator.text = $text
context.coordinator.didChange = textDidChange
if let textDidChange {
// wait until the next runloop iteration so that SwiftUI view updates have finished and
// the text view knows its new content size
DispatchQueue.main.async {
textDidChange(uiView)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text, didChange: textDidChange)
}
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
weak var textView: UITextView?
var text: Binding<String>
var didChange: ((UITextView) -> Void)?
var caretScrollPositionAnimator: UIViewPropertyAnimator?
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
self.text = text
self.didChange = didChange
super.init()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
}
@objc private func keyboardDidShow() {
guard let textView,
textView.isFirstResponder else {
return
}
ensureCursorVisible(textView: textView)
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
didChange?(textView)
ensureCursorVisible(textView: textView)
}
}
}

View File

@ -0,0 +1,45 @@
//
// CurrentAccountView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Pachyderm
import TuskerComponents
struct CurrentAccountView: View {
let account: (any AccountProtocol)?
@EnvironmentObject private var controller: ComposeController
var body: some View {
controller.currentAccountContainerView(AnyView(currentAccount))
}
private var currentAccount: some View {
HStack(alignment: .top) {
AvatarImageView(
url: account?.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.accessibilityHidden(true)
if let account {
VStack(alignment: .leading) {
controller.displayNameLabel(account, .title2, 24)
.lineLimit(1)
Text(verbatim: "@\(account.acct)")
.font(.body.weight(.light))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
}
}
}

View File

@ -0,0 +1,148 @@
//
// EmojiTextField.swift
// ComposeUI
//
// Created by Shadowfacts on 3/5/23.
//
import SwiftUI
struct EmojiTextField: UIViewRepresentable {
typealias UIViewType = UITextField
@EnvironmentObject private var controller: ComposeController
@Environment(\.colorScheme) private var colorScheme
@Binding var text: String
let placeholder: String
let maxLength: Int?
let becomeFirstResponder: Binding<Bool>?
let focusNextView: Binding<Bool>?
init(text: Binding<String>, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
self._text = text
self.placeholder = placeholder
self.maxLength = maxLength
self.becomeFirstResponder = becomeFirstResponder
self.focusNextView = focusNextView
}
func makeUIView(context: Context) -> UITextField {
let view = UITextField()
view.borderStyle = .roundedRect
view.font = .preferredFont(forTextStyle: .body)
view.adjustsFontForContentSizeCategory = true
view.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
.foregroundColor: UIColor.secondaryLabel,
])
context.coordinator.textField = view
view.delegate = context.coordinator
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
// otherwise when the text gets too wide it starts expanding the ComposeView
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view
}
func updateUIView(_ uiView: UITextField, context: Context) {
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 {
uiView.becomeFirstResponder()
becomeFirstResponder!.wrappedValue = false
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
}
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
let controller: ComposeController
var text: Binding<String>
var focusNextView: Binding<Bool>?
var maxLength: Int?
@Published var autocompleteState: AutocompleteState?
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
weak var textField: UITextField?
init(controller: ComposeController, text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) {
self.controller = controller
self.text = text
self.focusNextView = focusNextView
self.maxLength = maxLength
}
@objc func didChange(_ textField: UITextField) {
text.wrappedValue = textField.text ?? ""
}
@objc func returnKeyPressed() {
focusNextView?.wrappedValue = true
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if let maxLength {
return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
} else {
return true
}
}
func textFieldDidBeginEditing(_ textField: UITextField) {
controller.currentInput = self
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
func textFieldDidEndEditing(_ textField: UITextField) {
controller.currentInput = nil
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
func textFieldDidChangeSelection(_ textField: UITextField) {
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
}
// MARK: ComposeInput
var toolbarElements: [ToolbarElement] { [.emojiPicker] }
var textInputMode: UITextInputMode? {
textField?.textInputMode
}
func applyFormat(_ format: StatusFormat) {
}
func beginAutocompletingEmoji() {
textField?.insertText(":")
}
func autocomplete(with string: String) {
textField?.autocomplete(with: string, permittedModes: .emojis, autocompleteState: &autocompleteState)
}
}
}

View File

@ -0,0 +1,31 @@
//
// HeaderView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
import Pachyderm
import InstanceFeatures
struct HeaderView: View {
let currentAccount: (any AccountProtocol)?
let charsRemaining: Int
var body: some View {
HStack(alignment: .top) {
CurrentAccountView(account: currentAccount)
.accessibilitySortPriority(1)
Spacer()
Text(verbatim: charsRemaining.description)
.foregroundColor(charsRemaining < 0 ? .red : .secondary)
.font(Font.body.monospacedDigit())
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
// this should come first, so VO users can back to it from the main compose text view
.accessibilitySortPriority(0)
}.frame(height: 50)
}
}

View File

@ -0,0 +1,221 @@
//
// LanguagePicker.swift
// ComposeUI
//
// Created by Shadowfacts on 5/4/23.
//
import SwiftUI
@available(iOS 16.0, *)
struct LanguagePicker: View {
@Binding var draftLanguage: String?
@Binding var hasChangedSelection: Bool
@State private var isShowingSheet = false
private var codeFromDraft: Locale.LanguageCode? {
draftLanguage.map(Locale.LanguageCode.init(_:))
}
private var codeFromActiveInputMode: Locale.LanguageCode? {
UITextInputMode.activeInputModes.first.flatMap(Self.codeFromInputMode(_:))
}
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
guard let bcp47Lang = mode.primaryLanguage,
!bcp47Lang.isEmpty else {
return nil
}
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: min(3, bcp47Lang.count))]
if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
}
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 {
return nil
}
}
private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first,
case let code = Locale.LanguageCode(identifier),
code.isISOLanguage {
return code
} else {
return nil
}
}
private var languageCode: Binding<Locale.LanguageCode> {
Binding {
return codeFromDraft ?? codeFromActiveInputMode ?? codeFromPreferredLanguages ?? .english
} set: { newValue in
draftLanguage = newValue.identifier
}
}
var body: some View {
Button {
isShowingSheet = true
} label: {
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)
}
.presentationDetents([.large, .medium])
}
}
}
@available(iOS 16.0, *)
private struct LanguagePickerList: View {
@Binding var languageCode: Locale.LanguageCode
@Binding var hasChangedSelection: Bool
@Binding var isPresented: Bool
@Environment(\.composeUIConfig.groupedBackgroundColor) private var groupedBackgroundColor
@Environment(\.composeUIConfig.groupedCellBackgroundColor) private var groupedCellBackgroundColor
@State private var recentLangs: [Lang] = []
@State private var langs: [Lang] = []
@State private var filteredLangs: [Lang]?
@State private var query = ""
private var defaults: UserDefaults {
UserDefaults(suiteName: "group.space.vaccor.Tusker") ?? .standard
}
private var recentIdentifiers: [String] {
get {
defaults.object(forKey: "LanguagePickerRecents") as? [String] ?? []
}
nonmutating set {
defaults.set(newValue, forKey: "LanguagePickerRecents")
}
}
var body: some View {
List {
Section {
ForEach(recentLangs) { lang in
button(for: lang)
}
.listRowBackground(groupedCellBackgroundColor)
} header: {
Text("Recently Used")
}
Section {
ForEach(filteredLangs ?? langs) { lang in
button(for: lang)
}
.listRowBackground(groupedCellBackgroundColor)
} header: {
Text("All Languages")
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
.searchable(text: $query)
#if !os(visionOS)
.scrollDismissesKeyboard(.interactively)
#endif
.navigationTitle("Post Language")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
isPresented = false
}
}
}
.onAppear {
// 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
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)
}
}
}
@discardableResult
private func addRecentLang(_ code: Locale.LanguageCode) -> [String] {
var recents = recentIdentifiers
if !recents.contains(languageCode.identifier) {
recents.insert(languageCode.identifier, at: 0)
if recents.count > 5 {
recents = Array(recents[..<5])
}
recentIdentifiers = recents
}
return recents
}
private func button(for lang: Lang) -> some View {
Button {
languageCode = lang.code
hasChangedSelection = true
isPresented = false
addRecentLang(lang.code)
} label: {
HStack {
Text(lang.name)
Spacer()
if lang.code == languageCode {
Image(systemName: "checkmark")
}
}
}
}
struct Lang: Identifiable {
let code: Locale.LanguageCode
let name: String
var id: String {
code.identifier
}
init(code: Locale.LanguageCode) {
self.code = code
self.name = Locale.current.localizedString(forLanguageCode: code.identifier) ?? code.identifier
}
}
}

View File

@ -0,0 +1,336 @@
//
// MainTextView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/6/23.
//
import SwiftUI
struct MainTextView: View {
@EnvironmentObject private var controller: ComposeController
@EnvironmentObject private var draft: Draft
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var fontSize = 20
@State private var hasFirstAppeared = false
@State private var height: CGFloat?
@State private var updateSelection: ((UITextView) -> Void)?
private let minHeight: CGFloat = 150
private var effectiveHeight: CGFloat { height ?? minHeight }
var config: ComposeUIConfig {
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) {
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(placeholderOffset)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}
.frame(height: effectiveHeight)
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
}
private func becomeFirstResponderOnFirstAppearance() {
if !hasFirstAppeared {
hasFirstAppeared = true
controller.mainComposeTextViewBecomeFirstResponder = true
if config.textSelectionStartsAtBeginning {
updateSelection = { textView in
textView.selectedTextRange = textView.textRange(from: textView.beginningOfDocument, to: textView.beginningOfDocument)
}
}
}
}
private func textDidChange(textView: UITextView) {
height = max(textView.contentSize.height, minHeight)
}
}
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
@EnvironmentObject private var controller: ComposeController
@Environment(\.isEnabled) private var isEnabled: Bool
func makeUIView(context: Context) -> UITextView {
let textView = WrappedTextView(composeController: controller)
context.coordinator.textView = textView
textView.delegate = context.coordinator
textView.isEditable = true
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
}
func updateUIView(_ uiView: UITextView, context: Context) {
if text != uiView.text {
context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true
uiView.text = text
}
uiView.isEditable = isEnabled
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
uiView.backgroundColor = backgroundColor
context.coordinator.text = $text
if let updateSelection {
updateSelection(uiView)
self.updateSelection = nil
}
// wait until the next runloop iteration so that SwiftUI view updates have finished and
// the text view knows its new content size
DispatchQueue.main.async {
textDidChange(uiView)
if becomeFirstResponder {
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
uiView.becomeFirstResponder()
// can't update @State vars during the SwiftUI update
becomeFirstResponder = false
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(controller: controller, text: $text, textDidChange: textDidChange)
}
class WrappedTextView: UITextView {
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
private let composeController: ComposeController
init(composeController: ComposeController) {
self.composeController = composeController
super.init(frame: .zero, textContainer: nil)
}
required init?(coder: NSCoder) {
fatalError()
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if formattingActions.contains(action) {
return composeController.config.contentType != .plain
}
return super.canPerformAction(action, withSender: sender)
}
override func toggleBoldface(_ sender: Any?) {
(delegate as! Coordinator).applyFormat(.bold)
}
override func toggleItalics(_ sender: Any?) {
(delegate as! Coordinator).applyFormat(.italics)
}
override func validate(_ command: UICommand) {
super.validate(command)
if formattingActions.contains(command.action),
composeController.config.contentType != .plain {
command.attributes.remove(.disabled)
}
}
override func paste(_ sender: Any?) {
// we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion
// and things like URLs end up pasting as attachments
if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) {
composeController.paste(itemProviders: UIPasteboard.general.itemProviders)
} else {
super.paste(sender)
}
}
}
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling {
weak var textView: UITextView?
let controller: ComposeController
var text: Binding<String>
let textDidChange: (UITextView) -> Void
var caretScrollPositionAnimator: UIViewPropertyAnimator?
@Published var autocompleteState: AutocompleteState?
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
var skipNextSelectionChangedAutocompleteUpdate = false
init(controller: ComposeController, text: Binding<String>, textDidChange: @escaping (UITextView) -> Void) {
self.controller = controller
self.text = text
self.textDidChange = textDidChange
super.init()
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
}
@objc private func keyboardDidShow() {
guard let textView,
textView.isFirstResponder else {
return
}
ensureCursorVisible(textView: textView)
}
// MARK: UITextViewDelegate
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
textDidChange(textView)
ensureCursorVisible(textView: textView)
}
func textViewDidBeginEditing(_ textView: UITextView) {
controller.currentInput = self
updateAutocompleteState()
}
func textViewDidEndEditing(_ textView: UITextView) {
controller.currentInput = nil
updateAutocompleteState()
}
func textViewDidChangeSelection(_ textView: UITextView) {
if skipNextSelectionChangedAutocompleteUpdate {
skipNextSelectionChangedAutocompleteUpdate = false
} else {
updateAutocompleteState()
}
}
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
var actions = suggestedActions
if controller.config.contentType != .plain,
let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) {
if range.length > 0 {
let formatMenu = suggestedActions[index] as! UIMenu
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
self?.applyFormat(fmt)
}
})
actions[index] = newFormatMenu
} else {
actions.remove(at: index)
}
}
if range.length == 0 {
actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
self?.controller.shouldEmojiAutocompletionBeginExpanded = true
self?.beginAutocompletingEmoji()
}))
}
return UIMenu(children: actions)
}
// MARK: ComposeInput
var toolbarElements: [ToolbarElement] {
[.emojiPicker, .formattingButtons]
}
var textInputMode: UITextInputMode? {
textView?.textInputMode
}
func autocomplete(with string: String) {
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
}
func applyFormat(_ format: StatusFormat) {
guard let textView,
textView.isFirstResponder,
let insertionResult = format.insertionResult(for: controller.config.contentType) else {
return
}
let currentSelectedRange = textView.selectedRange
if currentSelectedRange.length == 0 {
textView.insertText(insertionResult.prefix + insertionResult.suffix)
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
} else {
let start = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound)
let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound)
let selectedText = textView.text.utf16[start..<end]
textView.insertText(insertionResult.prefix + String(Substring(selectedText)) + insertionResult.suffix)
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: currentSelectedRange.length)
}
}
func beginAutocompletingEmoji() {
guard let textView else {
return
}
var insertSpace = false
if let text = textView.text,
textView.selectedRange.upperBound > 0 {
let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound))
insertSpace = !text[characterBeforeCursorIndex].isWhitespace
}
textView.insertText((insertSpace ? " " : "") + ":")
}
private func updateAutocompleteState() {
guard let textView else {
autocompleteState = nil
return
}
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
}
}
}

View File

@ -0,0 +1,72 @@
//
// PollOptionView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
struct PollOptionView: View {
@EnvironmentObject private var controller: PollController
@EnvironmentObject private var poll: Poll
@ObservedObject private var option: PollOption
let remove: () -> Void
init(option: PollOption, remove: @escaping () -> Void) {
self.option = option
self.remove = remove
}
var body: some View {
HStack(spacing: 4) {
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor)
.animation(.default, value: poll.multiple)
textField
Button(action: remove) {
Image(systemName: "minus.circle.fill")
}
.accessibilityLabel("Remove option")
.buttonStyle(.plain)
.foregroundColor(poll.options.count == 1 ? .gray : .red)
.disabled(poll.options.count == 1)
.hoverEffect()
}
}
private var textField: some View {
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)
}
struct Checkbox: View {
private let radiusFraction: CGFloat
private let size: CGFloat = 20
private let innerSize: CGFloat
private let background: Color
init(radiusFraction: CGFloat, background: Color) {
self.radiusFraction = radiusFraction
self.innerSize = self.size - 4
self.background = background
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray)
.frame(width: size, height: size)
.cornerRadius(radiusFraction * size)
Rectangle()
.foregroundColor(background)
.frame(width: innerSize, height: innerSize)
.cornerRadius(radiusFraction * innerSize)
}
}
}
}

View File

@ -1,41 +1,41 @@
//
// ComposeReplyView.swift
// Tusker
// ReplyStatusView.swift
// ComposeUI
//
// Created by Shadowfacts on 8/22/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
import Pachyderm
import TuskerComponents
struct ComposeReplyView: View {
let status: StatusMO
struct ReplyStatusView: View {
let status: any StatusProtocol
let rowTopInset: CGFloat
let globalFrameOutsideList: CGRect
@EnvironmentObject private var controller: ComposeController
@State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat?
@ObservedObject private var preferences = Preferences.shared
private let horizSpacing: CGFloat = 8
var body: some View {
HStack(alignment: .top, spacing: horizSpacing) {
GeometryReader(content: self.replyAvatarImage)
.frame(width: 50)
VStack(alignment: .leading, spacing: 0) {
HStack {
AccountDisplayNameLabel(account: status.account, textStyle: .body, emojiSize: 17)
controller.displayNameLabel(status.account, .body, 17)
.lineLimit(1)
.layoutPriority(1)
Text(verbatim: "@\(status.account.acct)")
.font(.system(size: 17, weight: .light))
.font(.body.weight(.light))
.foregroundColor(.secondary)
.lineLimit(1)
Spacer()
}
.background(GeometryReader { proxy in
@ -45,8 +45,8 @@ struct ComposeReplyView: View {
displayNameHeight = newValue
}
})
ComposeReplyContentView(status: status) { newHeight in
controller.replyContentView(status) { newHeight in
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
// and it ends up partially behind the header
DispatchQueue.main.async {
@ -76,13 +76,16 @@ struct ComposeReplyView: View {
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
offset = min(offset, maxOffset)
return ComposeAvatarImageView(url: status.account.avatar)
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.offset(x: 0, y: offset)
.accessibilityHidden(true)
return AvatarImageView(
url: status.account.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.offset(x: 0, y: offset)
.accessibilityHidden(true)
}
}
private struct DisplayNameHeightPrefKey: PreferenceKey {
@ -91,9 +94,3 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
value = nextValue()
}
}
//struct ComposeReplyView_Previews: PreviewProvider {
// static var previews: some View {
// ComposeReplyView()
// }
//}

View File

@ -0,0 +1,29 @@
//
// WrappedProgressView.swift
// Tusker
//
// Created by Shadowfacts on 8/30/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct WrappedProgressView: UIViewRepresentable {
typealias UIViewType = UIProgressView
let value: Int
let total: Int
func makeUIView(context: Context) -> UIProgressView {
return UIProgressView(progressViewStyle: .bar)
}
func updateUIView(_ uiView: UIProgressView, context: Context) {
if total > 0 {
let progress = Float(value) / Float(total)
uiView.setProgress(progress, animated: true)
} else {
uiView.setProgress(0, animated: true)
}
}
}

View File

@ -0,0 +1,111 @@
//
// ZoomableScrollView.swift
// ComposeUI
//
// Created by Shadowfacts on 4/29/23.
//
import SwiftUI
@available(iOS 16.0, *)
struct ZoomableScrollView<Content: View>: UIViewControllerRepresentable {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIViewController(context: Context) -> Controller {
return Controller(content: content)
}
func updateUIViewController(_ uiViewController: Controller, context: Context) {
uiViewController.host.rootView = content
}
class Controller: UIViewController, UIScrollViewDelegate {
let scrollView = UIScrollView()
let host: UIHostingController<Content>
private var lastIntrinsicSize: CGSize?
private var contentViewTopConstraint: NSLayoutConstraint!
private var contentViewLeadingConstraint: NSLayoutConstraint!
private var hostBoundsObservation: NSKeyValueObservation?
init(content: Content) {
self.host = UIHostingController(rootView: content)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
scrollView.bouncesZoom = true
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
host.sizingOptions = .intrinsicContentSize
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
addChild(host)
scrollView.addSubview(host.view)
host.didMove(toParent: self)
contentViewLeadingConstraint = host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor)
contentViewTopConstraint = host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor)
NSLayoutConstraint.activate([
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentViewLeadingConstraint,
contentViewTopConstraint,
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if !host.view.intrinsicContentSize.equalTo(.zero),
host.view.intrinsicContentSize != lastIntrinsicSize {
self.lastIntrinsicSize = host.view.intrinsicContentSize
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
let maxWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right
let heightScale = maxHeight / host.view.intrinsicContentSize.height
let widthScale = maxWidth / host.view.intrinsicContentSize.width
let minScale = min(widthScale, heightScale)
let maxScale = minScale >= 1 ? minScale + 2 : 2
scrollView.minimumZoomScale = minScale
scrollView.maximumZoomScale = maxScale
scrollView.zoomScale = minScale
}
centerImage()
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return host.view
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
centerImage()
}
func centerImage() {
let yOffset = max(0, (view.bounds.size.height - host.view.bounds.height * scrollView.zoomScale) / 2)
contentViewTopConstraint.constant = yOffset
let xOffset = max(0, (view.bounds.size.width - host.view.bounds.width * scrollView.zoomScale) / 2)
contentViewLeadingConstraint.constant = xOffset
}
}
}

View File

@ -1,13 +1,14 @@
//
// CharacterCounterTests.swift
// PachydermTests
// ComposeUITests
//
// Created by Shadowfacts on 9/29/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import XCTest
@testable import Pachyderm
@testable import ComposeUI
import InstanceFeatures
class CharacterCounterTests: XCTestCase {
@ -16,32 +17,34 @@ class CharacterCounterTests: XCTestCase {
override func tearDown() {
}
let features = InstanceFeatures()
func testCountEmpty() {
XCTAssertEqual(CharacterCounter.count(text: ""), 0)
XCTAssertEqual(CharacterCounter.count(text: "", for: features), 0)
}
func testCountPlainText() {
XCTAssertEqual(CharacterCounter.count(text: "This is an example message"), 26)
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄"), 43)
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄"), 7)
XCTAssertEqual(CharacterCounter.count(text: "This is an example message", for: features), 26)
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄", for: features), 43)
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄", for: features), 7)
}
func testCountLinks() {
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com"), 55)
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com"), 57)
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com"), 32)
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz"), 55)
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com", for: features), 55)
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com", for: features), 57)
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com", for: features), 32)
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz", for: features), 55)
}
func testCountLocalMentions() {
XCTAssertEqual(CharacterCounter.count(text: "hello @example"), 14)
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name"), 22)
XCTAssertEqual(CharacterCounter.count(text: "hello @example", for: features), 14)
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name", for: features), 22)
}
func testCountRemoteMentions() {
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social"), 14)
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social"), 28)
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social", for: features), 14)
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social", for: features), 28)
}
}

View File

@ -0,0 +1,25 @@
//
// FuzzyMatcherTests.swift
// ComposeUITests
//
// Created by Shadowfacts on 10/11/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import XCTest
@testable import ComposeUI
class FuzzyMatcherTests: XCTestCase {
func testExample() throws {
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6)
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4)
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6)
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2)
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1)
XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score)
}
}

View File

@ -24,8 +24,8 @@ let package = Package(
.target(
name: "Duckable",
dependencies: []),
.testTarget(
name: "DuckableTests",
dependencies: ["Duckable"]),
// .testTarget(
// name: "DuckableTests",
// dependencies: ["Duckable"]),
]
)

View File

@ -7,8 +7,9 @@
import UIKit
@MainActor
public protocol DuckableViewController: UIViewController {
var duckableDelegate: DuckableViewControllerDelegate? { get set }
func duckableViewControllerShouldDuck() -> DuckAttemptAction
func duckableViewControllerMayAttemptToDuck()
@ -18,13 +19,16 @@ public protocol DuckableViewController: UIViewController {
}
extension DuckableViewController {
public func duckableViewControllerShouldDuck() -> DuckAttemptAction { .duck }
public func duckableViewControllerMayAttemptToDuck() {}
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
public func duckableViewControllerDidFinishAnimatingDuck() {}
}
public protocol DuckableViewControllerDelegate: AnyObject {
func duckableViewControllerWillDismiss(animated: Bool)
public enum DuckAttemptAction {
case duck
case dismiss
case block
}
extension UIViewController {

View File

@ -63,6 +63,9 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
presented.view.layer.opacity = 0
}
fadeAnimator.addCompletion { _ in
presented.view.layer.opacity = 1
}
fadeAnimator.startAnimation(afterDelay: 0.3)
} else {
@ -80,6 +83,7 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
presented.view.layer.opacity = 0
}
fadeAnimator.addCompletion { _ in
presented.view.layer.opacity = 1
duckable.duckableViewControllerDidFinishAnimatingDuck()
transitionContext.completeTransition(true)
}

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 {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
#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,17 +89,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
}
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.duckableDelegate = self
let nav = UINavigationController(rootViewController: viewController)
nav.modalPresentationStyle = .custom
nav.transitioningDelegate = self
present(nav, animated: animated) {
viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = self
present(viewController, animated: animated) {
self.configureChildForDuckedPlaceholder()
completion?()
}
}
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)
@ -136,10 +139,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
return
}
let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder()
dismiss(animated: true)
switch viewController.duckableViewControllerShouldDuck() {
case .duck:
let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder()
dismiss(animated: true)
case .block:
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
case .dismiss:
// duckableViewControllerWillDismiss()
dismiss(animated: true)
}
}
private func configureChildForDuckedPlaceholder() {
@ -148,6 +159,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
bottomConstraint.isActive = true
child.view.layer.cornerRadius = duckedCornerRadius
child.view.layer.cornerCurve = .continuous
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
child.view.layer.masksToBounds = true
}
@ -181,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
@ -207,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?) {
@ -236,4 +256,3 @@ extension DuckableContainerViewController: UISheetPresentationControllerDelegate
}
}
}

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,133 @@
//
// 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
}
to.view.frame = container.bounds
from.view.frame = container.bounds
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true
container.addSubview(to.view)
container.addSubview(from.view)
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
}
}

9
Packages/InstanceFeatures/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

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" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6"
}
}
],
"version" : 2
}

View File

@ -0,0 +1,31 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "InstanceFeatures",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "InstanceFeatures",
targets: ["InstanceFeatures"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(path: "../Pachyderm"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "InstanceFeatures",
dependencies: ["Pachyderm"]),
.testTarget(
name: "InstanceFeaturesTests",
dependencies: ["InstanceFeatures"]),
]
)

View File

@ -0,0 +1,3 @@
# InstanceFeatures
A description of this package.

View File

@ -0,0 +1,377 @@
//
// InstanceFeatures.swift
// Tusker
//
// Created by Shadowfacts on 1/23/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Combine
import Pachyderm
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 }
@Published @_spi(InstanceType) public private(set) var instanceType: InstanceType = .mastodon(.vanilla, nil)
@Published public private(set) var maxStatusChars = 500
@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
}
public var pollsAndAttachments: Bool {
instanceType.isPleroma
}
public var boostToOriginalAudience: Bool {
instanceType.isPleroma || instanceType.isMastodon
}
public var profilePinnedStatuses: Bool {
switch instanceType {
case .pixelfed:
return false
default:
return true
}
}
public var trends: Bool {
instanceType.isMastodon
}
public var profileSuggestions: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 4, 0)
}
public var trendingStatusesAndLinks: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
}
public var reblogVisibility: Bool {
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
}
public var probablySupportsMarkdown: Bool {
switch instanceType {
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .firefish(_):
return true
default:
return false
}
}
public var needsLocalOnlyEmojiHack: Bool {
if case .mastodon(.glitch, _) = instanceType {
return true
} else {
return false
}
}
public var needsWideColorGamutHack: Bool {
if case .mastodon(_, let version) = instanceType {
return version < Version(4, 0, 0)
} else {
return true
}
}
public var canFollowHashtags: Bool {
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 {
hasMastodonVersion(4, 0, 0)
}
public var notificationsAllowedTypes: Bool {
hasMastodonVersion(3, 5, 0)
}
public var pollVotersCount: Bool {
instanceType.isMastodon
}
public var createStatusWithLanguage: Bool {
instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil))
}
public var editStatuses: Bool {
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))
}
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: 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") {
instanceType = .mastodon(.glitch, Version(string: ver))
} else if nodeInfo?.software.name == "mastodon" {
instanceType = .mastodon(.vanilla, Version(string: ver))
} else if nodeInfo?.software.name == "hometown" {
var mastoVersion: Version?
var hometownVersion: Version?
let parts = ver.split(separator: "+")
if parts.count == 2,
let first = Version(string: String(parts[0])) {
if first > Version(1, 0, 8) {
// like 3.5.5+hometown-1.0.9
mastoVersion = first
if parts[1].starts(with: "hometown-") {
hometownVersion = Version(string: String(parts[1][parts[1].index(parts[1].startIndex, offsetBy: "hometown-".count + 1)...]))
}
} else {
// like "1.0.6+3.5.2"
hometownVersion = first
mastoVersion = Version(string: String(parts[1]))
}
} else {
mastoVersion = Version(string: ver)
}
instanceType = .mastodon(.hometown(hometownVersion), mastoVersion)
} else if let match = InstanceFeatures.pleromaVersionRegex.firstMatch(in: ver, range: NSRange(location: 0, length: ver.utf16.count)) {
var pleromaVersion: Version?
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))
}
} else if ver.contains("pixelfed") {
instanceType = .pixelfed
} else if nodeInfo?.software.name == "gotosocial" {
instanceType = .gotosocial
} else if ver.contains("firefish") || ver.contains("iceshrimp") || ver.contains("calckey") {
instanceType = .firefish(nodeInfo?.software.version)
} else {
instanceType = .mastodon(.vanilla, Version(string: ver))
}
maxStatusChars = instance.maxStatusCharacters ?? 500
charsReservedPerURL = instance.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
if let pollsConfig = instance.pollsConfiguration {
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(_, let version) = instanceType {
return version >= Version(major, minor, patch)
} else {
return false
}
}
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
switch instanceType {
case .pleroma(.vanilla(let version)), .pleroma(.akkoma(let version)):
return version >= Version(major, minor, patch)
default:
return false
}
}
}
extension InstanceFeatures {
@_spi(InstanceType) public enum InstanceType {
case mastodon(MastodonType, Version?)
case pleroma(PleromaType)
case pixelfed
case gotosocial
case firefish(String?)
var isMastodon: Bool {
if case .mastodon(_, _) = self {
return true
} else {
return false
}
}
func isMastodon(_ subtype: MastodonType) -> Bool {
if case .mastodon(let t, _) = self,
t.equalsIgnoreVersion(subtype) {
return true
} else {
return false
}
}
var isPleroma: Bool {
if case .pleroma(_) = self {
return true
} else {
return false
}
}
func isPleroma(_ subtype: PleromaType) -> Bool {
if case .pleroma(let t) = self,
t.equalsIgnoreVersion(subtype) {
return true
} else {
return false
}
}
}
@_spi(InstanceType) public enum MastodonType {
case vanilla
case hometown(Version?)
case glitch
func equalsIgnoreVersion(_ other: MastodonType) -> Bool {
switch (self, other) {
case (.vanilla, .vanilla):
return true
case (.hometown(_), .hometown(_)):
return true
case (.glitch, .glitch):
return true
default:
return false
}
}
}
@_spi(InstanceType) public enum PleromaType {
case vanilla(Version?)
case akkoma(Version?)
func equalsIgnoreVersion(_ other: PleromaType) -> Bool {
switch (self, other) {
case (.vanilla(_), .vanilla(_)):
return true
case (.akkoma(_), .akkoma(_)):
return true
default:
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

@ -1,13 +1,13 @@
//
// VersionTests.swift
// TuskerTests
// InstanceFeaturesTests
//
// Created by Shadowfacts on 4/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import XCTest
@testable import Tusker
@testable import InstanceFeatures
class VersionTests: XCTestCase {

View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

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

View File

@ -0,0 +1,125 @@
//
// MatchedGeometryModifiers.swift
// MatchGeom
//
// Created by Shadowfacts on 4/24/23.
//
import SwiftUI
extension View {
public func matchedGeometryPresentation<ID: Hashable, Presented: View>(id: Binding<ID?>, backgroundColor: UIColor, @ViewBuilder presenting: () -> Presented) -> some View {
self.modifier(MatchedGeometryPresentationModifier(id: id, backgroundColor: backgroundColor, presented: presenting()))
}
public func matchedGeometrySource<ID: Hashable, ID2: Hashable>(id: ID, presentationID: ID2) -> some View {
self.modifier(MatchedGeometrySourceModifier(id: AnyHashable(id), presentationID: AnyHashable(presentationID), matched: { AnyView(self) }))
}
public func matchedGeometryDestination<ID: Hashable>(id: ID) -> some View {
self.modifier(MatchedGeometryDestinationModifier(id: AnyHashable(id), matched: self))
}
}
private struct MatchedGeometryPresentationModifier<ID: Hashable, Presented: View>: ViewModifier {
@Binding var id: ID?
let backgroundColor: UIColor
let presented: Presented
@StateObject private var state = MatchedGeometryState()
private var isPresented: Binding<Bool> {
Binding {
id != nil
} set: {
if $0 {
fatalError()
} else {
id = nil
}
}
}
func body(content: Content) -> some View {
content
.environmentObject(state)
.backgroundPreferenceValue(MatchedGeometrySourcesKey.self, { sources in
Color.clear
.presentViewController(makeVC(allSources: sources), isPresented: isPresented)
})
}
private func makeVC(allSources: [SourceKey: (AnyView, CGRect)]) -> () -> UIViewController {
return {
// force unwrap is safe, this closure is only called when being presented so we must have an id
let id = AnyHashable(id!)
return MatchedGeometryViewController(
presentationID: id,
content: presented,
state: state,
backgroundColor: backgroundColor
)
}
}
}
private struct MatchedGeometrySourceModifier: ViewModifier {
let id: AnyHashable
let presentationID: AnyHashable
let matched: () -> AnyView
@EnvironmentObject private var state: MatchedGeometryState
func body(content: Content) -> some View {
content
.background(GeometryReader { proxy in
Color.clear
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
if let newValue {
state.sources[SourceKey(presentationID: presentationID, matchedID: id)] = (matched, newValue)
}
}
})
.opacity(state.animating && state.presentationID == presentationID ? 0 : 1)
}
}
private struct MatchedGeometryDestinationModifier<Matched: View>: ViewModifier {
let id: AnyHashable
let matched: Matched
@EnvironmentObject private var state: MatchedGeometryState
func body(content: Content) -> some View {
content
.background(GeometryReader { proxy in
Color.clear
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
if let newValue,
// ignore intermediate layouts that may happen while the dismiss animation is happening
state.mode != .dismissing {
state.destinations[id] = (AnyView(matched), newValue)
}
}
})
.opacity(state.animating ? 0 : 1)
}
}
private struct MatchedGeometryDestinationFrameKey: PreferenceKey {
static let defaultValue: CGRect? = nil
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
value = nextValue()
}
}
private struct MatchedGeometrySourcesKey: PreferenceKey {
static let defaultValue: [SourceKey: (AnyView, CGRect)] = [:]
static func reduce(value: inout Value, nextValue: () -> Value) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
struct SourceKey: Hashable {
let presentationID: AnyHashable
let matchedID: AnyHashable
}

View File

@ -0,0 +1,239 @@
//
// MatchedGeometryViewController.swift
// MatchGeom
//
// Created by Shadowfacts on 4/24/23.
//
import SwiftUI
import Combine
private let mass: CGFloat = 1
private let presentStiffness: CGFloat = 300
private let presentDamping: CGFloat = 20
private let dismissStiffness: CGFloat = 200
private let dismissDamping: CGFloat = 20
public class MatchedGeometryState: ObservableObject {
@Published var presentationID: AnyHashable?
@Published var animating: Bool = false
@Published public var mode: Mode = .presenting
@Published var sources: [SourceKey: (() -> AnyView, CGRect)] = [:]
@Published var currentFrames: [AnyHashable: CGRect] = [:]
@Published var destinations: [AnyHashable: (AnyView, CGRect)] = [:]
public enum Mode: Equatable {
case presenting
case idle
case dismissing
}
}
class MatchedGeometryViewController<Content: View>: UIViewController, UIViewControllerTransitioningDelegate {
let presentationID: AnyHashable
let content: Content
let state: MatchedGeometryState
let backgroundColor: UIColor
var contentHost: UIHostingController<ContentContainerView>!
var matchedHost: UIHostingController<MatchedContainerView>!
init(presentationID: AnyHashable, content: Content, state: MatchedGeometryState, backgroundColor: UIColor) {
self.presentationID = presentationID
self.content = content
self.state = state
self.backgroundColor = backgroundColor
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .custom
transitioningDelegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
contentHost = UIHostingController(rootView: ContentContainerView(content: content, state: state))
contentHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentHost.view.frame = view.bounds
contentHost.view.backgroundColor = backgroundColor
addChild(contentHost)
view.addSubview(contentHost.view)
contentHost.didMove(toParent: self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
state.presentationID = presentationID
}
var currentPresentationSources: [AnyHashable: (() -> AnyView, CGRect)] {
Dictionary(uniqueKeysWithValues: state.sources.filter { $0.key.presentationID == presentationID }.map { ($0.key.matchedID, $0.value) })
}
func addMatchedHostingController() {
let sources = currentPresentationSources.map { (id: $0.key, view: $0.value.0) }
matchedHost = UIHostingController(rootView: MatchedContainerView(sources: sources, state: state))
matchedHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
matchedHost.view.frame = view.bounds
matchedHost.view.backgroundColor = .clear
matchedHost.view.layer.zPosition = 100
addChild(matchedHost)
view.addSubview(matchedHost.view)
matchedHost.didMove(toParent: self)
}
struct ContentContainerView: View {
let content: Content
let state: MatchedGeometryState
var body: some View {
content
.environmentObject(state)
}
}
struct MatchedContainerView: View {
let sources: [(id: AnyHashable, view: () -> AnyView)]
@ObservedObject var state: MatchedGeometryState
var body: some View {
ZStack {
ForEach(sources, id: \.id) { (id, view) in
matchedView(id: id, source: view)
}
}
}
@ViewBuilder
func matchedView(id: AnyHashable, source: () -> AnyView) -> some View {
if let frame = state.currentFrames[id],
let dest = state.destinations[id]?.0 {
ZStack {
source()
dest
.opacity(state.mode == .presenting ? (state.animating ? 1 : 0) : (state.animating ? 0 : 1))
}
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
.ignoresSafeArea()
.animation(.interpolatingSpring(mass: Double(mass), stiffness: Double(state.mode == .presenting ? presentStiffness : dismissStiffness), damping: Double(state.mode == .presenting ? presentDamping : dismissDamping), initialVelocity: 0), value: frame)
}
}
}
// MARK: UIViewControllerTransitioningDelegate
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MatchedGeometryPresentationAnimationController<Content>()
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return MatchedGeometryDismissAnimationController<Content>()
}
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return MatchedGeometryPresentationController(presentedViewController: presented, presenting: presenting)
}
}
class MatchedGeometryPresentationAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.8
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let matchedGeomVC = transitionContext.viewController(forKey: .to) as! MatchedGeometryViewController<Content>
let container = transitionContext.containerView
// add the VC to the container, which kicks off layout out the content hosting controller
matchedGeomVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
matchedGeomVC.view.frame = container.bounds
container.addSubview(matchedGeomVC.view)
// layout out the content hosting controller and having enough destinations may take a while
// so listen for when it's ready, rather than trying to guess at the timing
let cancellable = matchedGeomVC.state.$destinations
.filter { destinations in matchedGeomVC.currentPresentationSources.allSatisfy { source in destinations.keys.contains(source.key) } }
.first()
.sink { destinations in
matchedGeomVC.addMatchedHostingController()
// setup the initial state for the animation
matchedGeomVC.matchedHost.view.isHidden = true
matchedGeomVC.state.mode = .presenting
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
// wait one runloop iteration for the matched hosting controller to be setup
DispatchQueue.main.async {
matchedGeomVC.matchedHost.view.isHidden = false
matchedGeomVC.state.animating = true
// get the now-current destinations, in case they've changed since the sunk value was published
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
}
}
matchedGeomVC.contentHost.view.layer.opacity = 0
let spring = UISpringTimingParameters(mass: mass, stiffness: presentStiffness, damping: presentDamping, initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: self.transitionDuration(using: transitionContext), timingParameters: spring)
animator.addAnimations {
matchedGeomVC.contentHost.view.layer.opacity = 1
}
animator.addCompletion { _ in
transitionContext.completeTransition(true)
matchedGeomVC.state.animating = false
matchedGeomVC.state.mode = .idle
matchedGeomVC.matchedHost?.view.removeFromSuperview()
matchedGeomVC.matchedHost?.removeFromParent()
cancellable.cancel()
}
animator.startAnimation()
}
}
class MatchedGeometryDismissAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.8
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let matchedGeomVC = transitionContext.viewController(forKey: .from) as! MatchedGeometryViewController<Content>
// recreate the matched host b/c using the current destinations doesn't seem to update the existing one
matchedGeomVC.addMatchedHostingController()
matchedGeomVC.matchedHost.view.isHidden = true
matchedGeomVC.state.mode = .dismissing
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
DispatchQueue.main.async {
matchedGeomVC.matchedHost.view.isHidden = false
matchedGeomVC.state.animating = true
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
}
let spring = UISpringTimingParameters(mass: mass, stiffness: dismissStiffness, damping: dismissDamping, initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: spring)
animator.addAnimations {
matchedGeomVC.contentHost.view.layer.opacity = 0
}
animator.addCompletion { _ in
transitionContext.completeTransition(true)
matchedGeomVC.state.animating = false
matchedGeomVC.state.mode = .idle
}
animator.startAnimation()
}
}
class MatchedGeometryPresentationController: UIPresentationController {
override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
delegate?.presentationControllerWillDismiss?(self)
}
}

View File

@ -0,0 +1,62 @@
//
// View+PresentViewController.swift
// MatchGeom
//
// Created by Shadowfacts on 4/24/23.
//
import SwiftUI
extension View {
func presentViewController(_ makeVC: @escaping () -> UIViewController, isPresented: Binding<Bool>) -> some View {
self
.background(
ViewControllerPresenter(makeVC: makeVC, isPresented: isPresented)
)
}
}
private struct ViewControllerPresenter: UIViewControllerRepresentable {
let makeVC: () -> UIViewController
@Binding var isPresented: Bool
func makeUIViewController(context: Context) -> UIViewController {
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if isPresented {
if uiViewController.presentedViewController == nil {
let presented = makeVC()
presented.presentationController!.delegate = context.coordinator
uiViewController.present(presented, animated: true)
context.coordinator.didPresent = true
}
} else {
if context.coordinator.didPresent,
let presentedViewController = uiViewController.presentedViewController,
!presentedViewController.isBeingDismissed {
uiViewController.dismiss(animated: true)
context.coordinator.didPresent = false
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(isPresented: $isPresented)
}
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
@Binding var isPresented: Bool
var didPresent = false
init(isPresented: Binding<Bool>) {
self._isPresented = isPresented
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
isPresented = false
didPresent = false
}
}
}

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
LastUpgradeVersion = "1500"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@ -17,7 +17,7 @@
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
BuildableName = "Pachyderm.framework"
BlueprintName = "Pachyderm"
ReferencedContainer = "container:Tusker.xcodeproj">
ReferencedContainer = "container:../../Tusker.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@ -35,7 +35,7 @@
BlueprintIdentifier = "PachydermTests"
BuildableName = "PachydermTests"
BlueprintName = "PachydermTests"
ReferencedContainer = "container:Pachyderm">
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
@ -56,7 +56,7 @@
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
BuildableName = "Pachyderm.framework"
BlueprintName = "Pachyderm"
ReferencedContainer = "container:Tusker.xcodeproj">
ReferencedContainer = "container:../../Tusker.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
@ -72,7 +72,7 @@
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
BuildableName = "Pachyderm.framework"
BlueprintName = "Pachyderm"
ReferencedContainer = "container:Tusker.xcodeproj">
ReferencedContainer = "container:../../Tusker.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>

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

@ -7,11 +7,12 @@
//
import Foundation
import WebURL
/**
The base Mastodon API client.
*/
public class Client {
public struct Client: Sendable {
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
@ -19,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?
@ -60,9 +59,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
}
@ -83,7 +84,7 @@ public class Client {
completion(.failure(Error(request: request, type: .invalidResponse)))
return
}
guard response.statusCode == 200 else {
guard response.statusCode == 200 || request.additionalAcceptableHTTPCodes.contains(response.statusCode) else {
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(Error(request: request, type: type)))
@ -104,6 +105,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
@ -112,11 +127,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
}
@ -129,47 +150,52 @@ 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, completion: @escaping Callback<LoginSettings>) {
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
"client_id" => clientID,
"client_secret" => clientSecret,
"grant_type" => "authorization_code",
"code" => authorizationCode,
"redirect_uri" => redirectURI
"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 nodeInfo(completion: @escaping Callback<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 components = URLComponents(string: url.href),
components.host == self.baseURL.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path))
self.run(nodeInfo, completion: completion)
public func revokeAccessToken() async throws {
guard let accessToken else {
return
}
let request = Request<Empty>(method: .post, path: "/oauth/revoke", body: ParametersBody([
"token" => accessToken,
"client_id" => clientID!,
"client_secret" => clientSecret!,
]))
return try await withCheckedThrowingContinuation({ continuation in
self.run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(_, _):
continuation.resume()
}
}
})
}
public func nodeInfo() async throws -> NodeInfo {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
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
}
}
@ -178,22 +204,32 @@ public class Client {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
}
public static func getFavourites() -> Request<[Status]> {
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
request.range = range
return request
}
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
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)")
@ -290,6 +326,13 @@ public class Client {
], attachment))
}
public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([
"description" => description,
"focus" => focus
], nil))
}
// MARK: - Mutes
public static func getMutes(range: RequestRange) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
@ -298,6 +341,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 }
@ -357,27 +404,55 @@ public class Client {
public static func createStatus(text: String,
contentType: StatusContentType = .plain,
inReplyTo: String? = nil,
media: [Attachment]? = nil,
mediaIDs: [String]? = nil,
sensitive: Bool? = nil,
spoilerText: String? = nil,
visibility: Status.Visibility? = nil,
language: String? = 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" => media?.map { $0.id } + "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(
id: String,
text: String,
contentType: StatusContentType = .plain,
spoilerText: String?,
sensitive: Bool,
language: String?,
mediaIDs: [String],
mediaAttributes: [EditStatusMediaAttributes],
poll: EditPollParameters?
) -> Request<Status> {
let params = EditStatusParameters(
id: id,
text: text,
contentType: contentType,
spoilerText: spoilerText,
sensitive: sensitive,
language: language,
mediaIDs: mediaIDs,
mediaAttributes: mediaAttributes,
poll: poll
)
return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params))
}
// MARK: - Timelines
@ -394,32 +469,46 @@ public class Client {
}
// MARK: - Instance
public static func getTrendingHashtags(limit: Int? = nil) -> Request<[Hashtag]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
}
public static func getTrendingStatuses(limit: Int? = nil) -> Request<[Status]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
public static func getTrendingHashtags(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
}
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
return Request(method: .get, path: "/api/v1/trends/statuses", queryParameters: parameters)
}
public static func getTrendingLinks(limit: Int? = nil) -> Request<[Card]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
public static func getTrendingLinks(limit: Int? = nil, offset: Int? = nil) -> Request<[Card]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
}
@ -444,10 +533,16 @@ public class Client {
])
}
// MARK: - Hashtags
/// Requires Mastodon 4.0.0+
public static func getHashtag(name: String) -> Request<Hashtag> {
return Request(method: .get, path: "/api/v1/tags/\(name)")
}
}
extension Client {
public struct Error: LocalizedError {
public struct Error: LocalizedError, Sendable {
public let requestMethod: Method
public let requestEndpoint: Endpoint
public let type: ErrorType
@ -462,13 +557,15 @@ extension Client {
self.type = type
}
public var localizedDescription: String {
public var errorDescription: String? {
switch type {
case .networkError(let error):
return "Network Error: \(error.localizedDescription)"
// todo: support more status codes
case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large"
case .unexpectedStatus(429):
return "HTTP 429: Rate Limit Exceeded"
case .unexpectedStatus(let code):
return "HTTP Code \(code)"
case .invalidRequest:
@ -482,7 +579,7 @@ extension Client {
}
}
}
public enum ErrorType: LocalizedError {
public enum ErrorType: LocalizedError, Sendable {
case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest
@ -490,4 +587,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

@ -8,7 +8,7 @@
import Foundation
public final class Account: AccountProtocol, Decodable {
public final class Account: AccountProtocol, Decodable, Sendable {
public let id: String
public let username: String
public let acct: String
@ -25,7 +25,7 @@ public final class Account: AccountProtocol, Decodable {
public let avatarStatic: URL?
public let header: URL?
public let headerStatic: URL?
public private(set) var emojis: [Emoji]
public let emojis: [Emoji]
public let moved: Bool?
public let movedTo: Account?
public let fields: [Field]
@ -70,12 +70,12 @@ public final class Account: AccountProtocol, Decodable {
}
}
public static func authorizeFollowRequest(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
public static func authorizeFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize")
}
public static func rejectFollowRequest(_ account: Account) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject")
public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject")
}
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {
@ -109,6 +109,12 @@ public final class Account: AccountProtocol, Decodable {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/follow")
}
public static func setShowReblogs(_ accountID: String, showReblogs: Bool) -> Request<Relationship> {
return Request(method: .post, path: "/api/v1/accounts/\(accountID)/follow", body: ParametersBody([
"reblogs" => showReblogs
]))
}
public static func unfollow(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(accountID)/unfollow")
}
@ -165,7 +171,7 @@ extension Account: CustomDebugStringConvertible {
}
extension Account {
public struct Field: Codable {
public struct Field: Codable, Equatable, Sendable {
public let name: String
public let value: String
public let verifiedAt: Date?

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

@ -8,11 +8,11 @@
import Foundation
public class Application: Decodable {
public struct Application: Decodable, Sendable {
public let name: String
public let website: URL?
public required init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)

View File

@ -8,7 +8,7 @@
import Foundation
public class Attachment: Codable {
public struct Attachment: Codable, Sendable {
public let id: String
public let kind: Kind
public let url: URL
@ -25,7 +25,18 @@ public class Attachment: Codable {
], nil))
}
required public init(from decoder: Decoder) throws {
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)
self.kind = try container.decode(Kind.self, forKey: .kind)
@ -50,7 +61,7 @@ public class Attachment: Codable {
}
extension Attachment {
public enum Kind: String, Codable {
public enum Kind: String, Codable, Sendable {
case image
case video
case gifv
@ -77,7 +88,7 @@ extension Attachment {
}
extension Attachment {
public struct Metadata: Codable {
public struct Metadata: Codable, Sendable {
public let length: String?
public let duration: Float?
public let audioEncoding: String?
@ -108,7 +119,7 @@ extension Attachment {
}
}
public struct ImageMetadata: Codable {
public struct ImageMetadata: Codable, Sendable {
public let width: Int?
public let height: Int?
public let size: String?

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public class Card: Codable {
public struct Card: Codable, Sendable {
public let url: WebURL
public let title: String
public let description: String
@ -26,7 +26,39 @@ public class Card: Codable {
/// Only present when returned from the trending links endpoint
public let history: [History]?
public required init(from decoder: Decoder) throws {
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)
self.url = try container.decode(WebURL.self, forKey: .url)
@ -75,7 +107,7 @@ public class Card: Codable {
}
extension Card {
public enum Kind: String, Codable {
public enum Kind: String, Codable, Sendable {
case link
case photo
case video

View File

@ -8,7 +8,7 @@
import Foundation
public class ConversationContext: Decodable {
public struct ConversationContext: Decodable, Sendable {
public let ancestors: [Status]
public let descendants: [Status]

View File

@ -8,7 +8,7 @@
import Foundation
public enum DirectoryOrder: String, CaseIterable {
public enum DirectoryOrder: String, CaseIterable, Sendable {
case active
case new
}

View File

@ -0,0 +1,97 @@
//
// EditStatusParameters.swift
// Pachyderm
//
// Created by Shadowfacts on 5/10/23.
//
import Foundation
struct EditStatusParameters: Encodable, Sendable {
let id: String
let text: String
let contentType: StatusContentType
let spoilerText: String?
let sensitive: Bool
let language: String?
let mediaIDs: [String]
let mediaAttributes: [EditStatusMediaAttributes]
let poll: EditPollParameters?
func encode(to encoder: Encoder) throws {
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.mimeType, forKey: .contentType)
try container.encodeIfPresent(self.spoilerText, forKey: .spoilerText)
try container.encode(self.sensitive, forKey: .sensitive)
try container.encodeIfPresent(self.language, forKey: .language)
try container.encode(self.mediaIDs, forKey: .mediaIDs)
try container.encode(self.mediaAttributes, forKey: .mediaAttributes)
try container.encodeIfPresent(self.poll, forKey: .poll)
}
enum CodingKeys: String, CodingKey {
case id
case text = "status"
case contentType = "content_type"
case spoilerText = "spoiler_text"
case sensitive
case language
case mediaIDs = "media_ids"
case mediaAttributes = "media_attributes"
case poll
}
}
public struct EditPollParameters: Encodable, Sendable {
let options: [String]
let expiresIn: Int
let multiple: Bool
public init(options: [String], expiresIn: Int, multiple: Bool) {
self.options = options
self.expiresIn = expiresIn
self.multiple = multiple
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.options, forKey: .options)
try container.encode(self.expiresIn, forKey: .expiresIn)
try container.encode(self.multiple, forKey: .multiple)
}
enum CodingKeys: String, CodingKey {
case options
case expiresIn = "expires_in"
case multiple
}
}
public struct EditStatusMediaAttributes: Encodable, Sendable {
let id: String
let description: String
let focus: (Float, Float)?
public init(id: String, description: String, focus: (Float, Float)?) {
self.id = id
self.description = description
self.focus = focus
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(description, forKey: .description)
if let focus {
try container.encode("\(focus.0),\(focus.1)", forKey: .focus)
}
}
enum CodingKeys: String, CodingKey {
case id
case description
case focus
}
}

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public class Emoji: Codable {
public struct Emoji: Codable, Sendable {
public let shortcode: String
// these shouldn't need to be WebURLs as they're not external resources,
// but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient
@ -18,7 +18,7 @@ public class Emoji: Codable {
public let visibleInPicker: Bool
public let category: String?
public required init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.shortcode = try container.decode(String.self, forKey: .shortcode)
@ -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

@ -8,7 +8,7 @@
import Foundation
public struct FilterV1: Decodable {
public struct FilterV1: Decodable, Sendable {
public let id: String
public let phrase: String
private let context: [String]
@ -45,7 +45,7 @@ public struct FilterV1: Decodable {
}
extension FilterV1 {
public enum Context: String, Decodable, CaseIterable {
public enum Context: String, Decodable, CaseIterable, Sendable {
case home
case notifications
case `public`

View File

@ -7,7 +7,7 @@
import Foundation
public struct FilterV2: Decodable {
public struct FilterV2: Decodable, Sendable {
public let id: String
public let title: String
public let context: [FilterV1.Context]
@ -80,14 +80,14 @@ public struct FilterV2: Decodable {
}
extension FilterV2 {
public enum Action: String, Decodable, Hashable, CaseIterable {
public enum Action: String, Decodable, Hashable, CaseIterable, Sendable {
case warn
case hide
}
}
extension FilterV2 {
public struct Keyword: Decodable {
public struct Keyword: Decodable, Sendable {
public let id: String
public let keyword: String
public let wholeWord: Bool

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