Compare commits

..

127 Commits

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

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

47
CHANGELOG-release.md Normal file
View File

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

View File

@ -1,5 +1,97 @@
# Changelog
## 2023.4 (75)
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.
## 2023.4 (74)
Features/Improvements:
- Add state restoration for more screens
- Persist state when switching between accounts
- Add handoff for various screens
- Add preference to hide GIF/ALT badges on attachments
- Add preference to use Mastodon timeline marker API for syncing Home timeline position
- Show percentage of voters for multi-choice poll results, rather than percentage of votes
- Change search results view controller to dismiss keyboard on scroll
- Only show inaccurate favorite/reblog count warning for posts from remote instances
- Show message on remote profiles with no statuses
- Add banner to profiles that have moved
- Hide placeholder image for link cards without images
- Don't check for present statuses when refreshing timeline
- Make timeline Load More button more prominent
- iOS 16.4: Use iOS-provided link previews in Share Sheet
Bugfixes:
- Fix tapping reblog count in conversation main status showing favorites list
- Fix status favorite/reblog list not adjusting to non-pure-black dark mode
- Fix non-pure-black dark mode not applying to auxiliary windows
- Fix poll option tracking gesture unselecting options when touch location moves between options
- Fix crash when tapping conversation "More Replies" cell
- Fix crash when script/style tags are present in post HTML
- Fix crash when opening Report screen in certain circumstances
## 2023.4 (73)
Features/Improvements:
- Add preference for non-pure-black dark mode
- Add Jump to Present button to timelines
- Improve status collapse animation in search results screen
- Add more trending links/hashtags/profiles buttons to Trends screen
- Add infinite scrolling to trending links/hashtags screens
- Add Share action to trending link context menu
Bugfixes:
- Fix icon in suggested profile popover not adjusting to dark mode
## 2023.4 (72)
Features/Improvements:
- Consolidate Trends into a single screen
- Make attachment description text selectable in gallery
- Add long press to copy usernames on profile screen
- Add Favorites screen to Explore tab
- Optimize conversation loading when opening a conversation that is already fully-loaded
- Apply Mastodon poll limits in Compose screen
- VoiceOver: Fast account switcher improvements (make the screen modal, select the first account upon opening the switcher, make each account a single item)
- VoiceOver: Improve labels for notifications
- VoiceOver: Fix custom emoji picker buttons not having labels
Bugfixes:
- Fix trends sometimes appearing in Explore/sidebar on non-Mastodon instances
- Fix status favorite/reblog accounts list not resizing on device rotation
- Fix bookmarks screen sometimes going haywire
- Fix trending statuses not being deselected upon navigating back
- Fix crash when tapping My Profile tab too early in app lifecycle
- Handle 401 errors on instance timelines properly
- Fix potential crash when showing context menu previews for status
- Fix follow request accept/reject buttons not matching accent color preference
- iPadOS: Fix crash when switching between sidebar and tab bar while on the Explore screen
- iOS 15: Fix accent colors not being disaplyed in Preferences
## 2023.4 (71)
Features/Improvements:
- Allow pinning instance public timelines to the Home tab
- Improve UI and retry mechanism when adding account
- Increase page size to 40 on a bunch of screens
- Update bookmarks screen when posts are bookmarked/unbookmarked
- Allow loading older and refreshing bookmarks screen
- Tweak follow count button color
Bugfixes:
- Fix timeline position sync not working in certain circumstances
- iPadOS: Fix flicker when opening favorite/reblog list in notificationss
## 2023.4 (70)
Features/Improvements:
- Add GIF/ALT badges to attachments
- Add menu action to hide/show reblogs from specific accounts
- Apply Mastodon's link truncation
- Add preference to hide link preview cards
- Tweak link preview card border color in dark mode
- Unify haptic feedback across the app
- Move Drafts button to the nav bar when the post doesn't have any content, to reduce accidental presses
Bugfixes:
- Fix status URLs with fragments not being resolved
- Workaround for local-only posts not being decodable when logged in to Akkoma instances
## 2023.3 (69)
Features/Improvements:
- Add Tip Jar under Preferences

View File

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

View File

@ -62,7 +62,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
guard case .idle = state else {
if animated,
case .ducked(_, placeholder: let placeholder) = state {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UIImpactFeedbackGenerator(style: .light).impactOccurred()
let origConstant = placeholder.topConstraint.constant
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {

View File

@ -155,6 +155,27 @@ public class Client {
}
}
public func revokeAccessToken() async throws {
guard let accessToken else {
return
}
let request = Request<Empty>(method: .post, path: "/oauth/revoke", body: ParametersBody([
"token" => accessToken,
"client_id" => clientID!,
"client_secret" => clientSecret!,
]))
return try await withCheckedThrowingContinuation({ continuation in
self.run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(_, _):
continuation.resume()
}
}
})
}
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
run(wellKnown) { result in
@ -178,8 +199,10 @@ public class Client {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
}
public static func getFavourites() -> Request<[Status]> {
return Request<[Status]>(method: .get, path: "/api/v1/favourites")
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
request.range = range
return request
}
public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
@ -394,32 +417,46 @@ public class Client {
}
// MARK: - Instance
public static func getTrendingHashtags(limit: Int? = nil) -> Request<[Hashtag]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
}
public static func getTrendingStatuses(limit: Int? = nil) -> Request<[Status]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
public static func getTrendingHashtags(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
}
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
return Request(method: .get, path: "/api/v1/trends/statuses", queryParameters: parameters)
}
public static func getTrendingLinks(limit: Int? = nil) -> Request<[Card]> {
let parameters: [Parameter]
if let limit = limit {
parameters = ["limit" => limit]
} else {
parameters = []
public static func getTrendingLinks(limit: Int? = nil, offset: Int? = nil) -> Request<[Card]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)
}
if let offset {
parameters.append("offset" => offset)
}
return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
}
@ -447,7 +484,7 @@ public class Client {
}
extension Client {
public struct Error: LocalizedError {
public struct Error: LocalizedError, Sendable {
public let requestMethod: Method
public let requestEndpoint: Endpoint
public let type: ErrorType
@ -482,7 +519,7 @@ extension Client {
}
}
}
public enum ErrorType: LocalizedError {
public enum ErrorType: LocalizedError, Sendable {
case networkError(Swift.Error)
case unexpectedStatus(Int)
case invalidRequest

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import Foundation
import WebURL
import WebURLFoundationExtras
public class Hashtag: Codable {
public struct Hashtag: Codable, Sendable {
public let name: String
public let url: WebURL
/// Only present when returned from the trending hashtags endpoint
@ -25,7 +25,7 @@ public class Hashtag: Codable {
self.following = nil
}
public required init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
// pixelfed (possibly others) don't fully escape special characters in the hashtag url

View File

@ -8,12 +8,12 @@
import Foundation
public class History: Codable {
public struct History: Codable, Sendable {
public let day: Date
public let uses: Int
public let accounts: Int
public required init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let day = try? container.decode(Date.self, forKey: .day) {

View File

@ -8,7 +8,7 @@
import Foundation
public class Instance: Decodable {
public struct Instance: Decodable, Sendable {
public let uri: String
public let title: String
public let description: String
@ -37,7 +37,7 @@ public class Instance: Decodable {
}
// we need a custom decoder, because all API-compatible implementations don't return some data in the same format
public required init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.uri = try container.decode(String.self, forKey: .uri)
self.title = try container.decode(String.self, forKey: .title)
@ -93,7 +93,7 @@ public class Instance: Decodable {
}
extension Instance {
public struct Stats: Decodable {
public struct Stats: Decodable, Sendable {
public let domainCount: Int?
public let statusCount: Int?
public let userCount: Int?
@ -107,7 +107,7 @@ extension Instance {
}
extension Instance {
public struct Configuration: Decodable {
public struct Configuration: Decodable, Sendable {
public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
@ -122,7 +122,7 @@ extension Instance {
}
extension Instance {
public struct StatusesConfiguration: Decodable {
public struct StatusesConfiguration: Decodable, Sendable {
public let maxCharacters: Int
public let maxMediaAttachments: Int
public let charactersReservedPerURL: Int
@ -136,7 +136,7 @@ extension Instance {
}
extension Instance {
public struct MediaAttachmentsConfiguration: Decodable {
public struct MediaAttachmentsConfiguration: Decodable, Sendable {
public let supportedMIMETypes: [String]
public let imageSizeLimit: Int
public let imageMatrixLimit: Int
@ -156,7 +156,7 @@ extension Instance {
}
extension Instance {
public struct PollsConfiguration: Decodable {
public struct PollsConfiguration: Decodable, Sendable {
public let maxOptions: Int
public let maxCharactersPerOption: Int
public let minExpiration: TimeInterval
@ -172,7 +172,7 @@ extension Instance {
}
extension Instance {
public struct Rule: Decodable, Identifiable {
public struct Rule: Decodable, Identifiable, Sendable {
public let id: String
public let text: String
}

View File

@ -8,7 +8,7 @@
import Foundation
public class List: Decodable, Equatable, Hashable {
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
public let id: String
public let title: String
@ -16,6 +16,11 @@ public class List: Decodable, Equatable, Hashable {
return .list(id: id)
}
public init(id: String, title: String) {
self.id = id
self.title = title
}
public static func ==(lhs: List, rhs: List) -> Bool {
return lhs.id == rhs.id && lhs.title == rhs.title
}
@ -25,28 +30,28 @@ public class List: Decodable, Equatable, Hashable {
hasher.combine(title)
}
public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
public static func getAccounts(_ listID: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(listID)/accounts")
request.range = range
return request
}
public static func update(_ list: List, title: String) -> Request<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
public static func update(_ listID: String, title: String) -> Request<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title]))
}
public static func delete(_ list: List) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
public static func delete(_ listID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)")
}
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
public static func add(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody(
"account_ids" => accountIDs
))
}
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
public static func remove(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody(
"account_ids" => accountIDs
))
}

View File

@ -8,7 +8,7 @@
import Foundation
public class LoginSettings: Decodable {
public struct LoginSettings: Decodable, Sendable {
public let accessToken: String
private let scope: String?

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public struct Mention: Codable {
public struct Mention: Codable, Sendable {
public let url: WebURL
public let username: String
public let acct: String

View File

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

View File

@ -8,14 +8,14 @@
import Foundation
public class Notification: Decodable {
public struct Notification: Decodable, Sendable {
public let id: String
public let kind: Kind
public let createdAt: Date
public let account: Account
public let status: Status?
public required init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
@ -45,7 +45,7 @@ public class Notification: Decodable {
}
extension Notification {
public enum Kind: String, Decodable, CaseIterable {
public enum Kind: String, Decodable, CaseIterable, Sendable {
case mention
case reblog
case favourite

View File

@ -8,7 +8,7 @@
import Foundation
public final class Poll: Codable {
public struct Poll: Codable, Sendable {
public let id: String
public let expiresAt: Date?
public let expired: Bool
@ -43,7 +43,7 @@ public final class Poll: Codable {
}
extension Poll {
public final class Option: Codable {
public struct Option: Codable, Sendable {
public let title: String
public let votesCount: Int?

View File

@ -0,0 +1,13 @@
//
// ListProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 2/25/23.
//
import Foundation
public protocol ListProtocol {
var id: String { get }
var title: String { get }
}

View File

@ -8,7 +8,7 @@
import Foundation
public class PushSubscription: Decodable {
public struct PushSubscription: Decodable, Sendable {
public let id: String
public let endpoint: URL
public let serverKey: String

View File

@ -8,12 +8,12 @@
import Foundation
public class RegisteredApplication: Decodable {
public struct RegisteredApplication: Decodable, Sendable {
public let id: String
public let clientID: String
public let clientSecret: String
public required init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Pixelfed API returns id/client_id as numbers instead of strings

View File

@ -8,7 +8,7 @@
import Foundation
public class Relationship: Decodable {
public struct Relationship: Decodable, Sendable {
public let id: String
public let following: Bool
public let followedBy: Bool

View File

@ -8,7 +8,7 @@
import Foundation
public class Report: Decodable {
public struct Report: Decodable, Sendable {
public let id: String
public let actionTaken: Bool

View File

@ -8,7 +8,7 @@
import Foundation
public enum Scope: String {
public enum Scope: String, Sendable {
case read
case write
case follow

View File

@ -8,7 +8,7 @@
import Foundation
public enum SearchResultType: String {
public enum SearchResultType: String, Sendable {
case accounts
case hashtags
case statuses

View File

@ -8,7 +8,7 @@
import Foundation
public class SearchResults: Decodable {
public struct SearchResults: Decodable, Sendable {
public let accounts: [Account]
public let statuses: [Status]
public let hashtags: [Hashtag]

View File

@ -9,7 +9,7 @@
import Foundation
import WebURL
public final class Status: StatusProtocol, Decodable {
public final class Status: StatusProtocol, Decodable, Sendable {
public let id: String
public let uri: String
public let url: WebURL?
@ -44,6 +44,47 @@ public final class Status: StatusProtocol, Decodable {
public var applicationName: String? { application?.name }
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.uri = try container.decode(String.self, forKey: .uri)
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
self.account = try container.decode(Account.self, forKey: .account)
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
self.content = try container.decode(String.self, forKey: .content)
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
self.favourited = try container.decodeIfPresent(Bool.self, forKey: .favourited)
self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
self.sensitive = try container.decode(Bool.self, forKey: .sensitive)
self.spoilerText = try container.decode(String.self, forKey: .spoilerText)
if let visibility = try? container.decode(Status.Visibility.self, forKey: .visibility) {
self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility),
s == "local" {
// hacky workaround for #332, akkoma describes local posts with a separate visibility
self.visibility = .public
self.localOnly = true
} else {
throw DecodingError.dataCorruptedError(forKey: .visibility, in: container, debugDescription: "Could not decode visibility")
}
self.attachments = try container.decode([Attachment].self, forKey: .attachments)
self.mentions = try container.decode([Mention].self, forKey: .mentions)
self.hashtags = try container.decode([Hashtag].self, forKey: .hashtags)
self.application = try container.decodeIfPresent(Application.self, forKey: .application)
self.language = try container.decodeIfPresent(String.self, forKey: .language)
self.pinned = try container.decodeIfPresent(Bool.self, forKey: .pinned)
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
}
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
return Request<ConversationContext>(method: .get, path: "/api/v1/statuses/\(statusID)/context")
}
@ -147,7 +188,7 @@ public final class Status: StatusProtocol, Decodable {
}
extension Status {
public enum Visibility: String, Codable, CaseIterable {
public enum Visibility: String, Codable, CaseIterable, Sendable {
case `public`
case unlisted
case `private`

View File

@ -8,7 +8,7 @@
import Foundation
public enum StatusContentType: String, Codable, CaseIterable {
public enum StatusContentType: String, Codable, CaseIterable, Sendable {
case plain, markdown, html
var mimeType: String {

View File

@ -7,7 +7,7 @@
import Foundation
public struct Suggestion: Decodable {
public struct Suggestion: Decodable, Sendable {
public let source: Source
public let account: Account
@ -17,7 +17,7 @@ public struct Suggestion: Decodable {
}
extension Suggestion {
public enum Source: String, Decodable {
public enum Source: String, Decodable, Sendable {
case staff
case pastInteractions = "past_interactions"
case global

View File

@ -8,7 +8,7 @@
import Foundation
public enum Timeline: Equatable, Hashable {
public enum Timeline: Equatable, Hashable, Sendable {
case home
case `public`(local: Bool)
case tag(hashtag: String)

View File

@ -0,0 +1,40 @@
//
// TimelineMarkers.swift
// Pachyderm
//
// Created by Shadowfacts on 2/14/23.
//
import Foundation
public struct TimelineMarkers: Decodable, Sendable {
public let home: Marker?
public let notifications: Marker?
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue))
}
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
"\(timeline.rawValue)[last_read_id]" => lastReadID,
]))
}
public enum Timeline: String {
case home
case notifications
}
public struct Marker: Decodable, Sendable {
public let lastReadID: String
public let version: Int
public let updatedAt: Date
enum CodingKeys: String, CodingKey {
case lastReadID = "last_read_id"
case version
case updatedAt = "updated_at"
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation
struct WellKnown: Decodable {
struct WellKnown: Decodable, Sendable {
let links: [Link]
struct Link: Decodable {

View File

@ -8,7 +8,7 @@
import Foundation
protocol Body {
protocol Body: Sendable {
var mimeType: String? { get }
var data: Data? { get }
}
@ -76,7 +76,7 @@ struct FormDataBody: Body {
}
}
struct JsonBody<T: Encodable>: Body {
struct JsonBody<T: Encodable & Sendable>: Body {
let value: T
init(_ value: T) {

View File

@ -8,7 +8,7 @@
import Foundation
public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertible {
public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertible, Sendable {
let components: [Component]
public init(stringLiteral value: StringLiteralType) {
@ -54,7 +54,7 @@ public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertibl
}
}
enum Component {
enum Component: Sendable {
case literal(String)
case interpolated(String)
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct FormAttachment {
public struct FormAttachment: Sendable {
let mimeType: String
let data: Data
let fileName: String

View File

@ -8,7 +8,7 @@
import Foundation
public enum Method {
public enum Method: Sendable {
case get, post, put, patch, delete
}

View File

@ -8,7 +8,7 @@
import Foundation
struct Parameter {
struct Parameter: Sendable {
let name: String
let value: String?
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct Request<ResultType: Decodable> {
public struct Request<ResultType: Decodable>: Sendable {
let method: Method
let endpoint: Endpoint
let body: Body

View File

@ -8,13 +8,24 @@
import Foundation
public enum RequestRange {
public enum RequestRange: Sendable {
case `default`
case count(Int)
/// Chronologically immediately before the given ID
case before(id: String, count: Int?)
/// Chronologically immediately after the given ID
case after(id: String, count: Int?)
public func withCount(_ count: Int) -> Self {
switch self {
case .default, .count(_):
return .count(count)
case .before(id: let id, count: _):
return .before(id: id, count: count)
case .after(id: let id, count: _):
return .after(id: id, count: count)
}
}
}
extension RequestRange {

View File

@ -8,6 +8,6 @@
import Foundation
public struct Empty: Decodable {
public struct Empty: Decodable, Sendable {
}

View File

@ -8,7 +8,7 @@
import Foundation
public struct Pagination {
public struct Pagination: Sendable {
public let older: RequestRange?
public let newer: RequestRange?
}

View File

@ -8,7 +8,7 @@
import Foundation
public enum Response<Result: Decodable> {
public enum Response<Result: Decodable & Sendable>: Sendable {
case success(Result, Pagination?)
case failure(Client.Error)
}

View File

@ -8,7 +8,8 @@
import Foundation
public class CollapseState: Equatable {
@MainActor
public final class CollapseState: Sendable {
public var collapsible: Bool?
public var collapsed: Bool?
@ -33,8 +34,4 @@ public class CollapseState: Equatable {
public static var unknown: CollapseState {
CollapseState(collapsible: nil, collapsed: nil)
}
public static func == (lhs: CollapseState, rhs: CollapseState) -> Bool {
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
}
}

View File

@ -14,6 +14,7 @@ public struct NotificationGroup: Identifiable, Hashable {
public let kind: Notification.Kind
public let statusState: CollapseState?
@MainActor
init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil }
self.notifications = notifications
@ -51,6 +52,7 @@ public struct NotificationGroup: Identifiable, Hashable {
notifications.append(contentsOf: group.notifications)
}
@MainActor
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [NotificationGroup]()
for notification in notifications {

View File

@ -62,7 +62,7 @@ public class GameModel: NSObject, NSCopying, GKGameModel {
case .playAnywhere(update.mark), .playSpecific(update.mark, column: update.subBoard.column, row: update.subBoard.row):
break
default:
fatalError()
return
}
controller.play(on: update.subBoard, column: update.column, row: update.row)
}

View File

@ -6,6 +6,7 @@
//
import Foundation
import Combine
public class GameController: ObservableObject {

View File

@ -20,6 +20,9 @@
D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D60088EE2980D8B5005B4D00 /* StoreKit.framework */; };
D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60088F12980DAA0005B4D00 /* TipJarView.swift */; };
D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60089182981FEBA005B4D00 /* ConfettiView.swift */; };
D600891B29848289005B4D00 /* PinnedTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891A29848289005B4D00 /* PinnedTimeline.swift */; };
D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */; };
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */; };
D601FA5B29787AB100A8E8B5 /* AccountFollowsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */; };
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */; };
D601FA5F297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */; };
@ -38,8 +41,6 @@
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */; };
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1627F8BB210080E273 /* VersionTests.swift */; };
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */; };
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; };
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
@ -102,7 +103,6 @@
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943123A5466600D38C68 /* SelectableTableViewCell.swift */; };
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */; };
D627944723A6AC9300D38C68 /* BasicTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */; };
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */; };
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */; };
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */; };
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D627FF75217E923E00CC0648 /* DraftsManager.swift */; };
@ -200,6 +200,7 @@
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; };
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; };
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */; };
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */; };
D6895DC228D65274006341DA /* CustomAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC128D65274006341DA /* CustomAlertController.swift */; };
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */; };
@ -214,11 +215,16 @@
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; };
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* SearchViewController.swift */; };
D68E525D24A3E8F00054355A /* InlineTrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */; };
D68E6F59253C9969001A1B4C /* MultiSourceEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */; };
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */; };
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; };
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */; };
D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */; };
D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */; };
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; };
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; };
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
@ -251,7 +257,6 @@
D6A5BB2D23BBA9C4003BF21D /* MyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */; };
D6A5BB2F23BBAC97003BF21D /* DelegatingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */; };
D6A5BB3123BBAD87003BF21D /* JSONResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */; };
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */; };
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
@ -293,6 +298,12 @@
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; };
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */; };
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4FA299035650009FCFF /* TrendsViewController.swift */; };
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */; };
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -319,10 +330,16 @@
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; };
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; };
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.swift */; };
@ -422,6 +439,9 @@
D60088F02980D938005B4D00 /* Tusker.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Tusker.storekit; sourceTree = "<group>"; };
D60088F12980DAA0005B4D00 /* TipJarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipJarView.swift; sourceTree = "<group>"; };
D60089182981FEBA005B4D00 /* ConfettiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiView.swift; sourceTree = "<group>"; };
D600891A29848289005B4D00 /* PinnedTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimeline.swift; sourceTree = "<group>"; };
D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelineTests.swift; sourceTree = "<group>"; };
D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddInstancePinnedTimelineView.swift; sourceTree = "<group>"; };
D601FA5A29787AB100A8E8B5 /* AccountFollowsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFollowsListViewController.swift; sourceTree = "<group>"; };
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCollectionViewController.swift; sourceTree = "<group>"; };
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandThreadCollectionViewCell.swift; sourceTree = "<group>"; };
@ -439,8 +459,6 @@
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
D6114E1227F89B440080E273 /* TrendingLinkTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinkTableViewCell.swift; sourceTree = "<group>"; };
D6114E1627F8BB210080E273 /* VersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionTests.swift; sourceTree = "<group>"; };
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTableViewCell.swift; sourceTree = "<group>"; };
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = "<group>"; };
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
@ -502,7 +520,6 @@
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableTableViewCell.swift; sourceTree = "<group>"; };
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigableTableViewCell.swift; sourceTree = "<group>"; };
D627944623A6AC9300D38C68 /* BasicTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BasicTableViewCell.xib; sourceTree = "<group>"; };
D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksTableViewController.swift; sourceTree = "<group>"; };
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTimelineViewController.swift; sourceTree = "<group>"; };
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListAccountsViewController.swift; sourceTree = "<group>"; };
D627FF75217E923E00CC0648 /* DraftsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftsManager.swift; sourceTree = "<group>"; };
@ -583,6 +600,7 @@
D667E5F72135C3040057A976 /* Mastodon+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Equatable.swift"; sourceTree = "<group>"; };
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Tusker-Bridging-Header.h"; sourceTree = "<group>"; };
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = "<group>"; };
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
@ -601,6 +619,7 @@
D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; };
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreTrendsFooterCollectionViewCell.swift; sourceTree = "<group>"; };
D686BBE224FBF8110068E6AA /* WrappedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedProgressView.swift; sourceTree = "<group>"; };
D6895DC128D65274006341DA /* CustomAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlertController.swift; sourceTree = "<group>"; };
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmReblogStatusPreviewView.swift; sourceTree = "<group>"; };
@ -616,11 +635,16 @@
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
D68E525C24A3E8F00054355A /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTrendsViewController.swift; sourceTree = "<group>"; };
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSourceEmojiLabel.swift; sourceTree = "<group>"; };
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEmojiLabel.swift; sourceTree = "<group>"; };
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = "<group>"; };
D690797224A4EF9700023A34 /* UIBezierPath+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBezierPath+Helpers.swift"; sourceTree = "<group>"; };
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Unsafe.swift"; sourceTree = "<group>"; };
D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorableViewController.swift; sourceTree = "<group>"; };
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = "<group>"; };
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
@ -653,7 +677,6 @@
D6A5BB2C23BBA9C4003BF21D /* MyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileTests.swift; sourceTree = "<group>"; };
D6A5BB2E23BBAC97003BF21D /* DelegatingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelegatingResponse.swift; sourceTree = "<group>"; };
D6A5BB3023BBAD87003BF21D /* JSONResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONResponse.swift; sourceTree = "<group>"; };
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTablePrefetching.swift; sourceTree = "<group>"; };
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
@ -695,6 +718,12 @@
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTree.swift; sourceTree = "<group>"; };
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = "<group>"; };
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AppListStyle.swift"; sourceTree = "<group>"; };
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineJumpButton.swift; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -728,10 +757,16 @@
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
D6D706A829498C82000827ED /* Tusker.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Tusker.xcconfig; sourceTree = "<group>"; };
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = "<group>"; };
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = "<group>"; };
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
@ -835,8 +870,6 @@
D611C2CC232DC5FC00C86A49 /* Hashtag Cell */ = {
isa = PBXGroup;
children = (
D611C2CD232DC61100C86A49 /* HashtagTableViewCell.swift */,
D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */,
D6E77D08286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift */,
);
path = "Hashtag Cell";
@ -852,6 +885,7 @@
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */,
D65B4B532971F71D00DABDFB /* EditedReport.swift */,
D600891A29848289005B4D00 /* PinnedTimeline.swift */,
);
path = Models;
sourceTree = "<group>";
@ -873,6 +907,7 @@
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */,
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */,
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */,
D600891E29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift */,
);
path = "Customize Timelines";
sourceTree = "<group>";
@ -907,6 +942,7 @@
isa = PBXGroup;
children = (
D6C82B4025C5BB7E0017F1E6 /* ExploreViewController.swift */,
D68E525C24A3E8F00054355A /* InlineTrendsViewController.swift */,
D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */,
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */,
D6945C3923AC75E2005C403C /* FindInstanceViewController.swift */,
@ -921,16 +957,21 @@
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */,
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */,
D693A72E25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib */,
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */,
D68329EE299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift */,
D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */,
);
path = Explore;
sourceTree = "<group>";
};
D627944823A6AD5100D38C68 /* Bookmarks */ = {
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */ = {
isa = PBXGroup;
children = (
D627944923A6AD6100D38C68 /* BookmarksTableViewController.swift */,
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */,
D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */,
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */,
);
path = Bookmarks;
path = "Local Predicate Statuses List";
sourceTree = "<group>";
};
D627944B23A9A02400D38C68 /* Lists */ = {
@ -947,6 +988,7 @@
children = (
D62D2425217ABF63005076CC /* UserActivityType.swift */,
D62D2421217AA7E1005076CC /* UserActivityManager.swift */,
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */,
D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */,
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */,
);
@ -993,7 +1035,6 @@
D6A3BC822321F69400FD64D5 /* Account List */,
D6B053A023BD2BED00A066FA /* Asset Picker */,
0411610522B457290030A9B7 /* Attachment Gallery */,
D627944823A6AD5100D38C68 /* Bookmarks */,
D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */,
@ -1002,6 +1043,7 @@
D61F759729384D4200C0B37F /* Customize Timelines */,
D641C788213DD86D004B4513 /* Large Image */,
D627944B23A9A02400D38C68 /* Lists */,
D627944823A6AD5100D38C68 /* Local Predicate Statuses List */,
D641C782213DD7F0004B4513 /* Main */,
D6F6A555291F4F0C00F496A8 /* Mute */,
D641C786213DD852004B4513 /* Notifications */,
@ -1026,6 +1068,7 @@
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */,
);
path = Timeline;
sourceTree = "<group>";
@ -1060,6 +1103,7 @@
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */,
D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */,
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */,
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */,
);
path = Profile;
sourceTree = "<group>";
@ -1070,6 +1114,7 @@
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */,
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */,
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */,
);
path = Conversation;
sourceTree = "<group>";
@ -1156,6 +1201,7 @@
D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */,
D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */,
D6ADB6EB28EA73CB009924AB /* StatusContentContainer.swift */,
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */,
);
path = Status;
sourceTree = "<group>";
@ -1167,6 +1213,7 @@
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */,
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */,
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */,
);
path = "Profile Header";
sourceTree = "<group>";
@ -1249,6 +1296,7 @@
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */,
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */,
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */,
D6D94954298963A900C59229 /* Colors.swift */,
);
path = Preferences;
sourceTree = "<group>";
@ -1278,6 +1326,8 @@
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
D61F758F29353B4300C0B37F /* FileManager+Size.swift */,
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */,
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1389,7 +1439,6 @@
D6BC9DD8232D8BCA002CA326 /* Search */ = {
isa = PBXGroup;
children = (
D68E525C24A3E8F00054355A /* SearchViewController.swift */,
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
);
path = Search;
@ -1404,6 +1453,7 @@
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
D620483523D38075008A63EF /* ContentTextView.swift */,
D6D9498E298EB79400C59229 /* CopyableLable.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
@ -1456,8 +1506,8 @@
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */,
D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */,
D63CC7112911F57C000E19DE /* StatusBarTappableViewController.swift */,
D6A6C10425B6138A00298D0F /* StatusTablePrefetching.swift */,
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
@ -1514,6 +1564,7 @@
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */,
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D60088F02980D938005B4D00 /* Tusker.storekit */,
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */,
@ -1561,6 +1612,7 @@
D6114E1627F8BB210080E273 /* VersionTests.swift */,
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
D600891C298482F0005B4D00 /* PinnedTimelineTests.swift */,
D6D4DDE6212518A200E1C4BB /* Info.plist */,
);
path = TuskerTests;
@ -1655,6 +1707,7 @@
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
D65B4B6129771A3F00DABDFB /* FetchStatusService.swift */,
D65B4B6529773AE600DABDFB /* DeleteStatusService.swift */,
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1761,7 +1814,7 @@
TargetAttributes = {
D6D4DDCB212518A000E1C4BB = {
CreatedOnToolsVersion = 10.0;
LastSwiftMigration = 1410;
LastSwiftMigration = 1420;
};
D6D4DDDF212518A200E1C4BB = {
CreatedOnToolsVersion = 10.0;
@ -1816,7 +1869,6 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */,
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
@ -1924,7 +1976,9 @@
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
D60E2F292442372B005F8713 /* AccountMO.swift in Sources */,
@ -1932,8 +1986,10 @@
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */,
D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* InlineTrendsViewController.swift in Sources */,
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */,
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */,
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
@ -1964,7 +2020,6 @@
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */,
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */,
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
@ -1973,6 +2028,7 @@
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */,
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */,
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */,
D620483623D38075008A63EF /* ContentTextView.swift in Sources */,
D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */,
@ -1982,6 +2038,7 @@
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */,
D6D94955298963A900C59229 /* Colors.swift in Sources */,
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
@ -2002,9 +2059,11 @@
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */,
D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
@ -2022,6 +2081,7 @@
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
@ -2031,13 +2091,16 @@
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
D6B17255254F88B800128392 /* OppositeCollapseKeywordsView.swift in Sources */,
D600891B29848289005B4D00 /* PinnedTimeline.swift in Sources */,
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */,
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */,
D68329EF299540050026EB24 /* MoreTrendsFooterCollectionViewCell.swift in Sources */,
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
@ -2054,7 +2117,7 @@
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
D6F6A557291F4F1600F496A8 /* MuteAccountView.swift in Sources */,
D65B4B8B297879E900DABDFB /* AccountFollowsViewController.swift in Sources */,
@ -2071,6 +2134,7 @@
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */,
D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,
@ -2102,6 +2166,7 @@
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
@ -2132,6 +2197,7 @@
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
@ -2147,7 +2213,9 @@
D6E77D0B286D426E00D8B732 /* TrendingLinkCardCollectionViewCell.swift in Sources */,
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */,
D65B4B58297203A700DABDFB /* ReportSelectRulesView.swift in Sources */,
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */,
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */,
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */,
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
@ -2168,6 +2236,7 @@
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
@ -2203,11 +2272,11 @@
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */,
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */,
D6C82B4125C5BB7E0017F1E6 /* ExploreViewController.swift in Sources */,
D6B053A423BD2C8100A066FA /* AssetCollectionsListViewController.swift in Sources */,
@ -2228,6 +2297,7 @@
D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */,
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
D600891D298482F0005B4D00 /* PinnedTimelineTests.swift in Sources */,
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */,
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
@ -2369,10 +2439,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 76;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2380,11 +2451,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.3;
MARKETING_VERSION = 2023.4;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Tusker/Tusker-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
@ -2437,7 +2509,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 76;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2446,7 +2518,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.3;
MARKETING_VERSION = 2023.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2585,10 +2657,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 76;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2596,13 +2669,15 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.3;
MARKETING_VERSION = 2023.4;
OTHER_CODE_SIGN_FLAGS = "";
OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OBJC_BRIDGING_HEADER = "Tusker/Tusker-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
@ -2613,10 +2688,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 76;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2624,11 +2700,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.3;
MARKETING_VERSION = 2023.4;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Tusker/Tusker-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6";
};
@ -2721,7 +2798,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 76;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2730,7 +2807,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.3;
MARKETING_VERSION = 2023.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2747,7 +2824,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 69;
CURRENT_PROJECT_VERSION = 76;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2756,7 +2833,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.3;
MARKETING_VERSION = 2023.4;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2835,7 +2912,7 @@
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 7.29.0;
minimumVersion = 8.0.0;
};
};
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {

View File

@ -13,11 +13,11 @@ import Pachyderm
class CreateListService {
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
private let didCreateList: (@MainActor (List) -> Void)?
private let didCreateList: (@MainActor (List) async -> Void)?
private var createAction: UIAlertAction?
init(mastodonController: MastodonController, present: @escaping (UIViewController) -> Void, didCreateList: (@MainActor (List) -> Void)?) {
init(mastodonController: MastodonController, present: @escaping (UIViewController) -> Void, didCreateList: (@MainActor (List) async -> Void)?) {
self.mastodonController = mastodonController
self.present = present
self.didCreateList = didCreateList
@ -50,7 +50,7 @@ class CreateListService {
let request = Client.createList(title: title)
let (list, _) = try await mastodonController.run(request)
mastodonController.addedList(list)
self.didCreateList?(list)
await self.didCreateList?(list)
} catch {
let alert = UIAlertController(title: "Error Creating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

View File

@ -48,7 +48,7 @@ class DeleteListService {
private func deleteList() async {
do {
let request = List.delete(list)
let request = List.delete(list.id)
_ = try await mastodonController.run(request)
mastodonController.deletedList(list)
} catch {

View File

@ -101,6 +101,10 @@ struct InstanceFeatures {
hasMastodonVersion(3, 5, 0)
}
var pollVotersCount: Bool {
instanceType.isMastodon
}
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased()
if ver.contains("glitch") {
@ -269,5 +273,5 @@ private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
"version": nodeInfo.software.version,
]
}
SentrySDK.addBreadcrumb(crumb: crumb)
SentrySDK.addBreadcrumb(crumb)
}

View File

@ -0,0 +1,35 @@
//
// LogoutService.swift
// Tusker
//
// Created by Shadowfacts on 1/27/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import Foundation
@MainActor
class LogoutService {
let accountInfo: LocalData.UserAccountInfo
private let mastodonController: MastodonController
init(accountInfo: LocalData.UserAccountInfo) {
self.accountInfo = accountInfo
self.mastodonController = MastodonController.getForAccount(accountInfo)
}
func run() {
Task.detached {
try? await self.mastodonController.client.revokeAccessToken()
}
MastodonController.removeForAccount(accountInfo)
LocalData.shared.removeAccount(accountInfo)
let psc = mastodonController.persistentContainer.persistentStoreCoordinator
for store in psc.persistentStores {
guard let url = store.url else {
continue
}
try? psc.destroyPersistentStore(at: url, type: .sqlite)
}
}
}

View File

@ -31,12 +31,16 @@ class MastodonController: ObservableObject {
}
}
static func removeForAccount(_ account: LocalData.UserAccountInfo) {
all.removeValue(forKey: account)
}
static func resetAll() {
all = [:]
}
private let transient: Bool
private(set) lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
private(set) nonisolated lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL
var accountInfo: LocalData.UserAccountInfo?
@ -106,7 +110,7 @@ class MastodonController: ObservableObject {
return response
}
func run<Result>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
let response = await runResponse(request)
try Task.checkCancellation()
switch response {
@ -162,6 +166,8 @@ class MastodonController: ObservableObject {
loadAccountPreferences()
lists = loadCachedLists()
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
@ -177,7 +183,7 @@ class MastodonController: ObservableObject {
_ = try await (ownAccount, ownInstance)
loadLists()
async let _ = await loadFilters()
_ = await loadFilters()
} catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
}
@ -359,6 +365,23 @@ class MastodonController: ObservableObject {
}
}
private func loadCachedLists() -> [List] {
let req = ListMO.fetchRequest()
guard let lists = try? persistentContainer.viewContext.fetch(req) else {
return []
}
return lists.map {
List(id: $0.id, title: $0.title)
}.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
}
func getCachedList(id: String) -> List? {
let req = ListMO.fetchRequest(id: id)
return (try? persistentContainer.viewContext.fetch(req).first).flatMap {
List(id: $0.id, title: $0.title)
}
}
@MainActor
func addedList(_ list: List) {
var new = self.lists

View File

@ -11,13 +11,13 @@ import Pachyderm
@MainActor
class RenameListService {
private let list: List
private let list: ListProtocol
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
private var renameAction: UIAlertAction?
init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
self.list = list
self.mastodonController = mastodonController
self.present = present
@ -47,7 +47,7 @@ class RenameListService {
private func updateList(with title: String) async {
do {
let req = List.update(list, title: title)
let req = List.update(list.id, title: title)
let (list, _) = try await mastodonController.run(req)
mastodonController.renamedList(list)
} catch {

View File

@ -39,9 +39,9 @@ class OpenInSafariActivity: UIActivity {
static func completionHandler(navigator: TuskerNavigationDelegate, url: URL) -> UIActivityViewController.CompletionWithItemsHandler {
return { (activityType, _, _, _) in
if activityType == .openInSafari {
let vc = SFSafariViewController(url: url)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
navigator.show(vc)
MainActor.runUnsafely {
navigator.selected(url: url, allowResolveStatuses: false, allowUniversalLinks: false)
}
}
}
}

View File

@ -26,6 +26,10 @@ class StatusActivityItemSource: NSObject, UIActivityItemSource {
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
guard #unavailable(iOS 16.4) else {
// iOS 16.4 shows the full content and attachments in the Messages preview, better than what we can generate with LPLinkMetadata
return nil
}
let metadata = LPLinkMetadata()
metadata.originalURL = status.url!
metadata.url = status.url!

View File

@ -20,6 +20,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
configureSentry()
swizzleStatusBar()
swizzlePresentationController()
AppShortcutItem.createItems(for: application)
@ -66,13 +67,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
options.enableSwizzling = false
// required to support releases/release health
options.enableAutoSessionTracking = true
options.enableOutOfMemoryTracking = false
options.enableAutoPerformanceTracking = false
options.enableWatchdogTerminationTracking = false
options.enableAutoPerformanceTracing = false
options.enableNetworkTracking = false
options.enableAppHangTracking = false
options.enableCoreDataTracking = false
options.enableCoreDataTracing = false
// we don't care about events like battery, keyboard show/hide
options.enableAutoBreadcrumbTracking = false
options.enableUserInteractionTracing = false
options.beforeSend = { event in
// just no, why would anyone need this information
@ -135,17 +137,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var originalIMP: IMP?
let imp = imp_implementationWithBlock({ (self: UIStatusBarManager, sender: AnyObject) in
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UIStatusBarManager, Selector, AnyObject) -> Void).self)
guard let windowScene = self.perform(Selector(("windowScene"))).takeUnretainedValue() as? UIWindowScene,
let xPosition = sender.value(forKey: "xPosition") as? CGFloat,
let delegate = windowScene.delegate as? TuskerSceneDelegate else {
original(self, selector, sender)
return
let exception = catchNSException {
guard let windowScene = self.perform(Selector(("windowScene"))).takeUnretainedValue() as? UIWindowScene,
let xPosition = sender.value(forKey: "xPosition") as? CGFloat,
let delegate = windowScene.delegate as? TuskerSceneDelegate else {
original(self, selector, sender)
return
}
switch delegate.handleStatusBarTapped(xPosition: xPosition) {
case .stop:
return
case .continue:
original(self, selector, sender)
}
}
switch delegate.handleStatusBarTapped(xPosition: xPosition) {
case .stop:
return
case .continue:
original(self, selector, sender)
if let exception {
SentrySDK.capture(exception: exception)
}
} as @convention(block) (UIStatusBarManager, AnyObject) -> Void)
originalIMP = class_replaceMethod(UIStatusBarManager.self, selector, imp, "v@:@")
@ -153,4 +160,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
Logging.general.error("Unable to swizzle status bar manager")
}
}
private func swizzlePresentationController() {
var originalIMP: IMP?
let imp = imp_implementationWithBlock({ (self: UIPresentationController) in
let new = UITraitCollection(pureBlackDarkMode: self.presentingViewController.traitCollection.pureBlackDarkMode)
if let existing = self.overrideTraitCollection {
self.overrideTraitCollection = UITraitCollection(traitsFrom: [existing, new])
} else {
self.overrideTraitCollection = new
}
let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UIPresentationController) -> Void).self)
original(self)
} as (@convention(block) (UIPresentationController) -> Void))
let sel = Selector(["Necessary", "If", "Traits", "update", "_"].reversed().joined())
originalIMP = class_replaceMethod(UIPresentationController.self, sel, imp, "v@:")
if originalIMP == nil {
Logging.general.error("Unable to swizzle presentation controller")
}
}
}

View File

@ -32,46 +32,38 @@ class ImageCache {
}
func get(_ url: URL, loadOriginal: Bool = false, completion: ((Data?, UIImage?) -> Void)?) -> Request? {
let key = url.absoluteString
let wrappedCompletion: ((Data?, UIImage?) -> Void)?
if let completion = completion {
wrappedCompletion = { (data, image) in
if let image {
if !loadOriginal,
let size = self.desiredPixelSize {
image.prepareThumbnail(of: size) {
completion(data, $0)
}
} else {
image.prepareForDisplay {
completion(data, $0)
}
}
} else {
completion(data, image)
}
}
} else {
wrappedCompletion = nil
}
if !ImageCache.disableCaching,
let entry = try? cache.get(key, loadOriginal: loadOriginal) {
wrappedCompletion?(entry.data, entry.image)
let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) {
completion?(entry.data, entry.image)
return nil
} else {
let task = dataTask(url: url, completion: wrappedCompletion)
task.resume()
return task
return Task.detached(priority: .userInitiated) {
let result = await self.fetch(url: url)
switch result {
case .data(let data):
completion?(data, nil)
case .dataAndImage(let data, let image):
completion?(data, image)
case .none:
completion?(nil, nil)
}
}
}
}
func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) {
// todo: this should integrate with the task cancellation mechanism somehow
return await withCheckedContinuation { continuation in
_ = get(url, loadOriginal: loadOriginal) { data, image in
continuation.resume(returning: (data, image))
if !ImageCache.disableCaching,
let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) {
return (entry.data, entry.image)
} else {
let result = await self.fetch(url: url)
switch result {
case .data(let data):
return (data, nil)
case .dataAndImage(let data, let image):
return (data, image)
case .none:
return (nil, nil)
}
}
}
@ -81,21 +73,28 @@ class ImageCache {
guard !ImageCache.disableCaching else { return }
if !((try? cache.has(url.absoluteString)) ?? false) {
let task = dataTask(url: url, completion: nil)
task.resume()
Task.detached(priority: .medium) {
_ = await self.fetch(url: url)
}
}
}
private func dataTask(url: URL, completion: ((Data?, UIImage?) -> Void)?) -> URLSessionDataTask {
return URLSession.shared.dataTask(with: url) { data, response, error in
guard error == nil,
let data else {
return
}
let image = UIImage(data: data)
try? self.cache.set(url.absoluteString, data: data, image: image)
completion?(data, image)
private func fetch(url: URL) async -> FetchResult {
guard let (data, _) = try? await URLSession.shared.data(from: url) else {
return .none
}
guard let image = UIImage(data: data) else {
try? cache.set(url.absoluteString, data: data, image: nil)
return .data(data)
}
let preparedImage: UIImage?
if let desiredPixelSize {
preparedImage = await image.byPreparingThumbnail(ofSize: desiredPixelSize)
} else {
preparedImage = await image.byPreparingForDisplay()
}
try? cache.set(url.absoluteString, data: data, image: preparedImage ?? image)
return .dataAndImage(data, preparedImage ?? image)
}
func getData(_ url: URL) -> Data? {
@ -114,6 +113,12 @@ class ImageCache {
return cache.disk?.getSizeInBytes()
}
typealias Request = URLSessionDataTask
typealias Request = Task<Void, Never>
enum FetchResult {
case data(Data)
case dataAndImage(Data, UIImage)
case none
}
}

View File

@ -77,6 +77,7 @@ class ImageDataCache {
try? disk?.removeAll()
}
// TODO: consider removing this and letting ImageCache just use the UIImage thumbnailing API
private func scaleImageIfDesired(data: Data) -> UIImage? {
guard let desiredPixelSize = desiredPixelSize,
let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary) else {
@ -84,14 +85,14 @@ class ImageDataCache {
}
let maxDimension = max(desiredPixelSize.width, desiredPixelSize.height)
let downsampleOptions = [
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
] as CFDictionary
]
if let downsampled = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) {
if let downsampled = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) {
return UIImage(cgImage: downsampled)
} else {
return nil

View File

@ -25,7 +25,7 @@ public final class AccountPreferences: NSManagedObject {
@NSManaged var pinnedTimelinesData: Data?
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: [])
var pinnedTimelines: [Timeline]
var pinnedTimelines: [PinnedTimeline]
static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context)

View File

@ -11,7 +11,7 @@ import CoreData
import Pachyderm
@objc(ListMO)
public final class ListMO: NSManagedObject {
public final class ListMO: NSManagedObject, ListProtocol {
@nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> {
return NSFetchRequest(entityName: "List")

View File

@ -211,7 +211,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
}
]
}
SentrySDK.addBreadcrumb(crumb: crumb)
SentrySDK.addBreadcrumb(crumb)
fatalError("Unable to save managed object context: \(String(describing: error))")
}
}
@ -545,6 +545,8 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
continue
}
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
timelinePosition.changedRemotely()
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
}
if changedAccountPrefs {

View File

@ -41,6 +41,10 @@ public final class TimelinePosition: NSManagedObject {
self.createdAt = Date()
}
func changedRemotely() {
_statusIDs.removeCachedValue()
}
}
// blergh, this is the simplest way of getting the Timeline into a format that A) CoreData can handle and B) is usable in the predicate

View File

@ -0,0 +1,55 @@
//
// MainActor+Unsafe.swift
// Tusker
//
// Created by Shadowfacts on 2/19/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import Foundation
/*
Copied from https://github.com/ChimeHQ/ConcurrencyPlus/blob/fe3b3fd5436b196d8c5211ab2cc4b69fc35524fe/Sources/ConcurrencyPlus/MainActor%2BUnsafe.swift
Copyright (c) 2022, Chime
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
public extension MainActor {
/// Execute the given body closure on the main actor without enforcing MainActor isolation.
///
/// This function exists to work around libraries with incorrect/inconsistent concurrency annotations. You should be **extremely** careful when using it, and only as a last resort.
///
/// It will crash if run on any non-main thread.
@MainActor(unsafe)
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
dispatchPrecondition(condition: .onQueue(.main))
return try body()
}
}

View File

@ -25,23 +25,4 @@ extension Timeline {
}
}
var image: UIImage {
switch self {
case .home:
return UIImage(systemName: "house.fill")!
case let .public(local):
if local {
return UIImage(systemName: "person.and.person.fill")!
} else {
return UIImage(systemName: "globe")!
}
case .list(id: _):
return UIImage(systemName: "list.bullet")!
case .tag(hashtag: _):
return UIImage(systemName: "number")!
case .direct:
return UIImage(systemName: "enveloep.fill")!
}
}
}

View File

@ -0,0 +1,48 @@
//
// View+AppListStyle.swift
// Tusker
//
// Created by Shadowfacts on 2/6/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import SwiftUI
import Combine
extension View {
@ViewBuilder
func appGroupedListBackground(container: UIAppearanceContainer.Type, applyBackground: Bool = true) -> some View {
if #available(iOS 16.0, *) {
if applyBackground {
self
.scrollContentBackground(.hidden)
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
} else {
self
.scrollContentBackground(.hidden)
}
} else {
self
.onAppear {
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
}
}
}
func appGroupedListRowBackground() -> some View {
self.modifier(AppGroupedListRowBackground())
}
}
private struct AppGroupedListRowBackground: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
if colorScheme == .dark, !Preferences.shared.pureBlackDarkMode {
content
.listRowBackground(Color.appGroupedCellBackground)
} else {
content
}
}
}

View File

@ -11,7 +11,7 @@ import Pachyderm
import Combine
/// An opaque object that serves as the cache for the filtered-ness of a particular status.
class FilterState {
class FilterState: @unchecked Sendable {
static var unknown: FilterState { FilterState(state: .unknown) }
fileprivate var state: State

View File

@ -55,7 +55,21 @@ struct HTMLConverter {
case let node as Element:
let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color])
for child in node.getChildNodes() {
var appendEllipsis = false
if node.tagName() == "a",
let el = child as? Element {
if el.hasClass("invisible") {
continue
} else if el.hasClass("ellipsis") {
appendEllipsis = true
}
}
attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre"))
if appendEllipsis {
attributed.append(NSAttributedString(""))
}
}
switch node.tagName() {
@ -120,6 +134,8 @@ struct HTMLConverter {
}
return attributed
case is DataNode:
return NSAttributedString()
default:
fatalError("Unexpected node type \(type(of: node))")
}

View File

@ -18,6 +18,7 @@ public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
private let fallback: Value
private var value: Value?
private var observation: NSKeyValueObservation?
private var skipClearingOnNextUpdate = false
init(from keyPath: ReferenceWritableKeyPath<Enclosing, Data?>, fallback: Value) {
self.keyPath = keyPath
@ -37,13 +38,16 @@ public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
} else {
guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback }
do {
let value = try decoder.decode(Box<Value>.self, from: data)
let value = try decoder.decode(Box.self, from: data)
wrapper.value = value.value
wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in
var updated = instance[keyPath: storageKeyPath]
updated.value = nil
updated.observation = nil
instance[keyPath: storageKeyPath] = updated
var wrapper = instance[keyPath: storageKeyPath]
if wrapper.skipClearingOnNextUpdate {
wrapper.skipClearingOnNextUpdate = false
} else {
wrapper.removeCachedValue()
}
instance[keyPath: storageKeyPath] = wrapper
})
instance[keyPath: storageKeyPath] = wrapper
return value.value
@ -55,12 +59,18 @@ public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
set {
var wrapper = instance[keyPath: storageKeyPath]
wrapper.value = newValue
wrapper.skipClearingOnNextUpdate = true
instance[keyPath: storageKeyPath] = wrapper
let newData = try! encoder.encode(Box(value: newValue))
instance[keyPath: wrapper.keyPath] = newData
}
}
mutating func removeCachedValue() {
value = nil
observation = nil
}
}
extension LazilyDecoding {
@ -72,7 +82,7 @@ extension LazilyDecoding {
extension LazilyDecoding {
// PropertyListEncoder only allows top-level types to be dicts or arrays, which breaks encoding nil-able values.
// Wrapping everything in a Box ensures that it's always a dict.
private struct Box<T: Codable>: Codable {
let value: T
struct Box: Codable {
let value: Value
}
}

View File

@ -11,7 +11,7 @@ import UIKit
struct MenuController {
static let composeCommand: UIKeyCommand = {
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.presentCompose), input: "n", modifierFlags: .command)
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.handleComposeKeyCommand), input: "n", modifierFlags: .command)
}()
static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand {

View File

@ -0,0 +1,129 @@
//
// PinnedTimeline.swift
// Tusker
//
// Created by Shadowfacts on 1/27/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
enum PinnedTimeline: Codable, Equatable, Hashable {
case home
case `public`(local: Bool)
case tag(hashtag: String)
case list(id: String)
case instance(URL)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "home":
self = .home
case "public":
self = .public(local: try container.decode(Bool.self, forKey: .local))
case "tag":
self = .tag(hashtag: try container.decode(String.self, forKey: .hashtag))
case "list":
self = .list(id: try container.decode(String.self, forKey: .listID))
case "instance":
self = .instance(try container.decode(URL.self, forKey: .instanceURL))
default:
throw DecodingError.dataCorruptedError(forKey: CodingKeys.type, in: container, debugDescription: "PinnedTimeline type must be one of 'home', 'local', 'tag', 'list', or 'instance'")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .home:
try container.encode("home", forKey: .type)
case .public(let local):
try container.encode("public", forKey: .type)
try container.encode(local, forKey: .local)
case .tag(let hashtag):
try container.encode("tag", forKey: .type)
try container.encode(hashtag, forKey: .hashtag)
case .list(let id):
try container.encode("list", forKey: .type)
try container.encode(id, forKey: .listID)
case .instance(let url):
try container.encode("instance", forKey: .type)
try container.encode(url, forKey: .instanceURL)
}
}
init?(timeline: Timeline) {
switch timeline {
case .home:
self = .home
case .public(let local):
self = .public(local: local)
case .tag(let hashtag):
self = .tag(hashtag: hashtag)
case .list(let id):
self = .list(id: id)
case .direct:
return nil
}
}
var timeline: Timeline? {
switch self {
case .home:
return .home
case .public(let local):
return .public(local: local)
case .tag(let hashtag):
return .tag(hashtag: hashtag)
case .list(let id):
return .list(id: id)
case .instance(_):
return nil
}
}
var title: String {
switch self {
case .home:
return "Home"
case let .public(local):
return local ? "Local" : "Federated"
case let .tag(hashtag):
return "#\(hashtag)"
case .list:
return "List"
case .instance(let url):
return url.host!
}
}
var image: UIImage {
switch self {
case .home:
return UIImage(systemName: "house.fill")!
case let .public(local):
if local {
return UIImage(systemName: "person.and.person.fill")!
} else {
return UIImage(systemName: "globe")!
}
case .list(id: _):
return UIImage(systemName: "list.bullet")!
case .tag(hashtag: _):
return UIImage(systemName: "number")!
case .instance(_):
return UIImage(systemName: "globe")!
}
}
private enum CodingKeys: String, CodingKey {
case type
case local
case hashtag
case listID
case instanceURL
}
}

View File

@ -13,20 +13,24 @@ import os
// to make the lock semantics more clear
@available(iOS, obsoleted: 16.0)
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: LockHolder<[AnyHashable: Any]>
private let lock: any Lock<[Key: Value]>
init() {
self.lock = LockHolder(initialState: [:])
if #available(iOS 16.0, *) {
self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
}
}
subscript(key: Key) -> Value? {
get {
return try! lock.withLock { dict in
return lock.withLock { dict in
dict[key]
} as! Value?
}
}
set(value) {
_ = try! lock.withLock { dict in
_ = lock.withLock { dict in
dict[key] = value
}
}
@ -34,40 +38,21 @@ class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
/// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread.
func removeValue(forKey key: Key) -> Value? {
return try! lock.withLock { dict in
return lock.withLock { dict in
dict.removeValue(forKey: key)
} as! Value?
}
}
func contains(key: Key) -> Bool {
return try! lock.withLock { dict in
return lock.withLock { dict in
dict.keys.contains(key)
} as! Bool
}
}
// TODO: this should really be throws/rethrows but the stupid type-erased lock makes that not possible
func withLock<R>(_ body: @Sendable (inout [Key: Value]) -> R) -> R where R: Sendable {
return try! lock.withLock { dict in
var downcasted = dict as! [Key: Value]
defer { dict = downcasted }
return body(&downcasted)
} as! R
}
}
// this type erased struct is necessary due to a compiler bug with stored constrained existential types
// see https://github.com/apple/swift/issues/61403
// see #178
fileprivate struct LockHolder<State> {
let withLock: (_ body: @Sendable (inout State) throws -> any Sendable) throws -> any Sendable
init(initialState: State) {
if #available(iOS 16.0, *) {
let lock = OSAllocatedUnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
} else {
let lock = UnfairLock(initialState: initialState)
self.withLock = lock.withLock(_:)
func withLock<R>(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable {
return try lock.withLock { dict in
return try body(&dict)
}
}
}

View File

@ -0,0 +1,104 @@
//
// Colors.swift
// Tusker
//
// Created by Shadowfacts on 1/31/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import SwiftUI
extension UIColor {
static let appBackground = UIColor { traitCollection in
if case .dark = traitCollection.userInterfaceStyle,
!traitCollection.pureBlackDarkMode {
return UIColor(hue: 230/360, saturation: 23/100, brightness: 10/100, alpha: 1)
} else {
return .systemBackground
}
}
static let appSecondaryBackground = UIColor { traitCollection in
if case .dark = traitCollection.userInterfaceStyle,
!traitCollection.pureBlackDarkMode {
if traitCollection.userInterfaceLevel == .elevated {
return UIColor(hue: 230/360, saturation: 23/100, brightness: 10/100, alpha: 1)
} else {
return UIColor(hue: 230/360, saturation: 23/100, brightness: 5/100, alpha: 1)
}
} else {
return .secondarySystemBackground
}
}
static let appGroupedBackground = UIColor { traitCollection in
if case .dark = traitCollection.userInterfaceStyle,
!traitCollection.pureBlackDarkMode {
return .appSecondaryBackground
} else {
return .systemGroupedBackground
}
}
static let appSelectedCellBackground = UIColor { traitCollection in
if case .dark = traitCollection.userInterfaceStyle,
!traitCollection.pureBlackDarkMode {
return UIColor(hue: 230/360, saturation: 20/100, brightness: 27/100, alpha: 1)
} else {
return .systemFill
}
}
static let appGroupedCellBackground = UIColor { traitCollection in
if case .dark = traitCollection.userInterfaceStyle {
if traitCollection.pureBlackDarkMode {
return .secondarySystemBackground
} else {
return .appFill
}
} else {
return .systemBackground
}
}
static let appFill = UIColor { traitCollection in
if case .dark = traitCollection.userInterfaceStyle,
!traitCollection.pureBlackDarkMode {
return UIColor(hue: 230/360, saturation: 20/100, brightness: 17/100, alpha: 1)
} else {
return .systemFill
}
}
}
extension Color {
static let appBackground = Color(uiColor: .appBackground)
static let appGroupedBackground = Color(uiColor: .appGroupedBackground)
static let appSecondaryBackground = Color(uiColor: .appSecondaryBackground)
static let appSelectedCellBackground = Color(uiColor: .appGroupedCellBackground)
static let appGroupedCellBackground = Color(uiColor: .appGroupedCellBackground)
static let appFill = Color(uiColor: .appFill)
}
private let traitsKey: String = ["Traits", "Defined", "client", "_"].reversed().joined()
private let key = "tusker_usePureBlackDarkMode"
extension UITraitCollection {
var pureBlackDarkMode: Bool {
get {
// default to true to mach OS behavior
(value(forKey: traitsKey) as? [String: Any])?[key] as? Bool ?? true
}
set {
var dict = value(forKey: traitsKey) as? [String: Any] ?? [:]
dict[key] = newValue
setValue(dict, forKey: traitsKey)
}
}
convenience init(pureBlackDarkMode: Bool) {
self.init()
self.pureBlackDarkMode = pureBlackDarkMode
}
}

View File

@ -38,12 +38,14 @@ class Preferences: Codable, ObservableObject {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
@ -63,6 +65,7 @@ class Preferences: Codable, ObservableObject {
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
@ -72,6 +75,7 @@ class Preferences: Codable, ObservableObject {
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
@ -79,7 +83,7 @@ class Preferences: Codable, ObservableObject {
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.hideDiscover = try container.decodeIfPresent(Bool.self, forKey: .hideDiscover) ?? false
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
@ -91,12 +95,14 @@ class Preferences: Codable, ObservableObject {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme)
try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode)
try container.encode(accentColor, forKey: .accentColor)
try container.encode(avatarStyle, forKey: .avatarStyle)
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
try container.encode(showLinkPreviews, forKey: .showLinkPreviews)
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
@ -112,6 +118,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari)
@ -121,6 +128,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
@ -128,7 +136,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
try container.encode(grayscaleImages, forKey: .grayscaleImages)
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
try container.encode(hideDiscover, forKey: .hideDiscover)
try container.encode(hideTrends, forKey: .hideTrends)
try container.encode(statusContentType, forKey: .statusContentType)
@ -138,12 +146,14 @@ class Preferences: Codable, ObservableObject {
// MARK: Appearance
@Published var theme = UIUserInterfaceStyle.unspecified
@Published var pureBlackDarkMode = true
@Published var accentColor = AccentColor.default
@Published var avatarStyle = AvatarStyle.roundRect
@Published var hideCustomEmojiInUsernames = false
@Published var showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false
@Published var hideActionsInTimeline = false
@Published var showLinkPreviews = true
@Published var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
@ -169,6 +179,7 @@ class Preferences: Codable, ObservableObject {
@Published var blurMediaBehindContentWarning = true
@Published var automaticallyPlayGifs = true
@Published var showUncroppedMediaInline = true
@Published var showAttachmentBadges = true
// MARK: Behavior
@Published var openLinksInApps = true
@ -179,6 +190,7 @@ class Preferences: Codable, ObservableObject {
@Published var oppositeCollapseKeywords: [String] = []
@Published var confirmBeforeReblog = false
@Published var timelineStateRestoration = true
@Published var timelineSyncMode = TimelineSyncMode.icloud
@Published var hideReblogsInTimelines = false
@Published var hideRepliesInTimelines = false
@ -187,7 +199,7 @@ class Preferences: Codable, ObservableObject {
@Published var defaultNotificationsMode = NotificationsMode.allNotifications
@Published var grayscaleImages = false
@Published var disableInfiniteScrolling = false
@Published var hideDiscover = false
@Published var hideTrends = false
// MARK: Advanced
@Published var statusContentType: StatusContentType = .plain
@ -199,12 +211,14 @@ class Preferences: Codable, ObservableObject {
private enum CodingKeys: String, CodingKey {
case theme
case pureBlackDarkMode
case accentColor
case avatarStyle
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case hideActionsInTimeline
case showLinkPreviews
case leadingStatusSwipeActions
case trailingStatusSwipeActions
@ -221,6 +235,7 @@ class Preferences: Codable, ObservableObject {
case blurMediaBehindContentWarning
case automaticallyPlayGifs
case showUncroppedMediaInline
case showAttachmentBadges
case openLinksInApps
case useInAppSafari
@ -230,6 +245,7 @@ class Preferences: Codable, ObservableObject {
case oppositeCollapseKeywords
case confirmBeforeReblog
case timelineStateRestoration
case timelineSyncMode
case hideReblogsInTimelines
case hideRepliesInTimelines
@ -237,7 +253,7 @@ class Preferences: Codable, ObservableObject {
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case hideDiscover
case hideTrends = "hideDiscover"
case statusContentType
@ -383,3 +399,10 @@ extension Preferences {
}
}
}
extension Preferences {
enum TimelineSyncMode: String, Codable {
case mastodon
case icloud
}
}

View File

@ -128,7 +128,9 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
private func createShareAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
let action = UIContextualAction(style: .normal, title: "Share") { [unowned container] _, _, completion in
container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
MainActor.runUnsafely {
container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
}
completion(true)
}
// bold to more closesly match other action symbols
@ -166,7 +168,9 @@ private func createBookmarkAction(status: StatusMO, container: StatusSwipeAction
private func createOpenInSafariAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
let action = UIContextualAction(style: .normal, title: "Open in Safari") { [unowned container] _, _, completion in
container.navigationDelegate.selected(url: status.url!, allowUniversalLinks: false)
MainActor.runUnsafely {
container.navigationDelegate.selected(url: status.url!, allowUniversalLinks: false)
}
completion(true)
}
action.image = UIImage(systemName: "safari")

View File

@ -49,7 +49,7 @@ class SavedDataManager: Codable {
var changed = false
if let hashtags = savedHashtags[accountID] {
let objects = hashtags.map {
let objects: [[String: Any]] = hashtags.map {
["url": $0.url, "name": $0.name]
}
let hashtagsReq = NSBatchInsertRequest(entity: SavedHashtag.entity(), objects: objects)

View File

@ -9,10 +9,14 @@
import UIKit
import Pachyderm
class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
var window: UIWindow?
var rootViewController: TuskerRootViewController? {
window?.rootViewController as? TuskerRootViewController
}
private var launchActivity: NSUserActivity?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
@ -70,7 +74,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? {
switch UserActivityType(rawValue: activity.activityType) {
case .showTimeline:
guard let timeline = UserActivityManager.getTimeline(from: activity) else { return nil }
guard let (timeline, _) = UserActivityManager.getTimeline(from: activity) else { return nil }
return timelineViewController(for: timeline, mastodonController: mastodonController)
case .showConversation:
@ -82,10 +86,10 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
return NotificationsPageViewController(initialMode: mode, mastodonController: mastodonController)
case .search:
return SearchViewController(mastodonController: mastodonController)
return InlineTrendsViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksTableViewController(mastodonController: mastodonController)
return BookmarksViewController(mastodonController: mastodonController)
case .myProfile:
return MyProfileViewController(mastodonController: mastodonController)
@ -112,7 +116,6 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
}
@objc private func themePrefChanged() {
window?.overrideUserInterfaceStyle = Preferences.shared.theme
window?.tintColor = Preferences.shared.accentColor.color
applyAppearancePreferences()
}
}

View File

@ -9,10 +9,12 @@
import UIKit
import Combine
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
var window: UIWindow?
var rootViewController: TuskerRootViewController? { nil }
private var cancellables = Set<AnyCancellable>()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
@ -100,8 +102,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
}
@objc private func themePrefChanged() {
window?.overrideUserInterfaceStyle = Preferences.shared.theme
window?.tintColor = Preferences.shared.accentColor.color
applyAppearancePreferences()
}
}

View File

@ -64,7 +64,15 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)")
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene))
let context: any UserActivityHandlingContext
if let account = UserActivityManager.getAccount(from: userActivity),
account.id != scene.session.mastodonController!.accountInfo!.id {
stateRestorationLogger.info("MainSceneDelegate cannot resume user activity for different account")
return
} else {
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
}
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
}
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
@ -169,10 +177,16 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
activateAccount(account, animated: false)
if let activity = launchActivity {
func doRestoreActivity(context: UserActivityHandlingContext) {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
context.finalize(activity: activity)
}
if activity.isStateRestorationActivity {
rootViewController?.restoreActivity(activity)
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
} else if activity.activityType != UserActivityType.mainScene.rawValue {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(isHandoff: false, root: rootViewController!))
} else {
stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)")
}
}
} else {
@ -204,9 +218,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
direction = .none
}
container.setRoot(newRoot, animating: direction)
container.setRoot(newRoot, for: account, animating: direction)
} else {
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot)
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
}
}
@ -214,7 +228,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
guard let account = window?.windowScene?.session.mastodonController?.accountInfo else {
return
}
LocalData.shared.removeAccount(account)
LogoutService(accountInfo: account).run()
if LocalData.shared.onboardingComplete {
activateAccount(LocalData.shared.accounts.first!, animated: false)
} else {
@ -243,8 +257,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
}
@objc func themePrefChanged() {
window?.overrideUserInterfaceStyle = Preferences.shared.theme
window?.tintColor = Preferences.shared.accentColor.color
applyAppearancePreferences()
}
func showAddAccount() {

View File

@ -7,11 +7,11 @@
//
import UIKit
import Sentry
protocol TuskerSceneDelegate: UISceneDelegate {
var window: UIWindow? { get }
var rootViewController: TuskerRootViewController? { get }
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult
}
enum StatusBarTapActionResult {
@ -27,4 +27,19 @@ extension TuskerSceneDelegate {
}
return .continue
}
func applyAppearancePreferences() {
guard let window else { return }
window.overrideUserInterfaceStyle = Preferences.shared.theme
window.tintColor = Preferences.shared.accentColor.color
let exception = catchNSException {
let key = ["Controller", "Presentation", "root", "_"].reversed().joined()
if let rootPresentationController = window.value(forKey: key) as? UIPresentationController {
rootPresentationController.overrideTraitCollection = UITraitCollection(pureBlackDarkMode: Preferences.shared.pureBlackDarkMode)
}
}
if let exception {
SentrySDK.capture(exception: exception)
}
}
}

View File

@ -11,6 +11,8 @@ import Pachyderm
class AccountFollowsListViewController: UIViewController, CollectionViewController {
private static let pageSize = 40
let accountID: String
let mastodonController: MastodonController
let mode: AccountFollowsViewController.Mode
@ -39,7 +41,8 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
}
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
return sectionConfig
@ -63,6 +66,16 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.updateUI(accountID: item)
cell.configurationUpdateHandler = { cell, state in
var config = UIBackgroundConfiguration.listPlainCell().updated(for: state)
if state.isHighlighted || state.isSelected {
config.backgroundColor = .appSelectedCellBackground
} else {
config.backgroundColor = .appBackground
}
cell.backgroundConfiguration = config
}
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, item in
cell.indicator.startAnimating()
@ -89,12 +102,12 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
}
}
private func request(for range: RequestRange) -> Request<[Account]> {
private nonisolated func request(for range: RequestRange) -> Request<[Account]> {
switch mode {
case .following:
return Account.getFollowing(accountID, range: range)
return Account.getFollowing(accountID, range: range.withCount(Self.pageSize))
case .followers:
return Account.getFollowers(accountID, range: range)
return Account.getFollowers(accountID, range: range.withCount(Self.pageSize))
}
}

View File

@ -31,7 +31,8 @@ class AccountListViewController: UIViewController, CollectionViewController {
}
override func loadView() {
let config = UICollectionLayoutListConfiguration(appearance: .grouped)
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self

View File

@ -72,7 +72,8 @@ class AssetCollectionViewController: UIViewController, UICollectionViewDelegate
// bottom ignores safe area because we want cells to underflow bottom of the screen on notched iPhones
view.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor),
])
view.backgroundColor = .systemBackground
view.backgroundColor = .appBackground
collectionView.backgroundColor = .appBackground
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed))

View File

@ -34,6 +34,7 @@ class AssetCollectionsListViewController: UITableViewController {
tableView.register(UINib(nibName: "AlbumTableViewCell", bundle: .main), forCellReuseIdentifier: "albumCell")
tableView.allowsFocus = true
tableView.backgroundColor = .appGroupedBackground
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
switch item {

View File

@ -1,196 +0,0 @@
//
// BookmarksTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 12/15/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class BookmarksTableViewController: EnhancedTableViewController {
private let statusCell = "statusCell"
let mastodonController: MastodonController
private var loaded = false
var statuses: [(id: String, state: CollapseState)] = []
var newer: RequestRange?
var older: RequestRange?
init(mastodonController: MastodonController) {
self.mastodonController = mastodonController
super.init(style: .plain)
dragEnabled = true
title = NSLocalizedString("Bookmarks", comment: "bookmarks screen title")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.allowsFocus = true
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
tableView.prefetchDataSource = self
userActivity = UserActivityManager.bookmarksActivity()
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !loaded {
loaded = true
let request = Client.getBookmarks()
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.statuses.append(contentsOf: statuses.map { ($0.id, .unknown) })
self.newer = pagination?.newer
self.older = pagination?.older
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
}
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return statuses.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
let (id, state) = statuses[indexPath.row]
cell.updateUI(statusID: id, state: state)
return cell
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.row == statuses.count, let older = older else {
return
}
let request = Client.getBookmarks(range: older)
mastodonController.run(request) { (response) in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
let newIndexPaths = (self.statuses.count..<(self.statuses.count + newStatuses.count)).map {
IndexPath(row: $0, section: 0)
}
self.statuses.append(contentsOf: newStatuses.map { ($0.id, .unknown) })
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
}
}
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let cellConfig = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
guard let status = mastodonController.persistentContainer.status(for: statuses[indexPath.row].id) else {
return cellConfig
}
let unbookmarkAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Unbookmark", comment: "unbookmark action title")) { (action, view, completion) in
let request = Status.unbookmark(status.id)
self.mastodonController.run(request) { (response) in
guard case let .success(newStatus, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus)
self.statuses.remove(at: indexPath.row)
}
}
unbookmarkAction.image = UIImage(systemName: "bookmark.fill")
let config: UISwipeActionsConfiguration
if let cellConfig = cellConfig {
config = UISwipeActionsConfiguration(actions: cellConfig.actions + [unbookmarkAction])
config.performsFirstActionWithFullSwipe = cellConfig.performsFirstActionWithFullSwipe
} else {
config = UISwipeActionsConfiguration(actions: [unbookmarkAction])
config.performsFirstActionWithFullSwipe = false
}
return config
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
let indicesToDelete = statusIDs
.compactMap { id in
self.statuses.firstIndex(where: { $0.id == id })
}
self.statuses.remove(atOffsets: IndexSet(indicesToDelete))
self.tableView.deleteRows(at: indicesToDelete.map { IndexPath(row: $0, section: 0) }, with: .automatic)
}
}
extension BookmarksTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension BookmarksTableViewController: ToastableViewController {
}
extension BookmarksTableViewController: MenuActionProvider {
}
extension BookmarksTableViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
tableView.beginUpdates()
tableView.endUpdates()
}
}
extension BookmarksTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let ids = indexPaths.map { statuses[$0.row].id }
prefetchStatuses(with: ids)
}
}

View File

@ -112,7 +112,7 @@ struct ComposeAttachmentsList: View {
self.isShowingAssetPickerPopover = false
}
// on iPadOS 16, this is necessary to show the dark color in the popover arrow
.background(Color(.systemBackground))
.background(Color(.appBackground))
.environment(\.colorScheme, .dark)
.edgesIgnoringSafeArea(.bottom)
.withSheetDetentsIfAvailable()

View File

@ -236,6 +236,7 @@ struct ComposeAutocompleteEmojisView: View {
CustomEmojiImageView(emoji: emoji)
.frame(height: emojiSize)
}
.accessibilityLabel(emoji.shortcode)
}
} header: {
if !section.isEmpty {
@ -271,6 +272,7 @@ struct ComposeAutocompleteEmojisView: View {
.foregroundColor(Color(UIColor.label))
}
}
.accessibilityLabel(emoji.shortcode)
.frame(height: emojiSize)
}
.animation(.linear(duration: 0.2), value: emojis)
@ -293,6 +295,7 @@ struct ComposeAutocompleteEmojisView: View {
.aspectRatio(contentMode: .fit)
.rotationEffect(expanded ? .zero : .degrees(180))
}
.accessibilityLabel(expanded ? "Collapse" : "Expand")
.frame(width: 20, height: 20)
}

View File

@ -15,15 +15,17 @@ struct ComposeEmojiTextField: UIViewRepresentable {
@Binding var text: String
let placeholder: String
let maxLength: Int?
let becomeFirstResponder: Binding<Bool>?
let focusNextView: Binding<Bool>?
private var didChange: ((String) -> Void)? = nil
private var didEndEditing: (() -> Void)? = nil
private var backgroundColor: UIColor? = nil
init(text: Binding<String>, placeholder: String, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
init(text: Binding<String>, placeholder: String, maxLength: Int? = nil, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
self._text = text
self.placeholder = placeholder
self.maxLength = maxLength
self.becomeFirstResponder = becomeFirstResponder
self.focusNextView = focusNextView
self.didChange = nil
@ -74,6 +76,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
} else {
uiView.text = text
}
context.coordinator.maxLength = maxLength
context.coordinator.didChange = didChange
context.coordinator.didEndEditing = didEndEditing
context.coordinator.focusNextView = focusNextView
@ -95,6 +98,7 @@ struct ComposeEmojiTextField: UIViewRepresentable {
var text: Binding<String>!
// break retained cycle through ComposeUIState.currentInput
unowned var uiState: ComposeUIState!
var maxLength: Int?
var didChange: ((String) -> Void)?
var didEndEditing: (() -> Void)?
var focusNextView: Binding<Bool>?
@ -114,6 +118,14 @@ struct ComposeEmojiTextField: UIViewRepresentable {
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) {
uiState.currentInput = self
updateAutocompleteState(textField: textField)

View File

@ -20,6 +20,7 @@ struct ComposePollView: View {
@ObservedObject var draft: Draft
@ObservedObject var poll: Draft.Poll
@EnvironmentObject var mastodonController: MastodonController
@Environment(\.colorScheme) var colorScheme: ColorScheme
@State private var duration: Duration
@ -31,6 +32,14 @@ struct ComposePollView: View {
self._duration = State(initialValue: .fromTimeInterval(poll.duration) ?? .oneDay)
}
private var canAddOption: Bool {
if let pollConfig = mastodonController.instance?.pollsConfiguration {
return poll.options.count < pollConfig.maxOptions
} else {
return true
}
}
var body: some View {
VStack {
HStack {
@ -67,9 +76,15 @@ struct ComposePollView: View {
.frame(height: 44 * CGFloat(poll.options.count))
Button(action: self.addOption) {
Label("Add Option", systemImage: "plus")
Label {
Text("Add Option")
} icon: {
Image(systemName: "plus")
.foregroundColor(.accentColor)
}
}
.buttonStyle(.borderless)
.disabled(!canAddOption)
HStack {
MenuPicker(selection: $poll.multiple, options: [
@ -96,7 +111,7 @@ struct ComposePollView: View {
private var backgroundColor: Color {
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
colorScheme == .dark ? Color(UIColor.secondarySystemBackground) : Color(white: 0.95)
colorScheme == .dark ? Color.appFill : Color(white: 0.95)
}
private var buttonBackgroundColor: Color {
@ -155,6 +170,8 @@ struct ComposePollOption: View {
@ObservedObject var option: Draft.Poll.Option
let optionIndex: Int
@EnvironmentObject private var mastodonController: MastodonController
var body: some View {
HStack(spacing: 4) {
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, borderWidth: 2)
@ -173,8 +190,8 @@ struct ComposePollOption: View {
}
private var textField: some View {
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)")
return field.backgroundColor(.systemBackground)
var field = ComposeEmojiTextField(text: $option.text, placeholder: "Option \(optionIndex + 1)", maxLength: mastodonController.instance?.pollsConfiguration?.maxCharactersPerOption)
return field.backgroundColor(.appBackground)
}
private func removeOption() {
@ -199,7 +216,7 @@ struct ComposePollOption: View {
.cornerRadius(radiusFraction * size)
Rectangle()
.foregroundColor(Color(UIColor.systemBackground))
.foregroundColor(Color(UIColor.appBackground))
.frame(width: innerSize, height: innerSize)
.cornerRadius(radiusFraction * innerSize)
}

View File

@ -54,6 +54,7 @@ struct ComposeToolbar: View {
.font(.system(size: imageSize))
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
if let currentInput = uiState.currentInput,
@ -74,16 +75,11 @@ struct ComposeToolbar: View {
.accessibilityLabel(format.accessibilityLabel)
.padding(5)
.hoverEffect()
.transition(.opacity.animation(.linear(duration: 0.2)))
}
}
Spacer()
Button(action: self.draftsButtonPressed) {
Text("Drafts")
}
.padding(5)
.hoverEffect()
}
.padding(.horizontal, 16)
.frame(minWidth: minWidth)
@ -119,10 +115,6 @@ struct ComposeToolbar: View {
uiState.currentInput?.beginAutocompletingEmoji()
}
private func draftsButtonPressed() {
uiState.isShowingDraftsList = true
}
private func formatAction(_ format: StatusFormat) -> () -> Void {
{
uiState.currentInput?.applyFormat(format)

View File

@ -94,6 +94,10 @@ struct ComposeView: View {
var body: some View {
ZStack(alignment: .top) {
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
Color.appBackground
.edgesIgnoringSafeArea(.all)
mainList
.scrollDismissesKeyboardInteractivelyIfAvailable()
@ -124,7 +128,7 @@ struct ComposeView: View {
}
})
.sheet(isPresented: $uiState.isShowingDraftsList) {
DraftsView(currentDraft: draft, mastodonController: mastodonController)
DraftsRepresentable(currentDraft: draft, mastodonController: mastodonController)
}
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
.alert(isPresented: $isShowingPostErrorAlert) {
@ -169,11 +173,13 @@ struct ComposeView: View {
)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
}
header
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
if uiState.draft.contentWarningEnabled {
ComposeEmojiTextField(
@ -184,6 +190,7 @@ struct ComposeView: View {
)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
}
MainComposeTextView(
@ -192,17 +199,20 @@ struct ComposeView: View {
)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
if let poll = draft.poll {
ComposePollView(draft: draft, poll: poll)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
}
ComposeAttachmentsList(
draft: draft
)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
.listRowBackground(Color.appBackground)
}
.animation(.default, value: draft.poll?.options.count)
.scrollDismissesKeyboardInteractivelyIfAvailable()
@ -239,16 +249,25 @@ struct ComposeView: View {
}
}
@ViewBuilder
private var postButton: some View {
Button {
Task {
await self.postStatus()
if draft.hasContent {
Button {
Task {
await self.postStatus()
}
} label: {
Text("Post")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!postButtonEnabled)
} else {
Button {
uiState.isShowingDraftsList = true
} label: {
Text("Drafts")
}
} label: {
Text("Post")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!postButtonEnabled)
}
private func cancel() {
@ -310,7 +329,7 @@ struct ComposeView: View {
}
}
private extension View {
extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {

View File

@ -8,6 +8,21 @@
import SwiftUI
@available(iOS, obsoleted: 16.0)
struct DraftsRepresentable: UIViewControllerRepresentable {
typealias UIViewControllerType = UIHostingController<DraftsView>
let currentDraft: Draft
let mastodonController: MastodonController
func makeUIViewController(context: Context) -> UIHostingController<DraftsView> {
return UIHostingController(rootView: DraftsView(currentDraft: currentDraft, mastodonController: mastodonController))
}
func updateUIViewController(_ uiViewController: UIHostingController<DraftsView>, context: Context) {
}
}
struct DraftsView: View {
let currentDraft: Draft
// don't pass this in via the environment b/c it crashes on macOS (at least, in Designed for iPad mode) since the environment doesn't get propagated through the modal popup window or something
@ -49,8 +64,10 @@ struct DraftsView: View {
.map { visibleDrafts[$0] }
.forEach { draftsManager.remove($0) }
}
.appGroupedListRowBackground()
}
.listStyle(.plain)
.appGroupedListBackground(container: DraftsRepresentable.UIViewControllerType.self)
.navigationTitle(Text("Drafts"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View File

@ -9,39 +9,20 @@
import SwiftUI
import Pachyderm
struct MainComposeTextView: View {
struct MainComposeTextView: View, PlaceholderViewProvider {
@ObservedObject var draft: Draft
@State private var placeholder: Text = {
let components = Calendar.current.dateComponents([.month, .day], from: Date())
if components.month == 3 && components.day == 14 {
if Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
return Text("Happy π day!")
}
} 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
return 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 {
return Text("Do you remember?")
} else if components.month == 10 && components.day == 31 {
if .random() {
return Text("Post something spooky!")
} else {
return Text("Any questions?")
}
}
return Text("What's on your mind?")
}()
@State private var placeholder: PlaceholderView = Self.placeholderView()
let minHeight: CGFloat = 150
@State private var height: CGFloat?
@Binding var becomeFirstResponder: Bool
@State private var hasFirstAppeared = false
@ScaledMetric private var fontSize = 20
@Environment(\.colorScheme) private var colorScheme
var body: some View {
ZStack(alignment: .topLeading) {
Color(UIColor.secondarySystemBackground)
colorScheme == .dark ? Color.appFill : Color(uiColor: .secondarySystemBackground)
if draft.text.isEmpty {
placeholder
@ -67,6 +48,38 @@ struct MainComposeTextView: View {
}
}
}
@ViewBuilder
static func placeholderView() -> 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?")
}
}
}
// 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 placeholderView() -> PlaceholderView
}
struct MainComposeWrappedTextView: UIViewRepresentable {
@ -98,6 +111,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
if context.coordinator.skipSettingTextOnNextUpdate {
context.coordinator.skipSettingTextOnNextUpdate = false
} else {
context.coordinator.skipNextAutocompleteUpdate = true
uiView.text = text
}
@ -185,6 +199,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
var caretScrollPositionAnimator: UIViewPropertyAnimator?
var skipSettingTextOnNextUpdate = false
var skipNextAutocompleteUpdate = false
var toolbarElements: [ComposeUIState.ToolbarElement] {
[.emojiPicker, .formattingButtons]
@ -324,6 +339,10 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
}
private func updateAutocompleteState() {
guard !skipNextAutocompleteUpdate else {
skipNextAutocompleteUpdate = false
return
}
guard let textView = textView,
let text = textView.text,
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {

View File

@ -9,18 +9,9 @@
import UIKit
import Pachyderm
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
class ConversationCollectionViewController: UIViewController, CollectionViewController {
class ConversationCollectionViewController: UIViewController, CollectionViewController, RefreshableViewController {
private unowned let conversationViewController: ConversationViewController
private let mastodonController: MastodonController
private let mainStatusID: String
private let mainStatusState: CollapseState
@ -32,11 +23,12 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) {
init(for mainStatusID: String, state: CollapseState, conversationViewController: ConversationViewController) {
self.mainStatusID = mainStatusID
self.mainStatusState = state
self.statusIDToScrollToOnLoad = mainStatusID
self.mastodonController = mastodonController
self.conversationViewController = conversationViewController
self.mastodonController = conversationViewController.mastodonController
super.init(nibName: nil, bundle: nil)
}
@ -47,7 +39,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .secondarySystemBackground
config.backgroundColor = .appSecondaryBackground
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
@ -55,17 +47,21 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
let rowsInSection = self.collectionView.numberOfItems(inSection: indexPath.section)
let lastInSection = indexPath.row == rowsInSection - 1
var config = sectionConfig
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = lastInSection ? .visible : .hidden
if case .ancestors = self.dataSource.sectionIdentifier(for: indexPath.section) {
config.bottomSeparatorVisibility = .hidden
} else if indexPath.row == self.collectionView.numberOfItems(inSection: indexPath.section) - 1 {
config.bottomSeparatorVisibility = .visible
} else {
config.bottomSeparatorVisibility = .hidden
}
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
return config
}
// we're not using contenetInsetsReference = .readableContent here because it always insets the cells even if
// the collection view's actual width is narrow enough to fit in the readable width, resulting in a bit of the
// background color always peaking through the edges
// background color always peeking through the edges
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
// something about the autoresizing mask breaks resizing the vc
@ -74,6 +70,11 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
collectionView.dragDelegate = self
collectionView.allowsFocus = true
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
dataSource = createDataSource()
}
@ -99,7 +100,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case let .status(id: id, state: state, prevLink: prevLink, nextLink: nextLink):
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
if id == self.mainStatusID {
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink))
} else {
@ -123,45 +124,33 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
loadViewIfNeeded()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendSections([.ancestors, .mainStatus])
if status.inReplyToID != nil {
snapshot.appendItems([.loadingIndicator], toSection: .statuses)
snapshot.appendItems([.loadingIndicator], toSection: .ancestors)
}
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
snapshot.appendItems([mainStatusItem], toSection: .statuses)
// this will be replace with the actual node in the tree once it's loaded
let tempMainNode = ConversationNode(status: status)
let mainStatusItem = Item.status(id: mainStatusID, node: tempMainNode, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
snapshot.appendItems([mainStatusItem], toSection: .mainStatus)
dataSource.apply(snapshot, animatingDifferences: false)
}
func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async {
let parentIDs = getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors)
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator])
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
let parentItems = parentIDs.enumerated().map { index, id in
Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true)
func addTree(_ tree: ConversationTree, mainStatus: StatusMO) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.ancestors, .mainStatus])
let mainStatusItem = Item.status(id: mainStatusID, node: tree.mainStatus, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
snapshot.appendItems([mainStatusItem], toSection: .mainStatus)
let parentItems = tree.ancestors.enumerated().map { index, node in
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
}
snapshot.insertItems(parentItems, beforeItem: mainStatusItem)
snapshot.appendItems(parentItems, toSection: .ancestors)
snapshot.reloadItems([mainStatusItem])
// fetch all descendant status managed objects
let descendantIDs = context.descendants.map(\.id)
let request = StatusMO.fetchRequest()
request.predicate = NSPredicate(format: "id IN %@", descendantIDs)
if let descendants = try? mastodonController.persistentContainer.viewContext.fetch(request) {
// convert array of descendant statuses into tree of sub-threads
let childThreads = self.getDescendantThreads(mainStatus: mainStatus, descendants: descendants)
// convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot)
}
// convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)
self.dataSource.apply(snapshot, animatingDifferences: false) {
let item: Item
@ -171,7 +160,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
position = .centeredVertically
} else {
item = snapshot.itemIdentifiers.first {
if case .status(id: self.statusIDToScrollToOnLoad, _, _, _) = $0 {
if case .status(id: self.statusIDToScrollToOnLoad, _, _, _, _) = $0 {
return true
} else {
return false
@ -187,54 +176,6 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
}
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
var statuses = statuses
var parents = [String]()
var parentID: String? = inReplyToID
while parentID != nil, let parentIndex = statuses.firstIndex(where: { $0.id == parentID }) {
let parentStatus = statuses.remove(at: parentIndex)
parents.insert(parentStatus.id, at: 0)
parentID = parentStatus.inReplyToID
}
return parents
}
private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] {
var descendants = descendants
func removeAllInReplyTo(id: String) -> [StatusMO] {
let statuses = descendants.filter { $0.inReplyToID == id }
descendants.removeAll { $0.inReplyToID == id }
return statuses
}
var nodes: [String: ConversationNode] = [
mainStatus.id: ConversationNode(status: mainStatus)
]
var idsToCheck = [mainStatusID]
while !idsToCheck.isEmpty {
let inReplyToID = idsToCheck.removeFirst()
let nodeForID = nodes[inReplyToID]!
let inReply = removeAllInReplyTo(id: inReplyToID)
for reply in inReply {
idsToCheck.append(reply.id)
let replyNode = ConversationNode(status: reply)
nodes[reply.id] = replyNode
nodeForID.children.append(replyNode)
}
}
return nodes[mainStatusID]!.children
}
private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
var childThreads = childThreads
@ -248,7 +189,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
for node in childThreads {
let section = Section.childThread(firstStatusID: node.status.id)
snapshot.appendSections([section])
snapshot.appendItems([.status(id: node.status.id, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
snapshot.appendItems([.status(id: node.status.id, node: node, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
var currentNode = node
while true {
@ -271,7 +212,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
currentNode = next
snapshot.appendItems([.status(id: next.status.id, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
snapshot.appendItems([.status(id: next.status.id, node: next, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
}
}
}
@ -280,7 +221,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
var snapshot = dataSource.snapshot()
var cellsToMask: [StatusCollectionViewCell] = []
for item in snapshot.itemIdentifiers {
guard case .status(id: _, state: let state, prevLink: _, nextLink: _) = item,
guard case .status(id: _, node: _, state: let state, prevLink: _, nextLink: _) = item,
state.collapsible == true else {
continue
}
@ -307,21 +248,31 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
}
}
@objc func refresh() {
Task {
await conversationViewController.refreshContext()
#if !targetEnvironment(macCatalyst)
self.collectionView.refreshControl!.endRefreshing()
#endif
}
}
}
extension ConversationCollectionViewController {
enum Section: Hashable {
case statuses
case ancestors
case mainStatus
case childThread(firstStatusID: String)
}
enum Item: Hashable {
case status(id: String, state: CollapseState, prevLink: Bool, nextLink: Bool)
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
case expandThread(childThreads: [ConversationNode], inline: Bool)
case loadingIndicator
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(id: a, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, state: _, prevLink: bPrev, nextLink: bNext)):
case let (.status(id: a, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, state: _, prevLink: bPrev, nextLink: bNext)):
return a == b && aPrev == bPrev && aNext == bNext
case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)):
return a.count == b.count && zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
@ -334,7 +285,7 @@ extension ConversationCollectionViewController {
func hash(into hasher: inout Hasher) {
switch self {
case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink):
case let .status(id: id, node: _, state: _, prevLink: prevLink, nextLink: nextLink):
hasher.combine(0)
hasher.combine(id)
hasher.combine(prevLink)
@ -355,7 +306,7 @@ extension ConversationCollectionViewController {
extension ConversationCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
switch dataSource.itemIdentifier(for: indexPath) {
case .status(id: let id, state: _, prevLink: _, nextLink: _):
case .status(id: let id, node: _, state: _, prevLink: _, nextLink: _):
return id != mainStatusID
case .expandThread(childThreads: _, inline: _):
return true
@ -370,12 +321,25 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
break
case .loadingIndicator:
break
case .status(id: let id, state: let state, _, _):
selected(status: id, state: state.copy())
case .status(id: let id, node: let node, state: let state, _, _):
// we can only take the fast path if the user tapped on a descendant status.
// if the current main status is C, or one of its descendants, and the user taps A, then B won't be loaded:
// A
// / \
// B C
if case .childThread(_) = dataSource.sectionIdentifier(for: indexPath.section) {
let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPath), mainStatus: node)
let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController)
conv.showStatusesAutomatically = showStatusesAutomatically
show(conv)
} else {
selected(status: id, state: state.copy())
}
case .expandThread(childThreads: let childThreads, inline: _):
if case .status(id: let id, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
// todo: it would be nice to avoid re-fetching the context here, since we should have all the necessary information already
let conv = ConversationViewController(for: id, state: state.copy(), mastodonController: mastodonController)
let indexPathBeforeExpandThread = IndexPath(row: indexPath.row - 1, section: indexPath.section)
if case .status(id: _, node: let node, state: let state, _, _) = dataSource.itemIdentifier(for: indexPathBeforeExpandThread) {
let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPathBeforeExpandThread), mainStatus: node)
let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController)
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
conv.showStatusesAutomatically = showStatusesAutomatically
show(conv)
@ -383,6 +347,34 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
}
}
// ConversationNode doesn't know about its parent, so we reconstruct that info from the data source
private func buildNewAncestors(above indexPath: IndexPath) -> [ConversationNode] {
let snapshot = dataSource.snapshot()
let currentAncestors = snapshot.itemIdentifiers(inSection: .ancestors).compactMap {
if case .status(id: _, node: let node, _, _, _) = $0 {
return node
} else {
return nil
}
}
let currentMainStatus = snapshot.itemIdentifiers(inSection: .mainStatus).compactMap {
if case .status(id: _, node: let node, _, _, _) = $0 {
return node
} else {
return nil
}
}
let parentsInCurrentSection = snapshot.itemIdentifiers(inSection: dataSource.sectionIdentifier(for: indexPath.section)!)[0..<indexPath.row].compactMap {
if case .status(id: _, node: let node, _, _, _) = $0 {
return node
} else {
return nil
}
}
return currentAncestors + currentMainStatus + parentsInCurrentSection
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
}

View File

@ -0,0 +1,101 @@
//
// ConversationTree.swift
// Tusker
//
// Created by Shadowfacts on 2/4/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
struct ConversationTree {
let ancestors: [ConversationNode]
let mainStatus: ConversationNode
var descendants: [ConversationNode] {
mainStatus.children
}
init(ancestors: [ConversationNode], mainStatus: ConversationNode) {
self.ancestors = ancestors
self.mainStatus = mainStatus
}
static func build(for mainStatus: StatusMO, ancestors: [StatusMO], descendants: [StatusMO]) -> ConversationTree {
let mainStatusNode = ConversationNode(status: mainStatus)
let ancestors = buildAncestorNodes(mainStatusNode: mainStatusNode, ancestors: ancestors)
buildDescendantNodes(mainStatusNode: mainStatusNode, descendants: descendants)
return ConversationTree(ancestors: ancestors, mainStatus: mainStatusNode)
}
private static func buildAncestorNodes(mainStatusNode: ConversationNode, ancestors: [StatusMO]) -> [ConversationNode] {
var statuses = ancestors
var parents = [ConversationNode]()
var parentID: String? = mainStatusNode.status.inReplyToID
while let currentParentID = parentID,
let parentIndex = statuses.firstIndex(where: { $0.id == currentParentID }) {
let parentStatus = statuses.remove(at: parentIndex)
let node = ConversationNode(status: parentStatus)
parents.insert(node, at: 0)
parentID = parentStatus.inReplyToID
}
// once the parents list is built and in-order, then we walk through and set each node's children
for (index, node) in parents.enumerated() {
if index == parents.count - 1 {
// the last parent is the direct parent of the main status
node.children = [mainStatusNode]
} else {
// otherwise, it's the parent of the status that comes immediately after it in the parents list
node.children = [parents[index + 1]]
}
}
return parents
}
// doesn't return anything, since we're modifying the main status node in-place
private static func buildDescendantNodes(mainStatusNode: ConversationNode, descendants: [StatusMO]) {
var descendants = descendants
func removeAllInReplyTo(id: String) -> [StatusMO] {
let statuses = descendants.filter { $0.inReplyToID == id }
descendants.removeAll { $0.inReplyToID == id }
return statuses
}
var nodes: [String: ConversationNode] = [
mainStatusNode.status.id: mainStatusNode
]
var idsToCheck = [mainStatusNode.status.id]
while !idsToCheck.isEmpty {
let inReplyToID = idsToCheck.removeFirst()
let nodeForID = nodes[inReplyToID]!
let inReply = removeAllInReplyTo(id: inReplyToID)
for reply in inReply {
idsToCheck.append(reply.id)
let replyNode = ConversationNode(status: reply)
nodes[reply.id] = replyNode
nodeForID.children.append(replyNode)
}
}
}
}

View File

@ -77,6 +77,14 @@ class ConversationViewController: UIViewController {
super.init(nibName: nil, bundle: nil)
}
init(preloadedTree: ConversationTree, state mainStatusState: CollapseState, mastodonController: MastodonController) {
self.mode = .preloaded(preloadedTree)
self.mainStatusState = mainStatusState
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@ -86,7 +94,7 @@ class ConversationViewController: UIViewController {
title = NSLocalizedString("Conversation", comment: "conversation screen title")
view.backgroundColor = .secondarySystemBackground
view.backgroundColor = .appSecondaryBackground
collapseBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "eye.fill")!, style: .plain, target: self, action: #selector(toggleCollapseButtonPressed))
updateVisibilityBarButtonItem()
@ -115,9 +123,15 @@ class ConversationViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
if case .unloaded = state {
await loadMainStatus()
if case .unloaded = state {
if case .preloaded(let tree) = mode {
// when everything is preloaded, we're on the fast path and want to avoid any async work
// just kicking off a MainActor task causes a delay before the content appears, even if the task doesn't suspend
mainStatusLoaded(tree.mainStatus.status)
} else {
Task { @MainActor in
await loadMainStatus()
}
}
}
}
@ -142,9 +156,20 @@ class ConversationViewController: UIViewController {
// MARK: Loading
@MainActor
private func loadMainStatus() async {
guard let mainStatusID = await resolveStatusIfNecessary() else {
return
let mainStatusID: String
switch mode {
case .localID(let id):
mainStatusID = id
case .resolve(let url):
if let id = await resolveStatus(url: url) {
mainStatusID = id
} else {
return
}
case .preloaded(_):
fatalError("unreachable")
}
@MainActor
@ -166,7 +191,7 @@ class ConversationViewController: UIViewController {
Task {
await doLoadMainStatus()
}
await mainStatusLoaded(cached)
mainStatusLoaded(cached)
} else {
// otherwise, show a loading indicator while loading the main status
let indicator = UIActivityIndicatorView(style: .medium)
@ -174,77 +199,110 @@ class ConversationViewController: UIViewController {
state = .loading(indicator)
if let status = await doLoadMainStatus() {
await mainStatusLoaded(status)
mainStatusLoaded(status)
}
}
}
@MainActor
private func resolveStatusIfNecessary() async -> String? {
switch mode {
case .localID(let id):
return id
case .resolve(let url):
let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating()
state = .loading(indicator)
private func resolveStatus(url: URL) async -> String? {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.startAnimating()
state = .loading(indicator)
let url = WebURL(url)!
let request = Client.search(query: url.serialized(), types: [.statuses], resolve: true)
do {
let (results, _) = try await mastodonController.run(request)
guard let status = results.statuses.first(where: { $0.url == url }) else {
throw UnableToResolveError()
}
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
mode = .localID(status.id)
return status.id
} catch {
state = .unableToResolve(error)
return nil
let url = WebURL(url)!.serialized(excludingFragment: true)
let request = Client.search(query: url, types: [.statuses], resolve: true)
do {
let (results, _) = try await mastodonController.run(request)
guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else {
throw UnableToResolveError()
}
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
mode = .localID(status.id)
return status.id
} catch {
state = .unableToResolve(error)
return nil
}
}
@MainActor
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController)
private func mainStatusLoaded(_ mainStatus: StatusMO) {
if let accountID = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showConversationActivity(mainStatusID: mainStatus.id, accountID: accountID)
}
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, conversationViewController: self)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
vc.showStatusesAutomatically = showStatusesAutomatically
vc.addMainStatus(mainStatus)
state = .displaying(vc)
await loadContext(for: mainStatus)
if case .preloaded(let tree) = mode {
vc.addTree(tree, mainStatus: mainStatus)
} else {
Task { @MainActor in
await loadTree(for: mainStatus)
}
}
}
@MainActor
private func loadContext(for mainStatus: StatusMO) async {
guard case .displaying(_) = state else {
private func loadTree(for mainStatus: StatusMO) async {
guard case .displaying(_) = state,
let context = await loadContext(for: mainStatus) else {
return
}
await mastodonController.persistentContainer.addAll(statuses: context.ancestors + context.descendants)
let ancestorIDs = context.ancestors.map(\.id)
let ancestorsReq = StatusMO.fetchRequest()
ancestorsReq.predicate = NSPredicate(format: "id in %@", ancestorIDs)
let ancestors = try? mastodonController.persistentContainer.viewContext.fetch(ancestorsReq)
let descendantIDs = context.descendants.map(\.id)
let descendantsReq = StatusMO.fetchRequest()
descendantsReq.predicate = NSPredicate(format: "id IN %@", descendantIDs)
let descendants = try? mastodonController.persistentContainer.viewContext.fetch(descendantsReq)
let tree = ConversationTree.build(for: mainStatus, ancestors: ancestors ?? [], descendants: descendants ?? [])
guard case .displaying(let vc) = state else {
return
}
vc.addTree(tree, mainStatus: mainStatus)
}
private func loadContext(for mainStatus: StatusMO) async -> ConversationContext? {
let request = Status.getContext(mainStatus.id)
do {
let (context, _) = try await mastodonController.run(request)
guard case .displaying(let vc) = state else {
return
}
await vc.addContext(context, for: mainStatus)
return context
} catch {
guard case .displaying(_) = state else {
return
return nil
}
let error = error as! Client.Error
let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in
toast.dismissToast(animated: true)
await self?.loadContext(for: mainStatus)
await self?.loadTree(for: mainStatus)
}
self.showToast(configuration: config, animated: true)
return nil
}
}
func refreshContext() async {
guard case .localID(let id) = mode,
let status = mastodonController.persistentContainer.status(for: id),
case .displaying(_) = state else {
return
}
await loadTree(for: status)
}
private func showMainStatusNotFound() {
let notFoundView = StatusNotFoundView(frame: .zero)
notFoundView.translatesAutoresizingMaskIntoConstraints = false
@ -341,6 +399,7 @@ extension ConversationViewController {
enum Mode {
case localID(String)
case resolve(URL)
case preloaded(ConversationTree)
}
}
@ -366,6 +425,17 @@ extension ConversationViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension ConversationViewController: StateRestorableViewController {
func stateRestorationActivity() -> NSUserActivity? {
if let accountID = mastodonController.accountInfo?.id,
case .localID(let id) = mode {
return UserActivityManager.showConversationActivity(mainStatusID: id, accountID: accountID)
} else {
return nil
}
}
}
extension ConversationViewController: ToastableViewController {
var toastScrollView: UIScrollView? {
if case .displaying(let vc) = state {

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