Compare commits

..

179 Commits

Author SHA1 Message Date
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
319 changed files with 13144 additions and 10498 deletions

View File

@ -1,3 +1,57 @@
## 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 ## 2023.4
Features/Improvements: Features/Improvements:
- Add preference for non-pure-black dark mode - Add preference for non-pure-black dark mode

View File

@ -1,5 +1,161 @@
# Changelog # Changelog
## 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) ## 2023.4 (75)
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below. This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.

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,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

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,186 @@
//
// 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.visibility,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
pollOptions: draft.poll?.pollOptions.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
)
}
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 AttachmentData.Error {
throw Error.attachmentData(index: index, cause: error)
}
do {
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
attachments.append(uploaded.id)
currentStep += 1
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
}
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(data: Data, utType: UTType, description: String?) async throws -> Attachment {
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
let req = Client.upload(attachment: formAttachment, description: description)
return try await mastodonController.run(req).0
}
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: AttachmentData.Error)
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 .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 // CharacterCounter.swift
// Pachyderm // ComposeUI
// //
// Created by Shadowfacts on 9/29/18. // Created by Shadowfacts on 9/29/18.
// Copyright © 2018 Shadowfacts. All rights reserved. // Copyright © 2018 Shadowfacts. All rights reserved.
// //
import Foundation import Foundation
import InstanceFeatures
public struct CharacterCounter { public struct CharacterCounter {
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) private 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 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) let mentionsRemoved = removeMentions(in: text)
var count = mentionsRemoved.count var count = mentionsRemoved.count
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) { for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
count -= match.range.length count -= match.range.length
count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length count += instanceFeatures.charsReservedPerURL
} }
return count 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,28 @@
//
// 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(completion: @escaping ([Emoji]) -> Void)
@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,217 @@
//
// 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 {
parent.draft.attachments.remove(attachment)
}
}
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
DispatchQueue.global(qos: .userInitiated).async {
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
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: 80, height: 80)
.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: 80)
.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)
.onChange(of: textEditorFocused) { newValue in
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
controller.focusAttachment()
}
}
}
@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)
@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,191 @@
//
// 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 let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
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 let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
self.image = UIImage(cgImage: cgImage)
}
} 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,248 @@
//
// 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 {
guard 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 {
Group {
attachmentsList
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))
}
}
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)
}
}
}

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,196 @@
//
// AutocompleteEmojisController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/26/23.
//
import SwiftUI
import Pachyderm
import Combine
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] = []
var emojisBySection: [String: [Emoji]] {
var values: [String: [Emoji]] = [:]
for emoji in emojis {
let key = emoji.category ?? ""
if !values.keys.contains(key) {
values[key] = [emoji]
} else {
values[key]!.append(emoji)
}
}
return values
}
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 {
await self.queryChanged(query)
}
}
}
@MainActor
private func queryChanged(_ query: String) async {
var emojis = await withCheckedContinuation { continuation in
composeController.mastodonController.getCustomEmojis {
continuation.resume(returning: $0)
}
}
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]()
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
newEmojis.append(emoji)
shortcodes.insert(emoji.shortcode)
}
self.emojis = newEmojis
}
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) {
HStack(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)
Spacer(minLength: emojiSize)
}
.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,124 @@
//
// AutocompleteHashtagsController.swift
// ComposeUI
//
// Created by Shadowfacts on 4/1/23.
//
import SwiftUI
import Combine
import Pachyderm
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 {
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,178 @@
//
// 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()
self.searchTask = Task {
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,499 @@
//
// 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
@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
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
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
@StateObject private var keyboardReader = KeyboardReader()
@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)
ControllerView(controller: { controller.toolbarController })
}
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
.padding(.bottom, keyboardInset)
.transition(.move(edge: .bottom))
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
ToolbarItem(placement: .confirmationAction) { postButton }
}
.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)
.scrollDismissesKeyboardInteractivelyIfAvailable()
.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))
}
.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")
}
}
}
@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
}
}
}
}
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,184 @@
//
// 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) {
poll.options.moveObjects(at: indices, to: newIndex)
}
private func removeOption(_ option: PollOption) {
poll.options.remove(option)
}
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)
)
.onChange(of: controller.duration) { newValue in
poll.duration = newValue.timeInterval
}
}
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,167 @@
//
// ToolbarController.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
import SwiftUI
import Pachyderm
import TuskerComponents
class ToolbarController: ViewController {
static let height: CGFloat = 44
private static let visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] = Pachyderm.Visibility.allCases.map { vis in
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
}
unowned let parent: ComposeController
@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
@State private var minWidth: CGFloat?
@State private var realWidth: CGFloat?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
cwButton
MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
.disabled(draft.editedStatusID != nil)
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker
.padding(.horizontal, -8)
.disabled(draft.editedStatusID != nil)
}
if let currentInput = composeController.currentInput,
currentInput.toolbarElements.contains(.emojiPicker) {
customEmojiButton
}
if let currentInput = composeController.currentInput,
currentInput.toolbarElements.contains(.formattingButtons),
composeController.config.contentType != .plain {
Spacer()
formatButtons
}
Spacer()
if #available(iOS 16.0, *),
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
}
}
.padding(.horizontal, 16)
.frame(minWidth: minWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
realWidth = width
}
})
}
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(height: ToolbarController.height)
.frame(maxWidth: .infinity)
.background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
.overlay(alignment: .top) {
Divider()
}
.background(GeometryReader { proxy in
Color.clear
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
minWidth = width
}
})
}
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 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)) {
if let imageName = format.imageName {
Image(systemName: imageName)
.font(.system(size: imageSize))
} else if let (str, attrs) = format.title {
let container = try! AttributeContainer(attrs, including: \.uiKit)
Text(AttributedString(str, attributes: container))
}
}
.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,72 @@
//
// 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 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) ||
attachments.count > 0 ||
poll?.hasContent == true
}
}

View File

@ -0,0 +1,317 @@
//
// 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 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, jpegType, pngType, mp4Type, quickTimeType]
}
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
attachment.id = UUID()
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!)
attachment.fileType = typeIdentifier
attachment.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, 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, 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
if type != .png && type != .jpeg,
let image = UIImage(data: data) {
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
} else {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
type = .jpeg
}
}
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
session.exportAsynchronously {
guard session.status == .completed else {
completion(.failure(.videoExport(session.error!)))
return
}
do {
let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, .mpeg4Movie)))
} catch {
completion(.failure(.videoExport(error)))
}
}
}
enum ExportError: Error {
case noAsset
case unknownAssetType
case missingAssetData
case videoExport(Error)
case noVideoExportSession
case loadingDrawing
case loadingData
case noData
}
}

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<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="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>
<entity name="TestEntity" representedClassName="TestEntity" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
</entity>
</model>

View File

@ -0,0 +1,210 @@
//
// 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,
localOnly: Bool
) -> Draft {
let draft = Draft(context: viewContext)
draft.accountID = accountID
draft.text = text
draft.initialText = text
draft.contentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID
draft.visibility = visibility
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.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,62 @@
//
// FuzzyMatcher.swift
// ComposeUI
//
// Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
struct FuzzyMatcher {
private init() {}
/// Rudimentary string fuzzy matching algorithm.
///
/// Operates on UTF-8 code points, so attempting to match strings which include characters composed of
/// multiple code points may produce unexpected results.
///
/// Scoring is as follows:
/// +2 points for every char in `pattern` that occurs in `str` sequentially
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
let pattern = pattern.lowercased()
let str = str.lowercased()
var patternIndex = pattern.utf8.startIndex
var lastStrMatchIndex: String.UTF8View.Index?
var strIndex = str.utf8.startIndex
var score = 0
while patternIndex < pattern.utf8.endIndex && strIndex < str.utf8.endIndex {
let patternChar = pattern.utf8[patternIndex]
let strChar = str.utf8[strIndex]
if patternChar == strChar {
let distance = str.utf8.distance(from: lastStrMatchIndex ?? str.utf8.startIndex, to: strIndex)
if distance > 1 {
score -= distance - 1
}
patternIndex = pattern.utf8.index(after: patternIndex)
lastStrMatchIndex = strIndex
strIndex = str.utf8.index(after: strIndex)
score += 2
} else {
strIndex = str.utf8.index(after: strIndex)
if strIndex >= str.utf8.endIndex {
patternIndex = pattern.utf8.index(after: patternIndex)
strIndex = str.utf8.index(after: lastStrMatchIndex ?? str.utf8.startIndex)
score -= 2
}
}
}
return (score > 0, score)
}
}

View File

@ -0,0 +1,39 @@
//
// KeyboardReader.swift
// ComposeUI
//
// Created by Shadowfacts on 3/7/23.
//
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
}
}
}

View File

@ -1,6 +1,6 @@
// //
// CompositionAttachmentData.swift // AttachmentData.swift
// Tusker // ComposeUI
// //
// Created by Shadowfacts on 1/1/20. // Created by Shadowfacts on 1/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved. // Copyright © 2020 Shadowfacts. All rights reserved.
@ -10,10 +10,11 @@ import UIKit
import Photos import Photos
import UniformTypeIdentifiers import UniformTypeIdentifiers
import PencilKit import PencilKit
import InstanceFeatures
enum CompositionAttachmentData { enum AttachmentData {
case asset(PHAsset) case asset(PHAsset)
case image(UIImage) case image(Data, originalType: UTType)
case video(URL) case video(URL)
case drawing(PKDrawing) case drawing(PKDrawing)
case gif(Data) case gif(Data)
@ -22,7 +23,7 @@ enum CompositionAttachmentData {
switch self { switch self {
case let .asset(asset): case let .asset(asset):
return asset.attachmentType! return asset.attachmentType!
case .image(_): case .image(_, originalType: _):
return .image return .image
case .video(_): case .video(_):
return .video return .video
@ -53,11 +54,21 @@ enum CompositionAttachmentData {
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
switch self { switch self {
case let .image(image): case let .image(originalData, originalType):
// Export as JPEG instead of PNG, otherweise photos straight from the camera are too large let data: Data
// for Mastodon in its default configuration (max of 10MB). let type: UTType
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. switch originalType {
completion(.success((image.jpegData(compressionQuality: 0.8)!, .jpeg))) case .png, .jpeg:
data = originalData
type = originalType
default:
let image = UIImage(data: originalData)!
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
case let .asset(asset): case let .asset(asset):
if asset.mediaType == .image { if asset.mediaType == .image {
let options = PHImageRequestOptions() let options = PHImageRequestOptions()
@ -66,38 +77,12 @@ enum CompositionAttachmentData {
options.resizeMode = .none options.resizeMode = .none
options.isNetworkAccessAllowed = true options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
guard var data = data, let dataUTI = dataUTI else { guard let data = data, let dataUTI = dataUTI else {
completion(.failure(.missingData)) completion(.failure(.missingData))
return return
} }
let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
guard !skipAllConversion else { completion(.success(processed))
completion(.success((data, UTType(dataUTI)!)))
return
}
let utType: UTType
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 || dataUTI == "public.heic" {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if dataUTI == "public.png" {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
utType = .png
} else {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
utType = .jpeg
}
} else {
utType = UTType(dataUTI)!
}
completion(.success((data, utType)))
} }
} else if asset.mediaType == .video { } else if asset.mediaType == .video {
let options = PHVideoRequestOptions() let options = PHVideoRequestOptions()
@ -106,7 +91,7 @@ enum CompositionAttachmentData {
options.version = .current options.version = .current
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
if let exportSession = exportSession { if let exportSession = exportSession {
CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion) AttachmentData.exportVideoData(session: exportSession, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error { } else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error))) completion(.failure(.videoExport(error)))
} else { } else {
@ -122,7 +107,7 @@ enum CompositionAttachmentData {
completion(.failure(.noVideoExportSession)) completion(.failure(.noVideoExportSession))
return return
} }
CompositionAttachmentData.exportVideoData(session: session, completion: completion) AttachmentData.exportVideoData(session: session, completion: completion)
case let .drawing(drawing): case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1) let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
@ -132,6 +117,33 @@ enum CompositionAttachmentData {
} }
} }
private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
guard !skipAllConversion else {
return (data, type)
}
var data = data
var type = type
let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
} else {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
type = .jpeg
}
}
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
session.outputFileType = .mp4 session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
@ -172,7 +184,7 @@ enum CompositionAttachmentData {
} }
extension PHAsset { extension PHAsset {
var attachmentType: CompositionAttachmentData.AttachmentType? { var attachmentType: AttachmentData.AttachmentType? {
switch self.mediaType { switch self.mediaType {
case .image: case .image:
return .image return .image
@ -184,7 +196,7 @@ extension PHAsset {
} }
} }
extension CompositionAttachmentData: Codable { extension AttachmentData: Codable {
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
@ -192,17 +204,18 @@ extension CompositionAttachmentData: Codable {
case let .asset(asset): case let .asset(asset):
try container.encode("asset", forKey: .type) try container.encode("asset", forKey: .type)
try container.encode(asset.localIdentifier, forKey: .assetIdentifier) try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
case let .image(image): case let .image(originalData, originalType):
try container.encode("image", forKey: .type) try container.encode("image", forKey: .type)
try container.encode(image.pngData()!, forKey: .imageData) try container.encode(originalType, forKey: .imageType)
try container.encode(originalData, forKey: .imageData)
case .video(_): case .video(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded")) throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded"))
case let .drawing(drawing): case let .drawing(drawing):
try container.encode("drawing", forKey: .type) try container.encode("drawing", forKey: .type)
let drawingData = drawing.dataRepresentation() let drawingData = drawing.dataRepresentation()
try container.encode(drawingData, forKey: .drawing) try container.encode(drawingData, forKey: .drawing)
case .gif(_): case .gif(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "gif CompositionAttachments cannot be encoded")) throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded"))
} }
} }
@ -217,10 +230,16 @@ extension CompositionAttachmentData: Codable {
} }
self = .asset(asset) self = .asset(asset)
case "image": case "image":
guard let image = UIImage(data: try container.decode(Data.self, forKey: .imageData)) else { let data = try container.decode(Data.self, forKey: .imageData)
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data") 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)
} }
self = .image(image)
case "drawing": case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing) let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData) let drawing = try PKDrawing(data: drawingData)
@ -233,6 +252,7 @@ extension CompositionAttachmentData: Codable {
enum CodingKeys: CodingKey { enum CodingKeys: CodingKey {
case type case type
case imageData case imageData
case imageType
/// The local identifier of the PHAsset for this attachment /// The local identifier of the PHAsset for this attachment
case assetIdentifier case assetIdentifier
/// The PKDrawing object for this attachment. /// The PKDrawing object for this attachment.
@ -240,13 +260,13 @@ extension CompositionAttachmentData: Codable {
} }
} }
extension CompositionAttachmentData: Equatable { extension AttachmentData: Equatable {
static func ==(lhs: CompositionAttachmentData, rhs: CompositionAttachmentData) -> Bool { static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.asset(a), .asset(b)): case let (.asset(a), .asset(b)):
return a.localIdentifier == b.localIdentifier return a.localIdentifier == b.localIdentifier
case let (.image(a), .image(b)): case let (.image(a, originalType: aType), .image(b, originalType: bType)):
return a == b return a == b && aType == bType
case let (.video(a), .video(b)): case let (.video(a), .video(b)):
return a == b return a == b
case let (.drawing(a), .drawing(b)): case let (.drawing(a), .drawing(b)):

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

@ -0,0 +1,33 @@
//
// PKDrawing+Render.swift
// ComposeUI
//
// Created by Shadowfacts on 5/9/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import PencilKit
extension PKDrawing {
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
var drawingImage: UIImage!
lightTraitCollection.performAsCurrent {
drawingImage = self.image(from: rect, scale: scale)
}
let imageRect = CGRect(origin: .zero, size: rect.size)
let format = UIGraphicsImageRendererFormat()
format.opaque = false
format.scale = scale
let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
return renderer.image { (context) in
UIColor.white.setFill()
context.fill(imageRect)
drawingImage.draw(in: imageRect)
}
}
}

View File

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

View File

@ -1,5 +1,5 @@
// //
// ComposeTextViewCaretScrolling.swift // TextViewCaretScrolling.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 11/11/20. // Created by Shadowfacts on 11/11/20.
@ -8,11 +8,11 @@
import UIKit import UIKit
protocol ComposeTextViewCaretScrolling: AnyObject { protocol TextViewCaretScrolling: AnyObject {
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set } var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
} }
extension ComposeTextViewCaretScrolling { extension TextViewCaretScrolling {
func ensureCursorVisible(textView: UITextView) { func ensureCursorVisible(textView: UITextView) {
guard textView.isFirstResponder, guard textView.isFirstResponder,
let range = textView.selectedTextRange, 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,20 @@
//
// View+ForwardsCompat.swift
// ComposeUI
//
// Created by Shadowfacts on 3/25/23.
//
import SwiftUI
extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
if #available(iOS 16.0, *) {
self.scrollDisabled(disabled)
} else {
self
}
}
}

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,139 @@
//
// 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
}
var body: some View {
ZStack(alignment: .topLeading) {
if attachment.attachmentDescription.isEmpty {
placeholder
.font(.body)
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
}
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
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,141 @@
//
// 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
}
context.coordinator.text = $text
context.coordinator.maxLength = maxLength
context.coordinator.focusNextView = focusNextView
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
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,201 @@
//
// 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 else {
return nil
}
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: 3)]
if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
}
let code = Locale.LanguageCode(String(maybeIso639Code))
if code.isISOLanguage {
return code
} else {
return nil
}
}
private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first {
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage {
return code
} else {
return nil
}
} 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")
.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)
.scrollDismissesKeyboard(.interactively)
.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
.map { Lang(code: .init($0)) }
.sorted { $0.name < $1.name }
langs = Locale.LanguageCode.isoLanguageCodes
.map { Lang(code: $0) }
.sorted { $0.name < $1.name }
}
.onChange(of: query) { newValue in
if newValue.isEmpty {
filteredLangs = nil
} else {
filteredLangs = langs.filter {
$0.name.localizedCaseInsensitiveContains(newValue) || $0.code.identifier.localizedCaseInsensitiveContains(newValue)
}
}
}
}
@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,309 @@
//
// 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
}
var body: some View {
ZStack(alignment: .topLeading) {
colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground)
if draft.text.isEmpty {
ControllerView(controller: { PlaceholderController() })
.font(.system(size: fontSize))
.foregroundColor(.secondary)
.offset(x: 4, y: 8)
.accessibilityHidden(true)
}
MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, updateSelection: $updateSelection, textDidChange: textDidChange)
}
.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
@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.backgroundColor = .clear
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
textView.adjustsFontForContentSizeCategory = true
textView.textContainer.lineBreakMode = .byWordWrapping
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
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
var image: UIImage?
if let imageName = fmt.imageName {
image = UIImage(systemName: imageName)
}
return UIAction(title: fmt.accessibilityLabel, image: image) { [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,23 +1,23 @@
// //
// ComposeReplyView.swift // ReplyStatusView.swift
// Tusker // ComposeUI
// //
// Created by Shadowfacts on 8/22/20. // Created by Shadowfacts on 3/25/23.
// Copyright © 2020 Shadowfacts. All rights reserved.
// //
import SwiftUI import SwiftUI
import Pachyderm
import TuskerComponents
struct ComposeReplyView: View { struct ReplyStatusView: View {
let status: StatusMO let status: any StatusProtocol
let rowTopInset: CGFloat let rowTopInset: CGFloat
let globalFrameOutsideList: CGRect let globalFrameOutsideList: CGRect
@EnvironmentObject private var controller: ComposeController
@State private var displayNameHeight: CGFloat? @State private var displayNameHeight: CGFloat?
@State private var contentHeight: CGFloat? @State private var contentHeight: CGFloat?
@ObservedObject private var preferences = Preferences.shared
private let horizSpacing: CGFloat = 8 private let horizSpacing: CGFloat = 8
var body: some View { var body: some View {
@ -27,12 +27,12 @@ struct ComposeReplyView: View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack { HStack {
AccountDisplayNameLabel(account: status.account, textStyle: .body, emojiSize: 17) controller.displayNameLabel(status.account, .body, 17)
.lineLimit(1) .lineLimit(1)
.layoutPriority(1) .layoutPriority(1)
Text(verbatim: "@\(status.account.acct)") Text(verbatim: "@\(status.account.acct)")
.font(.system(size: 17, weight: .light)) .font(.body.weight(.light))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
@ -46,7 +46,7 @@ struct ComposeReplyView: View {
} }
}) })
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 // 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 // and it ends up partially behind the header
DispatchQueue.main.async { DispatchQueue.main.async {
@ -76,11 +76,14 @@ 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 // once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
offset = min(offset, maxOffset) offset = min(offset, maxOffset)
return ComposeAvatarImageView(url: status.account.avatar) return AvatarImageView(
.frame(width: 50, height: 50) url: status.account.avatar,
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50) size: 50,
.offset(x: 0, y: offset) style: controller.config.avatarStyle,
.accessibilityHidden(true) fetchAvatar: controller.fetchAvatar
)
.offset(x: 0, y: offset)
.accessibilityHidden(true)
} }
} }
@ -91,9 +94,3 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
value = nextValue() 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 // CharacterCounterTests.swift
// PachydermTests // ComposeUITests
// //
// Created by Shadowfacts on 9/29/18. // Created by Shadowfacts on 9/29/18.
// Copyright © 2018 Shadowfacts. All rights reserved. // Copyright © 2018 Shadowfacts. All rights reserved.
// //
import XCTest import XCTest
@testable import Pachyderm @testable import ComposeUI
import InstanceFeatures
class CharacterCounterTests: XCTestCase { class CharacterCounterTests: XCTestCase {
@ -17,31 +18,33 @@ class CharacterCounterTests: XCTestCase {
override func tearDown() { override func tearDown() {
} }
let features = InstanceFeatures()
func testCountEmpty() { func testCountEmpty() {
XCTAssertEqual(CharacterCounter.count(text: ""), 0) XCTAssertEqual(CharacterCounter.count(text: "", for: features), 0)
} }
func testCountPlainText() { func testCountPlainText() {
XCTAssertEqual(CharacterCounter.count(text: "This is an example message"), 26) XCTAssertEqual(CharacterCounter.count(text: "This is an example message", for: features), 26)
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄"), 43) XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄", for: features), 43)
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄"), 7) XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄", for: features), 7)
} }
func testCountLinks() { 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", for: features), 55)
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com"), 57) XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com", for: features), 57)
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com"), 32) 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"), 55) 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() { func testCountLocalMentions() {
XCTAssertEqual(CharacterCounter.count(text: "hello @example"), 14) XCTAssertEqual(CharacterCounter.count(text: "hello @example", for: features), 14)
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name"), 22) XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name", for: features), 22)
} }
func testCountRemoteMentions() { func testCountRemoteMentions() {
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social"), 14) 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"), 28) 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

@ -10,6 +10,8 @@ import UIKit
public protocol DuckableViewController: UIViewController { public protocol DuckableViewController: UIViewController {
var duckableDelegate: DuckableViewControllerDelegate? { get set } var duckableDelegate: DuckableViewControllerDelegate? { get set }
func duckableViewControllerShouldDuck() -> DuckAttemptAction
func duckableViewControllerMayAttemptToDuck() func duckableViewControllerMayAttemptToDuck()
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
@ -18,6 +20,7 @@ public protocol DuckableViewController: UIViewController {
} }
extension DuckableViewController { extension DuckableViewController {
public func duckableViewControllerShouldDuck() -> DuckAttemptAction { .duck }
public func duckableViewControllerMayAttemptToDuck() {} public func duckableViewControllerMayAttemptToDuck() {}
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {} public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
public func duckableViewControllerDidFinishAnimatingDuck() {} public func duckableViewControllerDidFinishAnimatingDuck() {}
@ -27,6 +30,12 @@ public protocol DuckableViewControllerDelegate: AnyObject {
func duckableViewControllerWillDismiss(animated: Bool) func duckableViewControllerWillDismiss(animated: Bool)
} }
public enum DuckAttemptAction {
case duck
case dismiss
case block
}
extension UIViewController { extension UIViewController {
@available(iOS 16.0, *) @available(iOS 16.0, *)
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool { public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {

View File

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

View File

@ -88,10 +88,9 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) { private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.duckableDelegate = self viewController.duckableDelegate = self
let nav = UINavigationController(rootViewController: viewController) viewController.modalPresentationStyle = .custom
nav.modalPresentationStyle = .custom viewController.transitioningDelegate = self
nav.transitioningDelegate = self present(viewController, animated: animated) {
present(nav, animated: animated) {
self.configureChildForDuckedPlaceholder() self.configureChildForDuckedPlaceholder()
completion?() completion?()
} }
@ -136,10 +135,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else { guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
return return
} }
let placeholder = createPlaceholderForDuckedViewController(viewController) switch viewController.duckableViewControllerShouldDuck() {
state = .ducked(viewController, placeholder: placeholder) case .duck:
configureChildForDuckedPlaceholder() let placeholder = createPlaceholderForDuckedViewController(viewController)
dismiss(animated: true) state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder()
dismiss(animated: true)
case .block:
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
case .dismiss:
duckableViewControllerWillDismiss(animated: true)
dismiss(animated: true)
}
} }
private func configureChildForDuckedPlaceholder() { private func configureChildForDuckedPlaceholder() {
@ -148,6 +155,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
bottomConstraint.isActive = true bottomConstraint.isActive = true
child.view.layer.cornerRadius = duckedCornerRadius child.view.layer.cornerRadius = duckedCornerRadius
child.view.layer.cornerCurve = .continuous
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
child.view.layer.masksToBounds = true child.view.layer.masksToBounds = true
} }
@ -236,4 +244,3 @@ extension DuckableContainerViewController: UISheetPresentationControllerDelegate
} }
} }
} }

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

@ -7,17 +7,23 @@
// //
import Foundation import Foundation
import Combine
import Pachyderm import Pachyderm
import Sentry
struct InstanceFeatures { public class InstanceFeatures: ObservableObject {
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive) private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive) private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive)
private var instanceType: InstanceType = .mastodon(.vanilla, nil) private let _featuresUpdated = PassthroughSubject<Void, Never>()
private(set) var maxStatusChars = 500 public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
var localOnlyPosts: Bool { @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?
public var localOnlyPosts: Bool {
switch instanceType { switch instanceType {
case .mastodon(.hometown(_), _), .mastodon(.glitch, _): case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
return true return true
@ -26,19 +32,19 @@ struct InstanceFeatures {
} }
} }
var mastodonAttachmentRestrictions: Bool { public var mastodonAttachmentRestrictions: Bool {
instanceType.isMastodon instanceType.isMastodon
} }
var pollsAndAttachments: Bool { public var pollsAndAttachments: Bool {
instanceType.isPleroma instanceType.isPleroma
} }
var boostToOriginalAudience: Bool { public var boostToOriginalAudience: Bool {
instanceType.isPleroma || instanceType.isMastodon instanceType.isPleroma || instanceType.isMastodon
} }
var profilePinnedStatuses: Bool { public var profilePinnedStatuses: Bool {
switch instanceType { switch instanceType {
case .pixelfed: case .pixelfed:
return false return false
@ -47,33 +53,33 @@ struct InstanceFeatures {
} }
} }
var trends: Bool { public var trends: Bool {
instanceType.isMastodon instanceType.isMastodon
} }
var profileSuggestions: Bool { public var profileSuggestions: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 4, 0) instanceType.isMastodon && hasMastodonVersion(3, 4, 0)
} }
var trendingStatusesAndLinks: Bool { public var trendingStatusesAndLinks: Bool {
instanceType.isMastodon && hasMastodonVersion(3, 5, 0) instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
} }
var reblogVisibility: Bool { public var reblogVisibility: Bool {
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0)) (instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0)) || (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
} }
var probablySupportsMarkdown: Bool { public var probablySupportsMarkdown: Bool {
switch instanceType { switch instanceType {
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _): case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .calckey(_):
return true return true
default: default:
return false return false
} }
} }
var needsLocalOnlyEmojiHack: Bool { public var needsLocalOnlyEmojiHack: Bool {
if case .mastodon(.glitch, _) = instanceType { if case .mastodon(.glitch, _) = instanceType {
return true return true
} else { } else {
@ -81,34 +87,61 @@ struct InstanceFeatures {
} }
} }
var needsWideColorGamutHack: Bool { public var needsWideColorGamutHack: Bool {
if case .mastodon(_, .some(let version)) = instanceType { if case .mastodon(_, let version) = instanceType {
return version < Version(4, 0, 0) return version < Version(4, 0, 0)
} else { } else {
return true return true
} }
} }
var canFollowHashtags: Bool { public var canFollowHashtags: Bool {
hasMastodonVersion(4, 0, 0) hasMastodonVersion(4, 0, 0)
} }
var filtersV2: Bool { public var filtersV2: Bool {
hasMastodonVersion(4, 0, 0) hasMastodonVersion(4, 0, 0)
} }
var notificationsAllowedTypes: Bool { public var notificationsAllowedTypes: Bool {
hasMastodonVersion(3, 5, 0) hasMastodonVersion(3, 5, 0)
} }
var pollVotersCount: Bool { public var pollVotersCount: Bool {
instanceType.isMastodon instanceType.isMastodon
} }
mutating func update(instance: Instance, nodeInfo: NodeInfo?) { 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 needsEditAttachmentsInSeparateRequest: Bool {
instanceType.isPleroma(.akkoma(nil))
}
public init() {
}
public func update(instance: Instance, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased() let ver = instance.version.lowercased()
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
if ver.contains("glitch") { if ver.contains("glitch") {
instanceType = .mastodon(.glitch, Version(string: ver)) instanceType = .mastodon(.glitch, Version(string: ver))
} else if nodeInfo?.software.name == "mastodon" {
instanceType = .mastodon(.vanilla, Version(string: ver))
} else if nodeInfo?.software.name == "hometown" { } else if nodeInfo?.software.name == "hometown" {
var mastoVersion: Version? var mastoVersion: Version?
var hometownVersion: Version? var hometownVersion: Version?
@ -144,17 +177,26 @@ struct InstanceFeatures {
instanceType = .pleroma(.akkoma(akkomaVersion)) instanceType = .pleroma(.akkoma(akkomaVersion))
} else if ver.contains("pixelfed") { } else if ver.contains("pixelfed") {
instanceType = .pixelfed instanceType = .pixelfed
} else if nodeInfo?.software.name == "gotosocial" {
instanceType = .gotosocial
} else if ver.contains("calckey") {
instanceType = .calckey(nodeInfo?.software.version)
} else { } else {
instanceType = .mastodon(.vanilla, Version(string: ver)) instanceType = .mastodon(.vanilla, Version(string: ver))
} }
maxStatusChars = instance.maxStatusCharacters ?? 500 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
}
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo) _featuresUpdated.send()
} }
func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if case .mastodon(_, .some(let version)) = instanceType { if case .mastodon(_, let version) = instanceType {
return version >= Version(major, minor, patch) return version >= Version(major, minor, patch)
} else { } else {
return false return false
@ -163,7 +205,7 @@ struct InstanceFeatures {
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
switch instanceType { switch instanceType {
case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))): case .pleroma(.vanilla(let version)), .pleroma(.akkoma(let version)):
return version >= Version(major, minor, patch) return version >= Version(major, minor, patch)
default: default:
return false return false
@ -172,10 +214,12 @@ struct InstanceFeatures {
} }
extension InstanceFeatures { extension InstanceFeatures {
enum InstanceType { @_spi(InstanceType) public enum InstanceType {
case mastodon(MastodonType, Version?) case mastodon(MastodonType, Version?)
case pleroma(PleromaType) case pleroma(PleromaType)
case pixelfed case pixelfed
case gotosocial
case calckey(String?)
var isMastodon: Bool { var isMastodon: Bool {
if case .mastodon(_, _) = self { if case .mastodon(_, _) = self {
@ -185,6 +229,15 @@ extension InstanceFeatures {
} }
} }
func isMastodon(_ subtype: MastodonType) -> Bool {
if case .mastodon(let t, _) = self,
t.equalsIgnoreVersion(subtype) {
return true
} else {
return false
}
}
var isPleroma: Bool { var isPleroma: Bool {
if case .pleroma(_) = self { if case .pleroma(_) = self {
return true return true
@ -192,86 +245,49 @@ extension InstanceFeatures {
return false return false
} }
} }
}
enum MastodonType { func isPleroma(_ subtype: PleromaType) -> Bool {
case vanilla if case .pleroma(let t) = self,
case hometown(Version?) t.equalsIgnoreVersion(subtype) {
case glitch
}
enum PleromaType {
case vanilla(Version?)
case akkoma(Version?)
}
}
extension InstanceFeatures {
struct Version: Equatable, Comparable {
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
}
static func ==(lhs: Version, rhs: Version) -> Bool {
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
}
static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool {
if lhs.major < rhs.major {
return true
} else if lhs.major > rhs.major {
return false
} else if lhs.minor < rhs.minor {
return true
} else if lhs.minor > rhs.minor {
return false
} else if lhs.patch < rhs.patch {
return true return true
} else { } else {
return false return false
} }
} }
} }
}
private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) { @_spi(InstanceType) public enum MastodonType {
let crumb = Breadcrumb(level: .info, category: "MastodonController") case vanilla
crumb.data = [ case hometown(Version?)
"instance": [ case glitch
"version": instance.version
], func equalsIgnoreVersion(_ other: MastodonType) -> Bool {
] switch (self, other) {
if let nodeInfo { case (.vanilla, .vanilla):
crumb.data!["nodeInfo"] = [ return true
"software": nodeInfo.software.name, case (.hometown(_), .hometown(_)):
"version": nodeInfo.software.version, 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
}
}
} }
SentrySDK.addBreadcrumb(crumb)
} }

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 // VersionTests.swift
// TuskerTests // InstanceFeaturesTests
// //
// Created by Shadowfacts on 4/2/22. // Created by Shadowfacts on 4/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved. // Copyright © 2022 Shadowfacts. All rights reserved.
// //
import XCTest import XCTest
@testable import Tusker @testable import InstanceFeatures
class VersionTests: XCTestCase { 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,238 @@
//
// 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
}
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

@ -17,7 +17,7 @@
BlueprintIdentifier = "D61099AA2144B0CC00432DC2" BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
BuildableName = "Pachyderm.framework" BuildableName = "Pachyderm.framework"
BlueprintName = "Pachyderm" BlueprintName = "Pachyderm"
ReferencedContainer = "container:Tusker.xcodeproj"> ReferencedContainer = "container:../../Tusker.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
@ -35,7 +35,7 @@
BlueprintIdentifier = "PachydermTests" BlueprintIdentifier = "PachydermTests"
BuildableName = "PachydermTests" BuildableName = "PachydermTests"
BlueprintName = "PachydermTests" BlueprintName = "PachydermTests"
ReferencedContainer = "container:Pachyderm"> ReferencedContainer = "container:">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
</Testables> </Testables>
@ -56,7 +56,7 @@
BlueprintIdentifier = "D61099AA2144B0CC00432DC2" BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
BuildableName = "Pachyderm.framework" BuildableName = "Pachyderm.framework"
BlueprintName = "Pachyderm" BlueprintName = "Pachyderm"
ReferencedContainer = "container:Tusker.xcodeproj"> ReferencedContainer = "container:../../Tusker.xcodeproj">
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
</LaunchAction> </LaunchAction>
@ -72,7 +72,7 @@
BlueprintIdentifier = "D61099AA2144B0CC00432DC2" BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
BuildableName = "Pachyderm.framework" BuildableName = "Pachyderm.framework"
BlueprintName = "Pachyderm" BlueprintName = "Pachyderm"
ReferencedContainer = "container:Tusker.xcodeproj"> ReferencedContainer = "container:../../Tusker.xcodeproj">
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
</ProfileAction> </ProfileAction>

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import WebURL
/** /**
The base Mastodon API client. The base Mastodon API client.
@ -83,7 +84,7 @@ public class Client {
completion(.failure(Error(request: request, type: .invalidResponse))) completion(.failure(Error(request: request, type: .invalidResponse)))
return 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 mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode) let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(Error(request: request, type: type))) completion(.failure(Error(request: request, type: type)))
@ -139,13 +140,14 @@ public class Client {
} }
} }
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([ let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
"client_id" => clientID, "client_id" => clientID,
"client_secret" => clientSecret, "client_secret" => clientSecret,
"grant_type" => "authorization_code", "grant_type" => "authorization_code",
"code" => authorizationCode, "code" => authorizationCode,
"redirect_uri" => redirectURI "redirect_uri" => redirectURI,
"scope" => scopes.scopeString,
])) ]))
run(request) { result in run(request) { result in
defer { completion(result) } defer { completion(result) }
@ -185,9 +187,9 @@ public class Client {
case let .success(wellKnown, _): case let .success(wellKnown, _):
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
let components = URLComponents(string: url.href), let href = WebURL(url.href),
components.host == self.baseURL.host { href.host == WebURL(self.baseURL)?.host {
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path)) let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
self.run(nodeInfo, completion: completion) self.run(nodeInfo, completion: completion)
} }
} }
@ -313,6 +315,13 @@ public class Client {
], attachment)) ], 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 // MARK: - Mutes
public static func getMutes(range: RequestRange) -> Request<[Account]> { public static func getMutes(range: RequestRange) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes") var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
@ -380,11 +389,11 @@ public class Client {
public static func createStatus(text: String, public static func createStatus(text: String,
contentType: StatusContentType = .plain, contentType: StatusContentType = .plain,
inReplyTo: String? = nil, inReplyTo: String? = nil,
media: [Attachment]? = nil, mediaIDs: [String]? = nil,
sensitive: Bool? = nil, sensitive: Bool? = nil,
spoilerText: String? = nil, spoilerText: String? = nil,
visibility: Status.Visibility? = nil, visibility: Visibility? = nil,
language: String? = nil, language: String? = nil, // language supported by mastodon and akkoma
pollOptions: [String]? = nil, pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil, pollExpiresIn: Int? = nil,
pollMultiple: Bool? = nil, pollMultiple: Bool? = nil,
@ -400,7 +409,32 @@ public class Client {
"poll[expires_in]" => pollExpiresIn, "poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple, "poll[multiple]" => pollMultiple,
"local_only" => localOnly, "local_only" => localOnly,
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions)) ] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
}
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 // MARK: - Timelines
@ -481,6 +515,12 @@ 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 { extension Client {
@ -499,13 +539,15 @@ extension Client {
self.type = type self.type = type
} }
public var localizedDescription: String { public var errorDescription: String? {
switch type { switch type {
case .networkError(let error): case .networkError(let error):
return "Network Error: \(error.localizedDescription)" return "Network Error: \(error.localizedDescription)"
// todo: support more status codes // todo: support more status codes
case .unexpectedStatus(413): case .unexpectedStatus(413):
return "HTTP 413: Payload Too Large" return "HTTP 413: Payload Too Large"
case .unexpectedStatus(429):
return "HTTP 429: Rate Limit Exceeded"
case .unexpectedStatus(let code): case .unexpectedStatus(let code):
return "HTTP Code \(code)" return "HTTP Code \(code)"
case .invalidRequest: case .invalidRequest:

View File

@ -70,12 +70,12 @@ public final class Account: AccountProtocol, Decodable, Sendable {
} }
} }
public static func authorizeFollowRequest(_ account: Account) -> Request<Relationship> { public static func authorizeFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize") return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize")
} }
public static func rejectFollowRequest(_ account: Account) -> Request<Relationship> { public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject") return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject")
} }
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> { public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {

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

@ -64,6 +64,6 @@ extension Hashtag: Equatable, Hashable {
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(url) hasher.combine(name)
} }
} }

View File

@ -24,6 +24,13 @@ public struct Mention: Codable, Sendable {
self.url = try container.decode(WebURL.self, forKey: .url) self.url = try container.decode(WebURL.self, forKey: .url)
} }
public init(url: WebURL, username: String, acct: String, id: String) {
self.url = url
self.username = username
self.acct = acct
self.id = id
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case url case url
case username case username

View File

@ -30,9 +30,7 @@ public struct Notification: Decodable, Sendable {
} }
public static func dismiss(id notificationID: String) -> Request<Empty> { public static func dismiss(id notificationID: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([ return Request<Empty>(method: .post, path: "/api/v1/notifications/\(notificationID)/dismiss")
"id" => notificationID
]))
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {

View File

@ -24,6 +24,20 @@ public struct Poll: Codable, Sendable {
expired || (expiresAt != nil && expiresAt! < Date()) expired || (expiresAt != nil && expiresAt! < Date())
} }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt)
self.expired = try container.decode(Bool.self, forKey: .expired)
self.multiple = try container.decode(Bool.self, forKey: .multiple)
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
self.ownVotes = try container.decodeIfPresent([Int].self, forKey: .ownVotes)
self.options = try container.decode([Poll.Option].self, forKey: .options)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
}
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> { public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil)) return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
} }

View File

@ -9,7 +9,6 @@
import Foundation import Foundation
public protocol AccountProtocol { public protocol AccountProtocol {
associatedtype Account: AccountProtocol
var id: String { get } var id: String { get }
var username: String { get } var username: String { get }
@ -27,7 +26,7 @@ public protocol AccountProtocol {
var moved: Bool? { get } var moved: Bool? { get }
var bot: Bool? { get } var bot: Bool? { get }
var movedTo: Account? { get } var movedTo: Self? { get }
var emojis: [Emoji] { get } var emojis: [Emoji] { get }
var fields: [Pachyderm.Account.Field] { get } var fields: [Pachyderm.Account.Field] { get }
} }

View File

@ -0,0 +1,21 @@
//
// RelationshipProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 3/26/23.
//
import Foundation
public protocol RelationshipProtocol {
var accountID: String { get }
var following: Bool { get }
var followedBy: Bool { get }
var blocking: Bool { get }
var muting: Bool { get }
var mutingNotifications: Bool { get }
var followRequested: Bool { get }
var domainBlocking: Bool { get }
var showingReblogs: Bool { get }
var endorsed: Bool { get }
}

View File

@ -25,7 +25,7 @@ public protocol StatusProtocol {
// var favourited: Bool { get } // var favourited: Bool { get }
var sensitive: Bool { get } var sensitive: Bool { get }
var spoilerText: String { get } var spoilerText: String { get }
var visibility: Pachyderm.Status.Visibility { get } var visibility: Visibility { get }
var applicationName: String? { get } var applicationName: String? { get }
var pinned: Bool? { get } var pinned: Bool? { get }
var bookmarked: Bool? { get } var bookmarked: Bool? { get }

View File

@ -8,8 +8,8 @@
import Foundation import Foundation
public struct Relationship: Decodable, Sendable { public struct Relationship: RelationshipProtocol, Decodable, Sendable {
public let id: String public let accountID: String
public let following: Bool public let following: Bool
public let followedBy: Bool public let followedBy: Bool
public let blocking: Bool public let blocking: Bool
@ -18,7 +18,21 @@ public struct Relationship: Decodable, Sendable {
public let followRequested: Bool public let followRequested: Bool
public let domainBlocking: Bool public let domainBlocking: Bool
public let showingReblogs: Bool public let showingReblogs: Bool
public let endorsed: Bool? public let endorsed: Bool
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.accountID = try container.decode(String.self, forKey: .id)
self.following = try container.decode(Bool.self, forKey: .following)
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
self.blocking = try container.decode(Bool.self, forKey: .blocking)
self.muting = try container.decode(Bool.self, forKey: .muting)
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id

View File

@ -41,6 +41,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
public let poll: Poll? public let poll: Poll?
// Hometown, Glitch only // Hometown, Glitch only
public let localOnly: Bool? public let localOnly: Bool?
public let editedAt: Date?
public var applicationName: String? { application?.name } public var applicationName: String? { application?.name }
@ -48,14 +49,23 @@ public final class Status: StatusProtocol, Decodable, Sendable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)
self.uri = try container.decode(String.self, forKey: .uri) self.uri = try container.decode(String.self, forKey: .uri)
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url) do {
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
} catch {
let s = try? container.decode(String.self, forKey: .url)
if s == "" {
self.url = nil
} else {
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
}
}
self.account = try container.decode(Account.self, forKey: .account) self.account = try container.decode(Account.self, forKey: .account)
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID) self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID) self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog) self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
self.content = try container.decode(String.self, forKey: .content) self.content = try container.decode(String.self, forKey: .content)
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decode([Emoji].self, forKey: .emojis) self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount) self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount) self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged) self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
@ -63,7 +73,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted) self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
self.sensitive = try container.decode(Bool.self, forKey: .sensitive) self.sensitive = try container.decode(Bool.self, forKey: .sensitive)
self.spoilerText = try container.decode(String.self, forKey: .spoilerText) self.spoilerText = try container.decode(String.self, forKey: .spoilerText)
if let visibility = try? container.decode(Status.Visibility.self, forKey: .visibility) { if let visibility = try? container.decode(Visibility.self, forKey: .visibility) {
self.visibility = visibility self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility), } else if let s = try? container.decode(String.self, forKey: .visibility),
@ -83,6 +93,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked) self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
self.card = try container.decodeIfPresent(Card.self, forKey: .card) self.card = try container.decodeIfPresent(Card.self, forKey: .card)
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll) self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
} }
public static func getContext(_ statusID: String) -> Request<ConversationContext> { public static func getContext(_ statusID: String) -> Request<ConversationContext> {
@ -154,6 +165,14 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute") return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
} }
public static func source(_ statusID: String) -> Request<StatusSource> {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/source")
}
public static func history(_ statusID: String) -> Request<[StatusEdit]> {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case uri case uri
@ -184,15 +203,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
case card case card
case poll case poll
case localOnly = "local_only" case localOnly = "local_only"
} case editedAt = "edited_at"
}
extension Status {
public enum Visibility: String, Codable, CaseIterable, Sendable {
case `public`
case unlisted
case `private`
case direct
} }
} }

View File

@ -0,0 +1,38 @@
//
// StatusEdit.swift
// Pachyderm
//
// Created by Shadowfacts on 5/11/23.
//
import Foundation
public struct StatusEdit: Decodable {
public let content: String
public let spoilerText: String
public let sensitive: Bool
public let createdAt: Date
public let account: Account
public let poll: Poll?
public let attachments: [Attachment]
public let emojis: [Emoji]
enum CodingKeys: String, CodingKey {
case content
case spoilerText = "spoiler_text"
case sensitive
case createdAt = "created_at"
case account = "account"
case poll
case attachments = "media_attachments"
case emojis
}
public struct Poll: Decodable {
public let options: [Option]
public struct Option: Decodable {
public let title: String
}
}
}

View File

@ -0,0 +1,20 @@
//
// StatusSource.swift
// Pachyderm
//
// Created by Shadowfacts on 5/10/23.
//
import Foundation
public struct StatusSource: Decodable {
public let id: String
public let text: String
public let spoilerText: String
enum CodingKeys: String, CodingKey {
case id
case text
case spoilerText = "spoiler_text"
}
}

View File

@ -38,6 +38,8 @@ extension Timeline {
request.queryParameters.append("local" => true) request.queryParameters.append("local" => true)
} }
request.range = range request.range = range
// 206 can happen when the timeline is being regenerated and therefore is incomplete
request.additionalAcceptableHTTPCodes = [206]
return request return request
} }
} }

View File

@ -1,16 +1,33 @@
// //
// Visibility+String.swift // Visibility.swift
// Tusker // Pachyderm
// //
// Created by Shadowfacts on 8/29/18. // Created by Shadowfacts on 3/7/23.
// Copyright © 2018 Shadowfacts. All rights reserved.
// //
import Pachyderm import Foundation
import UIKit
extension Status.Visibility { public enum Visibility: String, Sendable, Codable, CaseIterable, Comparable {
case `public`
case unlisted
case `private`
case direct
public static func < (lhs: Visibility, rhs: Visibility) -> Bool {
switch (lhs, rhs) {
case (.direct, .public), (.private, .public), (.unlisted, .public):
return true
case (.direct, .unlisted), (.private, .unlisted):
return true
case (.direct, .private):
return true
default:
return false
}
}
}
public extension Visibility {
var displayName: String { var displayName: String {
switch self { switch self {
case .public: case .public:
@ -62,20 +79,4 @@ extension Status.Visibility {
return "envelope" return "envelope"
} }
} }
}
extension Status.Visibility: Comparable {
public static func < (lhs: Pachyderm.Status.Visibility, rhs: Pachyderm.Status.Visibility) -> Bool {
switch (lhs, rhs) {
case (.direct, .public), (.private, .public), (.unlisted, .public):
return true
case (.direct, .unlisted), (.private, .unlisted):
return true
case (.direct, .private):
return true
default:
return false
}
}
} }

View File

@ -33,8 +33,13 @@ public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertibl
switch $0 { switch $0 {
case .literal(let s): case .literal(let s):
return s return s
#if DEBUG
case .interpolated(let s):
return s
#else
case .interpolated(_): case .interpolated(_):
return "<redacted>" return "<redacted>"
#endif
} }
}.joined(separator: "") }.joined(separator: "")
} }

View File

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

View File

@ -12,6 +12,7 @@ import Foundation
public final class CollapseState: Sendable { public final class CollapseState: Sendable {
public var collapsible: Bool? public var collapsible: Bool?
public var collapsed: Bool? public var collapsed: Bool?
public var statusPropertiesHash: Int?
public var unknown: Bool { public var unknown: Bool {
collapsible == nil || collapsed == nil collapsible == nil || collapsed == nil
@ -23,7 +24,9 @@ public final class CollapseState: Sendable {
} }
public func copy() -> CollapseState { public func copy() -> CollapseState {
return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed) let new = CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
new.statusPropertiesHash = self.statusPropertiesHash
return new
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {

View File

@ -8,24 +8,16 @@
import Foundation import Foundation
public struct NotificationGroup: Identifiable, Hashable { public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification] public private(set) var notifications: [Notification]
public let id: String public let id: String
public let kind: Notification.Kind public let kind: Notification.Kind
public let statusState: CollapseState?
@MainActor public init?(notifications: [Notification]) {
init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil } guard !notifications.isEmpty else { return nil }
self.notifications = notifications self.notifications = notifications
self.id = notifications.first!.id self.id = notifications.first!.id
self.kind = notifications.first!.kind self.kind = notifications.first!.kind
switch kind {
case .mention, .status:
self.statusState = .unknown
default:
self.statusState = nil
}
} }
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool { public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {

View File

@ -8,6 +8,7 @@
import XCTest import XCTest
@testable import Pachyderm @testable import Pachyderm
@MainActor
class NotificationGroupTests: XCTestCase { class NotificationGroupTests: XCTestCase {
let decoder: JSONDecoder = { let decoder: JSONDecoder = {

View File

@ -0,0 +1,53 @@
//
// StatusTests.swift
//
//
// Created by Shadowfacts on 5/8/23.
//
import XCTest
@testable import Pachyderm
final class StatusTests: XCTestCase {
func testDecode() {
let data = """
{
"id": "1",
"uri": "https://example.com/a/1",
"url": "",
"account": {
"id": "2",
"username": "a",
"acct": "a",
"display_name": "",
"locked": false,
"created_at": 0,
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/a"
},
"content": "",
"created_at": 0,
"emojis": [],
"reblogs_count": 0,
"favourites_count": 0,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"media_attachments": [],
"mentions": [],
"tags": []
}
""".data(using: .utf8)!
do {
_ = try JSONDecoder().decode(Status.self, from: data)
} catch {
print(error)
XCTFail()
}
}
}

9
Packages/TuskerComponents/.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,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: "TuskerComponents",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "TuskerComponents",
targets: ["TuskerComponents"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
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: "TuskerComponents",
dependencies: []),
// .testTarget(
// name: "TuskerComponentsTests",
// dependencies: ["TuskerComponents"]),
]
)

View File

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

View File

@ -0,0 +1,70 @@
//
// AbbreviatedTimeAgoFormatStyle.swift
//
//
// Created by Shadowfacts on 4/9/23.
//
import Foundation
public struct AbbreviatedTimeAgoFormatStyle: FormatStyle {
public typealias FormatInput = Date
public typealias FormatOutput = String
public func format(_ value: Date) -> String {
let (amount, component) = timeAgo(value: value)
switch component {
case .year:
return "\(amount)y"
case .month:
return "\(amount)mo"
case .weekOfYear:
return "\(amount)w"
case .day:
return "\(amount)d"
case .hour:
return "\(amount)h"
case .minute:
return "\(amount)m"
case .second:
if amount >= 3 {
return "\(amount)s"
} else {
return "Now"
}
default:
fatalError("Unexpected component: \(component)")
}
}
private static let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
private func timeAgo(value: Date) -> (Int, Calendar.Component) {
let calendar = NSCalendar.current
let components = calendar.dateComponents(Self.unitFlags, from: value, to: Date())
if components.year! >= 1 {
return (components.year!, .year)
} else if components.month! >= 1 {
return (components.month!, .month)
} else if components.weekOfYear! >= 1 {
return (components.weekOfYear!, .weekOfYear)
} else if components.day! >= 1 {
return (components.day!, .day)
} else if components.hour! >= 1 {
return (components.hour!, .hour)
} else if components.minute! >= 1 {
return (components.minute!, .minute)
} else {
return (components.second!, .second)
}
}
}
public extension FormatStyle where Self == AbbreviatedTimeAgoFormatStyle {
static var abbreviatedTimeAgo: Self {
Self()
}
}

View File

@ -1,6 +1,6 @@
// //
// AlertWithData.swift // AlertWithData.swift
// Tusker // TuskerComponents
// //
// Created by Shadowfacts on 11/9/22. // Created by Shadowfacts on 11/9/22.
// Copyright © 2022 Shadowfacts. All rights reserved. // Copyright © 2022 Shadowfacts. All rights reserved.
@ -39,7 +39,7 @@ struct AlertWithData<Data, A: View, M: View>: ViewModifier {
} }
extension View { extension View {
func alertWithData<Data, A: View, M: View>(_ title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View { public func alertWithData<Data, A: View, M: View>(_ title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
modifier(AlertWithData(title: title, data: data, actions: actions, message: message)) modifier(AlertWithData(title: title, data: data, actions: actions, message: message))
} }
} }

View File

@ -0,0 +1,66 @@
//
// AvatarImageView.swift
// ComposeUI
//
// Created by Shadowfacts on 3/4/23.
//
import SwiftUI
public struct AvatarImageView: View {
public typealias FetchAvatar = (URL) async -> UIImage?
let url: URL?
let size: CGFloat
let style: Style
let fetchAvatar: FetchAvatar
@State private var image: UIImage?
public init(url: URL?, size: CGFloat, style: Style, fetchAvatar: @escaping FetchAvatar) {
self.url = url
self.size = size
self.style = style
self.fetchAvatar = fetchAvatar
}
public var body: some View {
imageView
.resizable()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: style.cornerRadiusFraction * size, style: .continuous))
.task { @MainActor in
image = nil
if let url {
image = await fetchAvatar(url)
}
}
// tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes
.id(url)
}
private var imageView: Image {
if let image {
return Image(uiImage: image)
} else {
return placeholder
}
}
private var placeholder: Image {
Image(systemName: style == .roundRect ? "person.crop.square" : "person.crop.circle")
}
public enum Style: Equatable {
case roundRect, circle
var cornerRadiusFraction: CGFloat {
switch self {
case .roundRect:
return 0.1
case .circle:
return 0.5
}
}
}
}

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