Compare commits

..

84 Commits

Author SHA1 Message Date
Shadowfacts 5a4323067a Bump build number and update changelog 2023-12-22 11:02:29 -05:00
Shadowfacts 43d8434e17 Fix crash due to Explore data source being update off main thread when list deleted 2023-12-22 10:39:24 -05:00
Shadowfacts e8576277e0 Bump build number and update changelog 2023-12-17 18:16:47 -05:00
Shadowfacts 7f0a9d8d5a Fix status that is reblogged and contains a followed hashtag not showing reblogger label 2023-12-17 18:09:25 -05:00
Shadowfacts 51f4a780e2 Show loading indicator while translating status 2023-12-16 16:14:18 -05:00
Shadowfacts 180a8eb18d Fix Status reblogs inverse relationship being to-one instead of to-many 2023-12-14 21:57:44 -05:00
Shadowfacts eb61043867 Fix timeline state restoration not returning to correct scroll position in certain circumstances
All of the work done by the restoreInitial callback needs to be async,
so that when the TimelineLikeController signals that the loading
indicator should be removed, the collection view is in the right place.

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

Fixes #452
2023-12-05 22:00:48 -05:00
Shadowfacts 28c1a9092b Add server-provided translation
Closes #331
2023-12-04 19:31:51 -05:00
Shadowfacts 5e609aa40d V2 instance API, add translation to InstanceFeatures 2023-12-04 17:55:03 -05:00
Shadowfacts 158940f8e6 Refactor StatusContentContainer to use an array of subviews 2023-12-04 17:06:10 -05:00
Shadowfacts 141e8b96a5 Show label when attachments are hidden in timelines 2023-12-04 16:38:04 -05:00
Shadowfacts 108a02826f Remove incorrect workaround for crash when LazilyDecoding used on nil MO 2023-12-04 16:20:22 -05:00
Shadowfacts be1ca70ebf Add preference for showing attachments in timeline
Closes #330
2023-12-04 16:18:54 -05:00
Shadowfacts 34edd8a13f Fix reblogged statuses being pruned while still referenced, add workaround for crash 2023-12-03 15:08:38 -05:00
Shadowfacts 23f383a7f9 Get rid of network request during share extension launch
Closes #438
2023-12-02 15:33:15 -05:00
Shadowfacts 99caaa0f28 Bump version and update changelog 2023-11-29 18:05:58 -05:00
Shadowfacts 0f70c9059e Fix error decoding certain statuses on pixelfed 2023-11-19 22:52:58 -05:00
Shadowfacts 6d7074e71d Tweak profile header separator 2023-11-19 21:22:00 -05:00
Shadowfacts 13809b91d1 Fix crash if window removed while fast account switcher is hiding 2023-11-18 11:36:59 -05:00
Shadowfacts 16f6dc84c9 Update Sentry package 2023-11-18 11:15:47 -05:00
Shadowfacts cdfb06f4a7 Render IDN domains in for logged-in accounts 2023-11-18 11:08:35 -05:00
Shadowfacts 4e98e569eb Fix avatars in follow request notification not being rounded
Closes #448
2023-11-18 11:00:19 -05:00
Shadowfacts 6d3ffd7dd3 Style blockquote appropriately
Closes #22
2023-11-18 10:56:05 -05:00
Shadowfacts ca7fe74a90 Add accessibility description/action to status edit history entry 2023-11-10 14:48:48 -05:00
Shadowfacts 380f878d81 Use server language preference for default search token suggestion 2023-11-10 14:42:48 -05:00
Shadowfacts 1c36312850 Fix status deletions not being handled properly in logged-out views 2023-11-10 14:35:36 -05:00
Shadowfacts de946be008 Fix crash if ContentTextView asked for context menu config w/o mastodon controller 2023-11-10 14:20:33 -05:00
Shadowfacts b40d815274 Ensure LazilyDecoding runs on the managed object context's thread
Maybe fix the crash in KeyPath machinery?
2023-11-10 14:16:16 -05:00
Shadowfacts bc7500bde9 Fix crash when uploading attachment without known MIME type or extension 2023-11-10 14:08:11 -05:00
Shadowfacts 676e603ffc Fix crash when showing trending hashtag with less than two days of history 2023-11-10 14:04:11 -05:00
Shadowfacts cb47443649 Bump version and update changelog 2023-11-07 22:16:48 -05:00
Shadowfacts 86862825f6 Assert that the compose draft belongs to the view context 2023-11-05 18:32:05 -05:00
Shadowfacts e6f1968609 Fix TimelineLikeCollectionViewController.apply not actually applying snapshots on the main thread 2023-11-05 18:22:20 -05:00
Shadowfacts 4c5da1b5a9 Add URL handler for opening Compose window 2023-11-05 15:24:55 -05:00
Shadowfacts e57ef210fd Fix language picker button not having a pointer effect 2023-11-05 11:32:49 -05:00
Shadowfacts dcdfe853e1 Fix Cmd+W closing sometimes closing non-foreground window on macOS
Closes #444
2023-11-05 11:14:58 -05:00
Shadowfacts 34e57c297b Tweak HEIF/HEIC handling 2023-11-03 11:07:43 -04:00
Shadowfacts 6c2c2e6ae7 More logging to try and pin down LazilyDecoding EXC_BAD_ACCESS 2023-11-02 18:18:08 -04:00
Shadowfacts aae3bd0bba Remove dead code 2023-11-02 17:53:26 -04:00
Shadowfacts 2b5d4681e3 Prevent mul/und from being used as language
Closes #440
2023-11-02 10:44:52 -04:00
Shadowfacts e4eff2d362 Bump version and update changelog 2023-10-28 14:14:02 -05:00
Shadowfacts 37311e5f17 Fix potential crash due to race condition in timeline gap filling 2023-10-28 14:03:08 -05:00
Shadowfacts af5a0b7bbd Fix crash with large image dismiss gesture 2023-10-28 13:58:39 -05:00
Shadowfacts 3aa45cb365 Maybe fix crash due to reading ScaledMetric on background thread
ScaledMetric.wrappedValue calls into Font.scaleFactor(textStyle:in:)
which uses a dictionary setter
2023-10-28 13:56:25 -05:00
Shadowfacts a07b398cbe Maybe fix crash due to VC hierarchy consistency check failing on split collapse/expand 2023-10-28 13:52:54 -05:00
Shadowfacts 2ccec2f4df Fix crash if URLComponents.url is nil in instance selector 2023-10-28 13:47:44 -05:00
Shadowfacts 0de9a9fd37 Fix list timeline refresh failing if initial load returned no statuses 2023-10-28 13:36:11 -05:00
Shadowfacts bd21e88e8b Add UI for changing list reply policy and exclusivity
Closes #428
2023-10-28 12:16:14 -05:00
Shadowfacts 2464e2530f Remove dead code 2023-10-27 17:29:51 -05:00
Shadowfacts 44021d3ad2 Convert edit list screen to collection view 2023-10-27 17:29:51 -05:00
Shadowfacts a46eaafbcf Add reply policy and exclusive fields to lists 2023-10-27 17:00:53 -05:00
Shadowfacts eb496243c7 Use server preference for local-only on Hometown
Closes #281
2023-10-27 15:12:48 -05:00
Shadowfacts 6e5e0c3bb5 Use server preferences for default visibility and language
Closes #282
2023-10-27 14:59:21 -05:00
Shadowfacts dfc8234908 Attribute authenticated API requests to the user
Closes #134
2023-10-26 17:30:31 -05:00
Shadowfacts 157c8629a9 Add underline links preference
Closes #397
2023-10-24 16:02:03 -04:00
Shadowfacts bde21fbc6c Fix crash due to prematurely pruned statuses being fetched
If the app hasn't launched in long enough, we may be displaying old statuses as a result of state restoration. If the user leaves the app, those statuses can't get pruned, because the user may return. We need to make sure the lastFetchedAt date is current, since awakeFromFetch won't be called until the object is faulted in (which wasn't happening immediately during state restoration).
2023-10-24 15:50:58 -04:00
Shadowfacts 74820e8922 Underline links when button shapes accessibility setting is on 2023-10-24 15:50:58 -04:00
Shadowfacts f7a9075b77 Fix timeline jump button having background when button shapes accessibility setting is on 2023-10-24 15:50:58 -04:00
Shadowfacts 4af56e48bf Clean up TimelineLikeCollectionViewController.apply(_:animatingDifferences:) 2023-10-24 14:56:39 -04:00
Shadowfacts c4bf5d406d Fix older notifications not loading when initially visible set fits on one screen
Closes #346
2023-10-19 21:21:50 -04:00
Shadowfacts 53d43b5707 Update changelog 2023-10-01 22:14:26 -04:00
Shadowfacts b1564d822e Bump version and move to xcconfig to fix warnings 2023-10-01 22:14:01 -04:00
Shadowfacts a8a2f0a26c Add search operators UI on Mastodon 4.2
Closes #433
2023-10-01 21:40:53 -04:00
Shadowfacts 46e1205327 Fix delay before My Profile sidebar item appears on launch 2023-10-01 10:20:45 -04:00
Shadowfacts 6a2de2be55 Make suggested profile cells uniform height on trends screen 2023-10-01 10:15:00 -04:00
Shadowfacts db6ba0c62c Remove navigation mode preference feature flag 2023-10-01 00:14:20 -04:00
Shadowfacts 16029dc161 Fix Appearance > Interface prefs using wrong row background color 2023-10-01 00:12:01 -04:00
Shadowfacts 31a0db014a Improve multi-column layout for suggested profiles 2023-10-01 00:08:00 -04:00
Shadowfacts 5be8005e24 Use two columns for trending links/accounts on wide screens 2023-09-29 17:33:18 -04:00
Shadowfacts ad4e112e96 Fix switching back to previous navigation mode 2023-09-29 17:18:29 -04:00
Shadowfacts 7a2dc7d3c4 Improve readable-width content inset behavior 2023-09-28 21:30:30 -04:00
Shadowfacts 0948371f83 Improve appearance of lists when converting from HTML
Closes #434
2023-09-27 17:35:36 -04:00
Shadowfacts 3ba1a00257 Reconfigure visible updates when refreshing
Closes #300
2023-09-26 09:42:39 -04:00
Shadowfacts 1b42cd7816 Fix cell reuse bug with follow/action notifications 2023-09-26 09:18:01 -04:00
Shadowfacts a2fe0dfb78 Avoid unnecessarily recreating avatar views in notifications cells 2023-09-25 21:44:43 -04:00
Shadowfacts bf1ed57180 Allow authoring local-only posts on Akkoma
Closes #332
2023-09-25 21:23:28 -04:00
Shadowfacts 6821f1b9a0 Don't show doubled "New Post" in window titlebar on macOS
Closes #429
2023-09-24 23:50:08 -04:00
Shadowfacts 7ae741cd83 Fix Live Text control reappearing when swiping between gallery pages with controls hidden
Closes #431
2023-09-24 23:44:40 -04:00
Shadowfacts fe9ad83ddc Fix replies with content warnings showing confirm dialog when unchanged
Closes #430
2023-09-24 23:28:36 -04:00
Shadowfacts 6b7c828cc9 Try to compress videos to fit within instance limits
Closes #425
2023-09-16 14:07:49 -04:00
Shadowfacts 2be1ee19de Improve error message when uploading attachment to Pixelfed fails
See #425
2023-09-16 13:56:46 -04:00
Shadowfacts 3f15a453bd Update to recommended Xcode settings 2023-09-16 13:50:39 -04:00
114 changed files with 2790 additions and 1321 deletions

View File

@ -1,3 +1,48 @@
## 2023.8
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.
Features/Improvements:
- Show search operators on Mastodon 4.2
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
- Allow changing list reply policy and exclusivity options on Edit List screen
- Add Translate action to conversations (on supported Mastodon instances)
- Style block quotes correclty in rich-text posts
- Improve the appearance of lists in rich-text posts
- Add preference to underline links
- Compress uploaded video attachments to fit within instance limits
- Add preference to hide attachments in timelines
- Update visible timestamps after refresh notifications/timelines
- iPadOS: Allow switching between split screen and fullscreen navigation modes
- Pixelfed: Improve error message when uploading attachment fails
- Akkoma: Enable composing local-only posts
Bugfixes:
- Fix older notifications not loading if all initiially-loaded ones are grouped together
- Fix List timelines failing to refresh if they were initially empty
- Fix replies to posts with CWs always showing confirmation dialog when cancelling
- Fix Compose screen permitting setting the language to multiple/undefined
- Fix crash when uploading attachments without file extensions
- Fix Live Text button reappearing with swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- Fix avatars on follow request notifications not being rounded
- Fix timeline jump button appearing incorrectly when Button Shapes acccessibility setting is on
- Fix public instance timeline screen not handling post deletion correctly
- Fix post that's reblogged and contains a followed hashtag not showing the reblogger
- Fix crash on launch when reblogged posts are visible
- Fix crash when showing display names with custom emoji in certain places
- Fix crash when showing trending hashtags without history data
- Fix potential crash on instance selector screen
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix potential crash after deleting List on the Eplore screen
- Pixelfed: Fix error decoding certain posts
- VoiceOver: Fix history entries on Edit History screen not having descriptions
- iPadOS: Fix delay on app launch before "My Profile" sidebar item appears
- iPadOS: Fix language picker button not highlighting when hovered with the cursor
- macOS: Fix "New Post" window title appearing twice
- macOS: Fix Cmd+W sometimes closing non-foreground windows
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.7 ## 2023.7
This update adds support for iOS 17 and includes some minor changes. This update adds support for iOS 17 and includes some minor changes.

View File

@ -1,5 +1,75 @@
# Changelog # Changelog
## 2023.8 (110)
Bugfixes:
- Fix potential crash after deleting List on Explore screen
## 2023.8 (109)
Features/Improvements:
- Add Translate action to conversations (on supported Mastodon instances)
- Improve share extension launch speed
- Add preference for hiding attachments in timelines
Bugfixes:
- Fix crash during state restoration when reblogged statuses are present
- Fix timeline state restoration using incorrect scroll position in certain circumstances
- Fix status that is reblogged and contains a followed hashtag not showing reblogger label
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.8 (107)
Features/Improvements:
- Style blockquotes in statuses
- Use server language preference for search operator suggestions
- Render IDN domains in the account switcher
Bugfixes:
- Fix crash when showing trending hashtags with improper history data
- Fix crash when uploading attachment w/o file extension
- Fix status deletions not being handled properly in logged out views
- Fix status history entries not having VoiceOver descriptions
- Fix avatars in follow request notifications not being rounded
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix error decoding certain statuses on Pixelfed
## 2023.8 (106)
Bugfixes:
- Fix being able to set post language to multiple/undefined
- iPadOS: Fix language picker button not having a pointer effect
- macOS: Fix Cmd+W sometimes closing the non-foreground window
## 2023.8 (105)
Features/Improvements:
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
- Add preference to underline links
- Allow changing list reply policy and exclusivity from menu on Edit List screen
- Attribute network requests to user, rather than developer, when appropriate
Bugfixes:
- Fix older notifications not loading if all initially-loaded are grouped together
- Fix list timelines failing to refresh if there were no statuses initially
- Fix timeline jump button having a background when Button Shapes accessibility setting is on
- Fix crash when relaunching app after not being launched in more than a week
- Fix potential crash on instance selector screen
- Fix crash when showing display names with custom emojis in certain places
## 2023.8 (104)
Features/Improvements:
- Show search operators on Mastodon 4.2
- Enable composing local-only posts on Akkoma
- Update timestamps after refreshing notifications/timelines
- Improve list appearance in rich text posts
- Improve error message when uploading attachment to Pixelfed fails
- Compress uploaded videos to fit within instance limits
- iPad: Allow switching between split screen and full screen navigation
Bugfixes:
- Fix replies to posts with content warnings always showing confirmation dialog before closing
- Fix Live Text control reappearing when swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- iPad: Fix delay on app launch before "My Profile" sidebar item appears
- macOS: Fix "New Post" window title appearing twice
## 2023.7 (103) ## 2023.7 (103)
Features/Improvements: Features/Improvements:
- Add support for iOS 17 - Add support for iOS 17

View File

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

View File

@ -20,7 +20,11 @@ public final class ComposeController: ViewController {
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
public typealias EmojiImageView = (Emoji) -> AnyView public typealias EmojiImageView = (Emoji) -> AnyView
@Published public private(set) var draft: Draft @Published public private(set) var draft: Draft {
didSet {
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
}
}
@Published public var config: ComposeUIConfig @Published public var config: ComposeUIConfig
@Published public var mastodonController: ComposeMastodonContext @Published public var mastodonController: ComposeMastodonContext
let fetchAvatar: AvatarImageView.FetchAvatar let fetchAvatar: AvatarImageView.FetchAvatar
@ -106,6 +110,7 @@ public final class ComposeController: ViewController {
emojiImageView: @escaping EmojiImageView emojiImageView: @escaping EmojiImageView
) { ) {
self.draft = draft self.draft = draft
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
self.config = config self.config = config
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.fetchAvatar = fetchAvatar self.fetchAvatar = fetchAvatar

View File

@ -53,14 +53,21 @@ class ToolbarController: ViewController {
HStack(spacing: 0) { HStack(spacing: 0) {
cwButton cwButton
MenuPicker(selection: $draft.visibility, options: visibilityOptions, buttonStyle: .iconOnly) MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
// the button has a bunch of extra space by default, but combined with what we add it's too much #if !targetEnvironment(macCatalyst)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8) .padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil) .disabled(draft.editedStatusID != nil)
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
if composeController.mastodonController.instanceFeatures.localOnlyPosts { if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker localOnlyPicker
#if targetEnvironment(macCatalyst)
.padding(.leading, 4)
#else
.padding(.horizontal, -8) .padding(.horizontal, -8)
#endif
.disabled(draft.editedStatusID != nil) .disabled(draft.editedStatusID != nil)
} }
@ -118,9 +125,20 @@ class ToolbarController: ViewController {
.hoverEffect() .hoverEffect()
} }
private var visibilityBinding: Binding<Pachyderm.Visibility> {
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
// changing the visibility when local-only.
if draft.localOnly,
composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility {
return .constant(.public)
} else {
return $draft.visibility
}
}
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] { private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
let visibilities: [Pachyderm.Visibility] let visibilities: [Pachyderm.Visibility]
if !controller.parent.mastodonController.instanceFeatures.composeDirectStatuses { if !composeController.mastodonController.instanceFeatures.composeDirectStatuses {
visibilities = [.public, .unlisted, .private] visibilities = [.public, .unlisted, .private]
} else { } else {
visibilities = Pachyderm.Visibility.allCases visibilities = Pachyderm.Visibility.allCases

View File

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

View File

@ -137,6 +137,8 @@ extension DraftAttachment {
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment" //private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
private let imageType = UTType.image.identifier private let imageType = UTType.image.identifier
private let heifType = UTType.heif.identifier
private let heicType = UTType.heic.identifier
private let jpegType = UTType.jpeg.identifier private let jpegType = UTType.jpeg.identifier
private let pngType = UTType.png.identifier private let pngType = UTType.png.identifier
private let mp4Type = UTType.mpeg4Movie.identifier private let mp4Type = UTType.mpeg4Movie.identifier
@ -148,17 +150,18 @@ extension DraftAttachment: NSItemProviderReading {
// todo: is there a better way of handling movies than manually adding all possible UTI types? // todo: is there a better way of handling movies than manually adding all possible UTI types?
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails // without the file extension, getting the thumbnail and exporting the video for attachment upload fails
[/*typeIdentifier, */ gifType, jpegType, pngType, imageType, mp4Type, quickTimeType] [/*typeIdentifier, */ gifType, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType]
} }
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
var data = data var data = data
var type = UTType(typeIdentifier)! var type = UTType(typeIdentifier)!
// this seems to only occur when the item is a UIImage, rather than just image data, // the type is .image in certain circumstances:
// which seems to only occur when sharing a screenshot directly from the markup screen // - macOS: image copied from macOS Safari -> only UIImage(data: data) works
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
if type == .image, if type == .image,
let image = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data), let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)),
let pngData = image.pngData() { let pngData = image.pngData() {
data = pngData data = pngData
type = .png type = .png
@ -216,7 +219,7 @@ extension DraftAttachment {
options.isNetworkAccessAllowed = true options.isNetworkAccessAllowed = true
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
if let exportSession { if let exportSession {
Self.exportVideoData(session: exportSession, completion: completion) Self.exportVideoData(session: exportSession, features: features, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error { } else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error))) completion(.failure(.videoExport(error)))
} else { } else {
@ -242,7 +245,7 @@ extension DraftAttachment {
completion(.failure(.noVideoExportSession)) completion(.failure(.noVideoExportSession))
return return
} }
Self.exportVideoData(session: session, completion: completion) Self.exportVideoData(session: session, features: features, completion: completion)
} else { } else {
let fileData: Data let fileData: Data
do { do {
@ -273,20 +276,13 @@ extension DraftAttachment {
var data = data var data = data
var type = type var type = type
if type != .png && type != .jpeg,
let image = UIImage(data: data) {
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let image = CIImage(data: data)! let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic { if needsColorSpaceConversion || type == .heic || type == .heif {
let context = CIContext() let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png { if type == .png {
@ -300,9 +296,12 @@ extension DraftAttachment {
return (data, type) return (data, type)
} }
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) { private static func exportVideoData(session: AVAssetExportSession, features: InstanceFeatures, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
session.outputFileType = .mp4 session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
if let configuration = features.mediaAttachmentsConfiguration {
session.fileLengthLimit = Int64(configuration.videoSizeLimit)
}
session.exportAsynchronously { session.exportAsynchronously {
guard session.status == .completed else { guard session.status == .completed else {
completion(.failure(.videoExport(session.error!))) completion(.failure(.videoExport(session.error!)))

View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22221.1" systemVersion="22G74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES"> <entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
<attribute name="accountID" attributeType="String"/> <attribute name="accountID" attributeType="String"/>
<attribute name="contentWarning" attributeType="String" defaultValueString=""/> <attribute name="contentWarning" attributeType="String" defaultValueString=""/>
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="editedStatusID" optional="YES" attributeType="String"/> <attribute name="editedStatusID" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="initialContentWarning" optional="YES" attributeType="String"/>
<attribute name="initialText" attributeType="String"/> <attribute name="initialText" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/> <attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/> <attribute name="language" optional="YES" attributeType="String"/>

View File

@ -81,6 +81,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
contentWarning: String, contentWarning: String,
inReplyToID: String?, inReplyToID: String?,
visibility: Visibility, visibility: Visibility,
language: String?,
localOnly: Bool localOnly: Bool
) -> Draft { ) -> Draft {
let draft = Draft(context: viewContext) let draft = Draft(context: viewContext)
@ -88,9 +89,11 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.text = text draft.text = text
draft.initialText = text draft.initialText = text
draft.contentWarning = contentWarning draft.contentWarning = contentWarning
draft.initialContentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID draft.inReplyToID = inReplyToID
draft.visibility = visibility draft.visibility = visibility
draft.language = language
draft.localOnly = localOnly draft.localOnly = localOnly
save() save()
return draft return draft
@ -112,6 +115,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.initialText = source.text draft.initialText = source.text
draft.contentWarning = source.spoilerText draft.contentWarning = source.spoilerText
draft.contentWarningEnabled = !source.spoilerText.isEmpty draft.contentWarningEnabled = !source.spoilerText.isEmpty
draft.initialContentWarning = source.spoilerText
draft.inReplyToID = inReplyToID draft.inReplyToID = inReplyToID
draft.visibility = visibility draft.visibility = visibility
draft.localOnly = localOnly draft.localOnly = localOnly

View File

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

View File

@ -30,7 +30,13 @@ struct LanguagePicker: View {
if maybeIso639Code.last == "-" { if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)] maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
} }
let code = Locale.LanguageCode(String(maybeIso639Code)) let identifier = String(maybeIso639Code)
// mul (for multiple languages) and unk (unknown) are ISO codes, but not ones that akkoma permits, so we ignore them on all platforms
guard identifier != "mul",
identifier != "und" else {
return nil
}
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage { if code.isISOLanguage {
return code return code
} else { } else {
@ -39,13 +45,10 @@ struct LanguagePicker: View {
} }
private var codeFromPreferredLanguages: Locale.LanguageCode? { private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first { if let identifier = Locale.preferredLanguages.first,
let code = Locale.LanguageCode(identifier) case let code = Locale.LanguageCode(identifier),
if code.isISOLanguage { code.isISOLanguage {
return code return code
} else {
return nil
}
} else { } else {
return nil return nil
} }
@ -66,6 +69,8 @@ struct LanguagePicker: View {
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased()) Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
} }
.accessibilityLabel("Post Language") .accessibilityLabel("Post Language")
.padding(5)
.hoverEffect()
.sheet(isPresented: $isShowingSheet) { .sheet(isPresented: $isShowingSheet) {
NavigationStack { NavigationStack {
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet) LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
@ -138,10 +143,12 @@ private struct LanguagePickerList: View {
// make sure recents always contains the currently selected lang // make sure recents always contains the currently selected lang
let recents = addRecentLang(languageCode) let recents = addRecentLang(languageCode)
recentLangs = recents recentLangs = recents
.filter { $0 != "mul" && $0 != "und" }
.map { Lang(code: .init($0)) } .map { Lang(code: .init($0)) }
.sorted { $0.name < $1.name } .sorted { $0.name < $1.name }
langs = Locale.LanguageCode.isoLanguageCodes langs = Locale.LanguageCode.isoLanguageCodes
.filter { $0.identifier != "mul" && $0.identifier != "und" }
.map { Lang(code: $0) } .map { Lang(code: $0) }
.sorted { $0.name < $1.name } .sorted { $0.name < $1.name }
} }

View File

@ -21,16 +21,29 @@ public class InstanceFeatures: ObservableObject {
@Published public private(set) var charsReservedPerURL = 23 @Published public private(set) var charsReservedPerURL = 23
@Published public private(set) var maxPollOptionChars: Int? @Published public private(set) var maxPollOptionChars: Int?
@Published public private(set) var maxPollOptionsCount: Int? @Published public private(set) var maxPollOptionsCount: Int?
@Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration?
@Published public private(set) var translation: Bool = false
public var localOnlyPosts: Bool { public var localOnlyPosts: Bool {
switch instanceType { switch instanceType {
case .mastodon(.hometown(_), _), .mastodon(.glitch, _): case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
return true return true
case .pleroma(.akkoma(_)):
return true
default: default:
return false return false
} }
} }
/// Instance types that use a separate visibility to indicate local-only posts.
public var localOnlyPostsVisibility: Bool {
if case .pleroma(.akkoma(_)) = instanceType {
return true
} else {
return false
}
}
public var mastodonAttachmentRestrictions: Bool { public var mastodonAttachmentRestrictions: Bool {
instanceType.isMastodon instanceType.isMastodon
} }
@ -155,6 +168,22 @@ public class InstanceFeatures: ObservableObject {
} }
} }
public var searchOperators: Bool {
hasMastodonVersion(4, 2, 0)
}
public var hasServerPreferences: Bool {
hasMastodonVersion(2, 8, 0)
}
public var listRepliesPolicy: Bool {
hasMastodonVersion(3, 3, 0)
}
public var exclusiveLists: Bool {
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
}
public init() { public init() {
} }
@ -211,6 +240,8 @@ public class InstanceFeatures: ObservableObject {
maxPollOptionChars = pollsConfig.maxCharactersPerOption maxPollOptionChars = pollsConfig.maxCharactersPerOption
maxPollOptionsCount = pollsConfig.maxOptions maxPollOptionsCount = pollsConfig.maxOptions
} }
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
translation = instance.translation
_featuresUpdated.send() _featuresUpdated.send()
} }

View File

@ -9,26 +9,39 @@ import Foundation
import Pachyderm import Pachyderm
public struct InstanceInfo { public struct InstanceInfo {
public let version: String public var version: String
public let maxStatusCharacters: Int? public var maxStatusCharacters: Int?
public let configuration: Instance.Configuration? public var configuration: InstanceV1.Configuration?
public let pollsConfiguration: Instance.PollsConfiguration? public var pollsConfiguration: InstanceV1.PollsConfiguration?
public var translation: Bool
public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) { public init(
version: String,
maxStatusCharacters: Int?,
configuration: InstanceV1.Configuration?,
pollsConfiguration: InstanceV1.PollsConfiguration?,
translation: Bool
) {
self.version = version self.version = version
self.maxStatusCharacters = maxStatusCharacters self.maxStatusCharacters = maxStatusCharacters
self.configuration = configuration self.configuration = configuration
self.pollsConfiguration = pollsConfiguration self.pollsConfiguration = pollsConfiguration
self.translation = translation
} }
} }
extension InstanceInfo { extension InstanceInfo {
public init(instance: Instance) { public init(v1 instance: InstanceV1) {
self.init( self.init(
version: instance.version, version: instance.version,
maxStatusCharacters: instance.maxStatusCharacters, maxStatusCharacters: instance.maxStatusCharacters,
configuration: instance.configuration, configuration: instance.configuration,
pollsConfiguration: instance.pollsConfiguration pollsConfiguration: instance.pollsConfiguration,
translation: false
) )
} }
public mutating func update(v2: InstanceV2) {
translation = v2.configuration.translation.enabled
}
} }

View File

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

View File

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

View File

@ -105,6 +105,20 @@ public class Client {
return task return task
} }
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in
run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
}
}
}
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.endpoint.path components.path = request.endpoint.path
@ -113,6 +127,7 @@ public class Client {
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval) var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
urlRequest.httpMethod = request.method.name urlRequest.httpMethod = request.method.name
urlRequest.httpBody = request.body.data urlRequest.httpBody = request.body.data
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
for (name, value) in request.headers { for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name) urlRequest.setValue(value, forHTTPHeaderField: name)
} }
@ -121,6 +136,8 @@ public class Client {
} }
if let accessToken = accessToken { if let accessToken = accessToken {
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
// We consider authenticated requests to be user-initiated.
urlRequest.attribution = .user
} }
return urlRequest return urlRequest
} }
@ -214,14 +231,22 @@ public class Client {
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts) return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
} }
public static func getInstance() -> Request<Instance> { public static func getInstanceV1() -> Request<InstanceV1> {
return Request<Instance>(method: .get, path: "/api/v1/instance") return Request<InstanceV1>(method: .get, path: "/api/v1/instance")
}
public static func getInstanceV2() -> Request<InstanceV2> {
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
} }
public static func getCustomEmoji() -> Request<[Emoji]> { public static func getCustomEmoji() -> Request<[Emoji]> {
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis") return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
} }
public static func getPreferences() -> Request<Preferences> {
return Request(method: .get, path: "/api/v1/preferences")
}
// MARK: - Accounts // MARK: - Accounts
public static func getAccount(id: String) -> Request<Account> { public static func getAccount(id: String) -> Request<Account> {
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)") return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
@ -395,7 +420,7 @@ public class Client {
mediaIDs: [String]? = nil, mediaIDs: [String]? = nil,
sensitive: Bool? = nil, sensitive: Bool? = nil,
spoilerText: String? = nil, spoilerText: String? = nil,
visibility: Visibility? = nil, visibility: String? = nil,
language: String? = nil, // language supported by mastodon and akkoma language: String? = nil, // language supported by mastodon and akkoma
pollOptions: [String]? = nil, pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil, pollExpiresIn: Int? = nil,
@ -408,7 +433,7 @@ public class Client {
"in_reply_to_id" => inReplyTo, "in_reply_to_id" => inReplyTo,
"sensitive" => sensitive, "sensitive" => sensitive,
"spoiler_text" => spoilerText, "spoiler_text" => spoilerText,
"visibility" => visibility?.rawValue, "visibility" => visibility,
"language" => language, "language" => language,
"poll[expires_in]" => pollExpiresIn, "poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple, "poll[multiple]" => pollMultiple,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,9 @@ import Foundation
import WebURL import WebURL
public final class Status: StatusProtocol, Decodable, Sendable { public final class Status: StatusProtocol, Decodable, Sendable {
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts.
public static let localPostVisibility: String = "local"
public let id: String public let id: String
public let uri: String public let uri: String
public let url: WebURL? public let url: WebURL?
@ -63,7 +66,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID) self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID) self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog) self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
self.content = try container.decode(String.self, forKey: .content) // pixelfed statuses may have null content
self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? [] self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount) self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
@ -77,7 +81,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.visibility = visibility self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility), } else if let s = try? container.decode(String.self, forKey: .visibility),
s == "local" { s == Status.localPostVisibility {
// hacky workaround for #332, akkoma describes local posts with a separate visibility // hacky workaround for #332, akkoma describes local posts with a separate visibility
self.visibility = .public self.visibility = .public
self.localOnly = true self.localOnly = true
@ -173,6 +177,10 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history") return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
} }
public static func translate(_ statusID: String) -> Request<Translation> {
return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate")
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case uri case uri

View File

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

View File

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

View File

@ -0,0 +1,97 @@
//
// PostVisibility.swift
// TuskerPreferences
//
// Created by Shadowfacts on 10/26/23.
//
import Foundation
import Pachyderm
public enum PostVisibility: Codable, Hashable, CaseIterable {
case serverDefault
case visibility(Visibility)
public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self {
case .serverDefault:
// If the server doesn't have a default visibility preference, we fallback to public.
// This isn't ideal, but I don't want to add a separate preference for "Default Post Visibility Fallback" :/
serverDefault ?? .public
case .visibility(let vis):
vis
}
}
public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility {
switch self {
case .serverDefault:
await serverDefault() ?? .public
case .visibility(let vis):
vis
}
}
public var displayName: String {
switch self {
case .serverDefault:
return "Account Default"
case .visibility(let vis):
return vis.displayName
}
}
public var imageName: String? {
switch self {
case .serverDefault:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}
public enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost
case visibility(Visibility)
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self {
case .sameAsPost:
Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault)
case .visibility(let vis):
vis
}
}
public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility {
switch self {
case .sameAsPost:
await Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault)
case .visibility(let vis):
vis
}
}
public var displayName: String {
switch self {
case .sameAsPost:
return "Same as Default"
case .visibility(let vis):
return vis.displayName
}
}
public var imageName: String? {
switch self {
case .sameAsPost:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}

View File

@ -61,8 +61,14 @@ public final class Preferences: Codable, ObservableObject {
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility) if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
self.defaultPostVisibility = .visibility(existing)
} else {
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
}
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
@ -121,6 +127,8 @@ public final class Preferences: Codable, ObservableObject {
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions) try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode) try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
@ -175,9 +183,11 @@ public final class Preferences: Codable, ObservableObject {
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] @Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing // MARK: Composing
@Published public var defaultPostVisibility = Visibility.public @Published public var defaultPostVisibility = PostVisibility.serverDefault
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published public var requireAttachmentDescriptions = false @Published public var requireAttachmentDescriptions = false
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
@ -245,6 +255,8 @@ public final class Preferences: Codable, ObservableObject {
case leadingStatusSwipeActions case leadingStatusSwipeActions
case trailingStatusSwipeActions case trailingStatusSwipeActions
case widescreenNavigationMode case widescreenNavigationMode
case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility case defaultPostVisibility
case defaultReplyVisibility case defaultReplyVisibility
@ -288,42 +300,6 @@ public final class Preferences: Codable, ObservableObject {
} }
extension Preferences {
public enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost
case visibility(Visibility)
public static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
public var resolved: Visibility {
switch self {
case .sameAsPost:
return Preferences.shared.defaultPostVisibility
case .visibility(let vis):
return vis
}
}
public var displayName: String {
switch self {
case .sameAsPost:
return "Same as Default"
case .visibility(let vis):
return vis.displayName
}
}
public var imageName: String? {
switch self {
case .sameAsPost:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}
}
extension Preferences { extension Preferences {
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting case useStatusSetting
@ -436,7 +412,6 @@ extension Preferences {
public enum FeatureFlag: String, Codable { public enum FeatureFlag: String, Codable {
case iPadMultiColumn = "ipad-multi-column" case iPadMultiColumn = "ipad-multi-column"
case iPadBrowserNavigation = "ipad-browser-navigation" case iPadBrowserNavigation = "ipad-browser-navigation"
case iPadNavigationMode = "ipad-navigation-mode"
} }
} }

View File

@ -16,6 +16,11 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
public private(set) var username: String! public private(set) var username: String!
public let accessToken: String public let accessToken: String
// Sort of hack to be able to access these from the share extension.
public internal(set) var serverDefaultLanguage: String?
public internal(set) var serverDefaultVisibility: String?
public internal(set) var serverDefaultFederation: Bool?
fileprivate static let tempAccountID = "temp" fileprivate static let tempAccountID = "temp"
static func id(instanceURL: URL, username: String?) -> String { static func id(instanceURL: URL, username: String?) -> String {
@ -47,21 +52,47 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
self.accessToken = accessToken self.accessToken = accessToken
} }
init?(userDefaultsDict dict: [String: String]) { init?(userDefaultsDict dict: [String: Any]) {
guard let id = dict["id"], guard let id = dict["id"] as? String,
let instanceURL = dict["instanceURL"], let instanceURL = dict["instanceURL"] as? String,
let url = URL(string: instanceURL), let url = URL(string: instanceURL),
let clientID = dict["clientID"], let clientID = dict["clientID"] as? String,
let secret = dict["clientSecret"], let secret = dict["clientSecret"] as? String,
let accessToken = dict["accessToken"] else { let accessToken = dict["accessToken"] as? String else {
return nil return nil
} }
self.id = id self.id = id
self.instanceURL = url self.instanceURL = url
self.clientID = clientID self.clientID = clientID
self.clientSecret = secret self.clientSecret = secret
self.username = dict["username"] self.username = dict["username"] as? String
self.accessToken = accessToken self.accessToken = accessToken
self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String
self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String
self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool
}
var userDefaultsDict: [String: Any] {
var dict: [String: Any] = [
"id": id,
"instanceURL": instanceURL.absoluteString,
"clientID": clientID,
"clientSecret": clientSecret,
"accessToken": accessToken,
]
if let username {
dict["username"] = username
}
if let serverDefaultLanguage {
dict["serverDefaultLanguage"] = serverDefaultLanguage
}
if let serverDefaultVisibility {
dict["serverDefaultVisibility"] = serverDefaultVisibility
}
if let serverDefaultFederation {
dict["serverDefaultFederation"] = serverDefaultFederation
}
return dict
} }
/// A filename-safe string for this account /// A filename-safe string for this account

View File

@ -46,19 +46,7 @@ public class UserAccountsManager: ObservableObject {
} }
set { set {
objectWillChange.send() objectWillChange.send()
let array = newValue.map { (info) -> [String: String] in let array = newValue.map(\.userDefaultsDict)
var res = [
"id": info.id,
"instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID,
"clientSecret": info.clientSecret,
"accessToken": info.accessToken
]
if let username = info.username {
res["username"] = username
}
return res
}
defaults.set(array, forKey: accountsKey) defaults.set(array, forKey: accountsKey)
} }
} }
@ -147,6 +135,17 @@ public class UserAccountsManager: ObservableObject {
mostRecentAccountID = account?.id mostRecentAccountID = account?.id
} }
public func updateServerPreferences(_ account: UserAccountInfo, defaultLanguage: String?, defaultVisibility: String?, defaultFederation: Bool?) {
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
return
}
var account = account
account.serverDefaultLanguage = defaultLanguage
account.serverDefaultVisibility = defaultVisibility
account.serverDefaultFederation = defaultFederation
accounts[index] = account
}
} }
public extension Notification.Name { public extension Notification.Name {

View File

@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
self.instanceFeatures = InstanceFeatures() self.instanceFeatures = InstanceFeatures()
Task { @MainActor in Task { @MainActor in
async let instance = try? await run(Client.getInstance()).0 async let instance = try? await run(Client.getInstanceV1()).0
async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
self.client.nodeInfo { response in self.client.nodeInfo { response in
switch response { switch response {
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
} }
}) })
guard let instance = await instance else { return } guard let instance = await instance else { return }
self.instanceFeatures.update(instance: InstanceInfo(instance: instance), nodeInfo: await nodeInfo) self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo)
} }
Task { @MainActor in Task { @MainActor in

View File

@ -12,6 +12,7 @@ import ComposeUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import TuskerPreferences import TuskerPreferences
import Combine import Combine
import Pachyderm
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
@ -50,21 +51,22 @@ class ShareViewController: UIViewController {
} }
private func createDraft(account: UserAccountInfo) async -> Draft { private func createDraft(account: UserAccountInfo) async -> Draft {
let (text, attachments) = await getDraftConfigurationFromExtensionContext() async let (text, attachments) = getDraftConfigurationFromExtensionContext()
let draft = DraftsPersistentContainer.shared.createDraft( let draft = DraftsPersistentContainer.shared.createDraft(
accountID: account.id, accountID: account.id,
text: text, text: await text,
contentWarning: "", contentWarning: "",
inReplyToID: nil, inReplyToID: nil,
visibility: Preferences.shared.defaultPostVisibility, visibility: Preferences.shared.defaultPostVisibility.resolved(withServerDefault: account.serverDefaultVisibility.flatMap(Visibility.init(rawValue:))),
localOnly: false language: account.serverDefaultLanguage,
localOnly: !(account.serverDefaultFederation ?? true)
) )
for attachment in attachments { for attachment in await attachments {
DraftsPersistentContainer.shared.viewContext.insert(attachment) DraftsPersistentContainer.shared.viewContext.insert(attachment)
} }
draft.draftAttachments = attachments draft.draftAttachments = await attachments
return draft return draft
} }

View File

@ -1,2 +1,5 @@
#include "Version.xcconfig"
DEVELOPMENT_TEAM = YOUR_TEAM_ID DEVELOPMENT_TEAM = YOUR_TEAM_ID
BUNDLE_ID_PREFIX = com.example BUNDLE_ID_PREFIX = com.example

View File

@ -214,8 +214,6 @@
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; }; D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; }; D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; }; D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; };
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; };
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; };
D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; }; D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; };
D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6A4531729EF64BA00032932 /* MainInterface.storyboard */; }; D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6A4531729EF64BA00032932 /* MainInterface.storyboard */; };
D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6A4531329EF64BA00032932 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6A4531329EF64BA00032932 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@ -258,7 +256,6 @@
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; }; D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; };
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.swift */; }; D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.swift */; };
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */; }; D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */; };
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; };
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; }; D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; };
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; }; D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; };
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
@ -267,6 +264,7 @@
D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; }; D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; };
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; };
D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C041C32AED77730094D32D /* EditListSettingsService.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; }; D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; };
@ -286,6 +284,9 @@
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = D6CA6ED129EF6091003EC5DF /* TuskerPreferences */; }; D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = D6CA6ED129EF6091003EC5DF /* TuskerPreferences */; };
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; }; D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; };
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; }; D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; };
D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */; };
D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */; };
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */; };
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; }; D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; };
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; }; D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; };
@ -337,6 +338,7 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; }; D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; };
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; }; D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; };
@ -614,8 +616,6 @@
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; }; D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; }; D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; }; D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = "<group>"; };
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = "<group>"; };
D6A4531329EF64BA00032932 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D6A4531329EF64BA00032932 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D6A4531529EF64BA00032932 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; D6A4531529EF64BA00032932 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
D6A4531829EF64BA00032932 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; }; D6A4531829EF64BA00032932 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
@ -648,6 +648,7 @@
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedPageViewController.swift; sourceTree = "<group>"; }; D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedPageViewController.swift; sourceTree = "<group>"; };
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = "<group>"; }; D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = "<group>"; };
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameView.swift; sourceTree = "<group>"; }; D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameView.swift; sourceTree = "<group>"; };
D6B5F3BC2ACA586C00309734 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.swift; sourceTree = "<group>"; }; D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.swift; sourceTree = "<group>"; };
D6B81F432560390300F6E31D /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = "<group>"; }; D6B81F432560390300F6E31D /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; }; D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
@ -656,7 +657,6 @@
D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = "<group>"; }; D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = "<group>"; };
D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = "<group>"; }; D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = "<group>"; };
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = "<group>"; }; D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = "<group>"; };
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; }; D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; };
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; }; D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; };
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
@ -666,6 +666,7 @@
D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; }; D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; };
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
D6C041C32AED77730094D32D /* EditListSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSettingsService.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.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>"; }; 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>"; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
@ -685,6 +686,9 @@
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerPreferences; path = Packages/TuskerPreferences; sourceTree = "<group>"; }; D6CA6ED029EF6060003EC5DF /* TuskerPreferences */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerPreferences; path = Packages/TuskerPreferences; sourceTree = "<group>"; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; }; D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; };
D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; }; D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; };
D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSCollectionLayoutSection+Readable.swift"; sourceTree = "<group>"; };
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiColumnCollectionViewLayout.swift; sourceTree = "<group>"; };
D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonSearchController.swift; sourceTree = "<group>"; };
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = "<group>"; }; D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = "<group>"; };
@ -746,6 +750,7 @@
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; }; D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; }; D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; }; D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; }; D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; }; D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; }; D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; };
@ -1245,6 +1250,7 @@
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */, D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */, D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */,
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */, D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */,
D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1286,8 +1292,6 @@
D6A3BC872321F78000FD64D5 /* Account Cell */ = { D6A3BC872321F78000FD64D5 /* Account Cell */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */,
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */,
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */, D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */,
); );
path = "Account Cell"; path = "Account Cell";
@ -1359,6 +1363,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */, D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */,
D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */,
); );
path = Search; path = Search;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1415,7 +1421,6 @@
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */, D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */, D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */,
D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */, D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */,
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */, D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */, D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
D6E0DC8D216EDF1E00369478 /* Previewing.swift */, D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
@ -1449,6 +1454,7 @@
children = ( children = (
D63CC703290EC472000E19DE /* Dist.xcconfig */, D63CC703290EC472000E19DE /* Dist.xcconfig */,
D6D706A829498C82000827ED /* Tusker.xcconfig */, D6D706A829498C82000827ED /* Tusker.xcconfig */,
D6B5F3BC2ACA586C00309734 /* Version.xcconfig */,
D674A50727F910F300BA03AC /* Pachyderm */, D674A50727F910F300BA03AC /* Pachyderm */,
D6BEA243291A0C83002F4D01 /* Duckable */, D6BEA243291A0C83002F4D01 /* Duckable */,
D68A76F22953915C001DA1B3 /* TTTKit */, D68A76F22953915C001DA1B3 /* TTTKit */,
@ -1499,6 +1505,7 @@
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */, D61DC84528F498F200B82C6E /* Logging.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */, D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */, D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
@ -1631,6 +1638,7 @@
D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */,
D6F6A551291F098700F496A8 /* RenameListService.swift */, D6F6A551291F098700F496A8 /* RenameListService.swift */,
D6C041C32AED77730094D32D /* EditListSettingsService.swift */,
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */, D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */, D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */, D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
@ -1770,8 +1778,9 @@
D6D4DDC4212518A000E1C4BB /* Project object */ = { D6D4DDC4212518A000E1C4BB /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1430; LastSwiftUpdateCheck = 1430;
LastUpgradeCheck = 1400; LastUpgradeCheck = 1500;
ORGANIZATIONNAME = Shadowfacts; ORGANIZATIONNAME = Shadowfacts;
TargetAttributes = { TargetAttributes = {
D6A4531229EF64BA00032932 = { D6A4531229EF64BA00032932 = {
@ -1846,7 +1855,6 @@
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */, D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */, D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */, D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */, D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */, D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
@ -1972,7 +1980,6 @@
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */, D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */, D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */, D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */, D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */, D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */,
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
@ -2077,6 +2084,7 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */, D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */,
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */, D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */, D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */, D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
@ -2122,6 +2130,7 @@
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */,
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */, D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */,
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */, D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */, D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */, D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
@ -2134,6 +2143,7 @@
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */, D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */,
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */, D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */,
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */, D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */,
@ -2204,12 +2214,12 @@
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */, D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */, D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */, D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
@ -2217,6 +2227,7 @@
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */, D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */,
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */, D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */, D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */,
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */, D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */, D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */, D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */,
@ -2398,7 +2409,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 103; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2406,7 +2417,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.7; MARKETING_VERSION = "$(MARKETING_VERSION)";
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -2464,7 +2475,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2489,7 +2500,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2517,7 +2528,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2545,7 +2556,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2699,7 +2710,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 103; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2707,7 +2718,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.7; MARKETING_VERSION = "$(MARKETING_VERSION)";
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
OTHER_LDFLAGS = ""; OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
@ -2730,7 +2741,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 103; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2738,7 +2749,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.7; MARKETING_VERSION = "$(MARKETING_VERSION)";
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -2836,7 +2847,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2861,7 +2872,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2958,7 +2969,7 @@
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git"; repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
requirement = { requirement = {
kind = upToNextMinorVersion; kind = upToNextMinorVersion;
minimumVersion = 8.0.0; minimumVersion = 8.15.0;
}; };
}; };
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = { D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1430" LastUpgradeVersion = "1500"
wasCreatedForAppExtension = "YES" wasCreatedForAppExtension = "YES"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction

View File

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

View File

@ -35,8 +35,7 @@ class DeleteStatusService {
reblogIDs = reblogs.map(\.id) reblogIDs = reblogs.map(\.id)
} }
NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [ NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [
"accountID": mastodonController.accountInfo!.id,
"statusIDs": [status.id] + reblogIDs, "statusIDs": [status.id] + reblogIDs,
]) ])
} catch { } catch {

View File

@ -0,0 +1,46 @@
//
// EditListSettingsService.swift
// Tusker
//
// Created by Shadowfacts on 10/28/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class EditListSettingsService {
private let list: ListProtocol
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
self.list = list
self.mastodonController = mastodonController
self.present = present
}
func run(title: String? = nil, replyPolicy: List.ReplyPolicy? = nil, exclusive: Bool? = nil) async {
do {
let req = List.update(
list.id,
title: title ?? list.title,
replyPolicy: replyPolicy ?? list.replyPolicy,
exclusive: exclusive ?? list.exclusive
)
let (list, _) = try await mastodonController.run(req)
mastodonController.updatedList(list)
} catch {
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
Task {
await self.run(title: title, replyPolicy: replyPolicy, exclusive: exclusive)
}
}))
present(alert)
}
}
}

View File

@ -36,11 +36,6 @@ class FetchStatusService {
} }
private func handleStatusNotFound() { private func handleStatusNotFound() {
// todo: what about when browsing on another instance?
guard let accountID = mastodonController.accountInfo?.id else {
return
}
var reblogIDs = [String]() var reblogIDs = [String]()
if let cached = mastodonController.persistentContainer.status(for: statusID) { if let cached = mastodonController.persistentContainer.status(for: statusID) {
let reblogsReq = StatusMO.fetchRequest() let reblogsReq = StatusMO.fetchRequest()
@ -50,8 +45,7 @@ class FetchStatusService {
} }
} }
NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [ NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [
"accountID": accountID,
"statusIDs": [statusID] + reblogIDs "statusIDs": [statusID] + reblogIDs
]) ])
} }

View File

@ -53,7 +53,8 @@ class MastodonController: ObservableObject {
let instanceFeatures = InstanceFeatures() let instanceFeatures = InstanceFeatures()
@Published private(set) var account: AccountMO? @Published private(set) var account: AccountMO?
@Published private(set) var instance: Instance? @Published private(set) var instance: InstanceV1?
@Published private var instanceV2: InstanceV2?
@Published private(set) var instanceInfo: InstanceInfo! @Published private(set) var instanceInfo: InstanceInfo!
@Published private(set) var nodeInfo: NodeInfo! @Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var lists: [List] = [] @Published private(set) var lists: [List] = []
@ -63,7 +64,7 @@ class MastodonController: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]() private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask? private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool { var loggedIn: Bool {
@ -107,9 +108,14 @@ class MastodonController: ObservableObject {
$instance $instance
.compactMap { $0 } .compactMap { $0 }
.sink { [unowned self] in .combineLatest($instanceV2)
self.updateActiveInstance(from: $0) .sink {[unowned self] (instance, v2) in
self.instanceInfo = InstanceInfo(instance: $0) var info = InstanceInfo(v1: instance)
if let v2 {
info.update(v2: v2)
}
self.instanceInfo = info
self.updateActiveInstance(from: info)
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -193,6 +199,8 @@ class MastodonController: ObservableObject {
@MainActor @MainActor
func initialize() { func initialize() {
precondition(!transient, "Cannot initialize transient MastodonController")
// we want this to happen immediately, and synchronously so that the filters (which don't change that often) // we want this to happen immediately, and synchronously so that the filters (which don't change that often)
// are available when Filterers are constructed // are available when Filterers are constructed
loadCachedFilters() loadCachedFilters()
@ -215,8 +223,13 @@ class MastodonController: ObservableObject {
_ = try await (ownAccount, ownInstance) _ = try await (ownAccount, ownInstance)
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
async let _ = try? getOwnInstanceV2()
}
loadLists() loadLists()
_ = await loadFilters() _ = await loadFilters()
await loadServerPreferences()
} catch { } catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))") Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
} }
@ -274,7 +287,7 @@ class MastodonController: ObservableObject {
} }
} }
func getOwnInstance(completion: ((Instance) -> Void)? = nil) { func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0) { getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 { if case let .success(instance) = $0 {
completion?(instance) completion?(instance)
@ -283,7 +296,7 @@ class MastodonController: ObservableObject {
} }
@MainActor @MainActor
func getOwnInstance() async throws -> Instance { func getOwnInstance() async throws -> InstanceV1 {
return try await withCheckedThrowingContinuation({ continuation in return try await withCheckedThrowingContinuation({ continuation in
getOwnInstanceInternal(retryAttempt: 0) { result in getOwnInstanceInternal(retryAttempt: 0) { result in
continuation.resume(with: result) continuation.resume(with: result)
@ -291,7 +304,7 @@ class MastodonController: ObservableObject {
}) })
} }
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) { private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread) assert(Thread.isMainThread)
@ -303,7 +316,7 @@ class MastodonController: ObservableObject {
} }
if ownInstanceRequest == nil { if ownInstanceRequest == nil {
let request = Client.getInstance() let request = Client.getInstanceV1()
ownInstanceRequest = run(request) { (response) in ownInstanceRequest = run(request) { (response) in
switch response { switch response {
case .failure(let error): case .failure(let error):
@ -358,13 +371,32 @@ class MastodonController: ObservableObject {
} }
} }
private func updateActiveInstance(from instance: Instance) { private func getOwnInstanceV2() async throws {
self.instanceV2 = try await client.run(Client.getInstanceV2()).0
}
// MainActor because the accountPreferences instance is bound to the view context
@MainActor
private func loadServerPreferences() async {
guard instanceFeatures.hasServerPreferences,
let (prefs, _) = try? await run(Client.getPreferences()) else {
return
}
accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true
if let accountInfo {
UserAccountsManager.shared.updateServerPreferences(accountInfo, defaultLanguage: prefs.postingDefaultLanguage, defaultVisibility: prefs.postingDefaultVisibility.rawValue, defaultFederation: prefs.postingDefaultFederation)
}
}
private func updateActiveInstance(from info: InstanceInfo) {
persistentContainer.performBackgroundTask { context in persistentContainer.performBackgroundTask { context in
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
existing.update(from: instance) existing.update(from: info)
} else { } else {
let new = ActiveInstance(context: context) let new = ActiveInstance(context: context)
new.update(from: instance) new.update(from: info)
} }
if context.hasChanges { if context.hasChanges {
try? context.save() try? context.save()
@ -434,16 +466,12 @@ class MastodonController: ObservableObject {
guard let lists = try? persistentContainer.viewContext.fetch(req) else { guard let lists = try? persistentContainer.viewContext.fetch(req) else {
return [] return []
} }
return lists.map { return lists.map(\.apiList).sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
List(id: $0.id, title: $0.title)
}.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
} }
func getCachedList(id: String) -> List? { func getCachedList(id: String) -> List? {
let req = ListMO.fetchRequest(id: id) let req = ListMO.fetchRequest(id: id)
return (try? persistentContainer.viewContext.fetch(req).first).flatMap { return (try? persistentContainer.viewContext.fetch(req).first).map(\.apiList)
List(id: $0.id, title: $0.title)
}
} }
@MainActor @MainActor
@ -460,7 +488,7 @@ class MastodonController: ObservableObject {
} }
@MainActor @MainActor
func renamedList(_ list: List) { func updatedList(_ list: List) {
var new = self.lists var new = self.lists
if let index = new.firstIndex(where: { $0.id == list.id }) { if let index = new.firstIndex(where: { $0.id == list.id }) {
new[index] = list new[index] = list
@ -519,8 +547,12 @@ class MastodonController: ObservableObject {
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
var acctsToMention = [String]() var acctsToMention = [String]()
var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility var visibility = if inReplyToID != nil {
var localOnly = false Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
} else {
Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
}
var localOnly = instanceFeatures.localOnlyPosts && !accountPreferences!.serverDefaultFederation
var contentWarning = "" var contentWarning = ""
if let inReplyToID = inReplyToID, if let inReplyToID = inReplyToID,
@ -559,6 +591,7 @@ class MastodonController: ObservableObject {
contentWarning: contentWarning, contentWarning: contentWarning,
inReplyToID: inReplyToID, inReplyToID: inReplyToID,
visibility: visibility, visibility: visibility,
language: accountPreferences!.serverDefaultLanguage,
localOnly: localOnly localOnly: localOnly
) )
} }

View File

@ -47,9 +47,9 @@ class RenameListService {
private func updateList(with title: String) async { private func updateList(with title: String) async {
do { do {
let req = List.update(list.id, title: title) let req = List.update(list.id, title: title, replyPolicy: nil, exclusive: nil)
let (list, _) = try await mastodonController.run(req) let (list, _) = try await mastodonController.run(req)
mastodonController.renamedList(list) mastodonController.updatedList(list)
} catch { } catch {
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

View File

@ -162,13 +162,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
} }
@objc func closeWindow() {
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) else {
return
}
UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil)
}
private func swizzleStatusBar() { private func swizzleStatusBar() {
let selector = Selector(("handleTapAction:")) let selector = Selector(("handleTapAction:"))
var originalIMP: IMP? var originalIMP: IMP?

View File

@ -59,10 +59,15 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
super.awakeFromFetch() super.awakeFromFetch()
managedObjectContext?.perform { managedObjectContext?.perform {
self.lastFetchedAt = Date() self.touch()
} }
} }
/// Update the `lastFetchedAt` date so this object isn't pruned early.
func touch() {
lastFetchedAt = Date()
}
} }
extension AccountMO { extension AccountMO {

View File

@ -24,10 +24,22 @@ public final class AccountPreferences: NSManagedObject {
@NSManaged public var accountID: String @NSManaged public var accountID: String
@NSManaged var createdAt: Date @NSManaged var createdAt: Date
@NSManaged var pinnedTimelinesData: Data? @NSManaged var pinnedTimelinesData: Data?
@NSManaged var serverDefaultFederation: Bool
@NSManaged var serverDefaultLanguage: String?
@NSManaged private var serverDefaultVisibilityString: String?
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines) @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines)
var pinnedTimelines: [PinnedTimeline] var pinnedTimelines: [PinnedTimeline]
var serverDefaultVisibility: Visibility? {
get {
serverDefaultVisibilityString.flatMap(Visibility.init(rawValue:))
}
set {
serverDefaultVisibilityString = newValue?.rawValue
}
}
static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context) let prefs = AccountPreferences(context: context)
prefs.accountID = account.id prefs.accountID = account.id

View File

@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject {
@NSManaged public var maxStatusCharacters: Int @NSManaged public var maxStatusCharacters: Int
@NSManaged private var configurationData: Data? @NSManaged private var configurationData: Data?
@NSManaged private var pollsConfigurationData: Data? @NSManaged private var pollsConfigurationData: Data?
@NSManaged public var translation: Bool
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
public var configuration: Instance.Configuration? public var configuration: InstanceV1.Configuration?
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
public var pollsConfiguration: Instance.PollsConfiguration? public var pollsConfiguration: InstanceV1.PollsConfiguration?
func update(from instance: Instance) { func update(from info: InstanceInfo) {
self.version = instance.version self.version = info.version
self.maxStatusCharacters = instance.maxStatusCharacters ?? 500 self.maxStatusCharacters = info.maxStatusCharacters ?? 500
self.configuration = instance.configuration self.configuration = info.configuration
self.pollsConfiguration = instance.pollsConfiguration self.pollsConfiguration = info.pollsConfiguration
self.translation = info.translation
} }
} }
@ -43,7 +45,8 @@ extension InstanceInfo {
version: activeInstance.version, version: activeInstance.version,
maxStatusCharacters: activeInstance.maxStatusCharacters, maxStatusCharacters: activeInstance.maxStatusCharacters,
configuration: activeInstance.configuration, configuration: activeInstance.configuration,
pollsConfiguration: activeInstance.pollsConfiguration pollsConfiguration: activeInstance.pollsConfiguration,
translation: activeInstance.translation
) )
} }
} }

View File

@ -25,6 +25,22 @@ public final class ListMO: NSManagedObject, ListProtocol {
@NSManaged public var id: String @NSManaged public var id: String
@NSManaged public var title: String @NSManaged public var title: String
@NSManaged private var replyPolicyString: String?
@NSManaged private var exclusiveInternal: Bool
public var replyPolicy: List.ReplyPolicy? {
get {
replyPolicyString.flatMap(List.ReplyPolicy.init(rawValue:))
}
set {
replyPolicyString = newValue?.rawValue
}
}
public var exclusive: Bool? {
get { exclusiveInternal }
set { exclusiveInternal = newValue ?? false }
}
} }
@ -37,5 +53,16 @@ extension ListMO {
func updateFrom(apiList list: List) { func updateFrom(apiList list: List) {
self.id = list.id self.id = list.id
self.title = list.title self.title = list.title
self.replyPolicy = list.replyPolicy
self.exclusive = list.exclusive
}
var apiList: List {
List(
id: id,
title: title,
replyPolicy: replyPolicy,
exclusive: exclusive
)
} }
} }

View File

@ -54,6 +54,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@NSManaged public var reblog: StatusMO? @NSManaged public var reblog: StatusMO?
@NSManaged public var localOnly: Bool @NSManaged public var localOnly: Bool
@NSManaged public var lastFetchedAt: Date? @NSManaged public var lastFetchedAt: Date?
@NSManaged public var language: String?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData) @LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment] public var attachments: [Attachment]
@ -89,10 +90,15 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
super.awakeFromFetch() super.awakeFromFetch()
managedObjectContext?.perform { managedObjectContext?.perform {
self.lastFetchedAt = Date() self.touch()
} }
} }
/// Update the `lastFetchedAt` date so this object isn't pruned early.
func touch() {
lastFetchedAt = Date()
}
} }
extension StatusMO { extension StatusMO {
@ -134,6 +140,7 @@ extension StatusMO {
self.visibility = status.visibility self.visibility = status.visibility
self.poll = status.poll self.poll = status.poll
self.localOnly = status.localOnly ?? false self.localOnly = status.localOnly ?? false
self.language = status.language
if let existing = container.account(for: status.account.id, in: context) { if let existing = container.account(for: status.account.id, in: context) {
existing.updateFrom(apiAccount: status.account, container: container) existing.updateFrom(apiAccount: status.account, container: container)

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B92" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES"> <entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -33,11 +33,15 @@
<attribute name="accountID" optional="YES" attributeType="String"/> <attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/> <attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
<attribute name="serverDefaultFederation" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="serverDefaultLanguage" optional="YES" attributeType="String"/>
<attribute name="serverDefaultVisibilityString" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES"> <entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES">
<attribute name="configurationData" optional="YES" attributeType="Binary"/> <attribute name="configurationData" optional="YES" attributeType="Binary"/>
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/> <attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
<attribute name="translation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="version" optional="YES" attributeType="String"/> <attribute name="version" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES"> <entity name="Filter" representedClassName="FilterMO" syncable="YES">
@ -59,7 +63,9 @@
<attribute name="url" attributeType="URI"/> <attribute name="url" attributeType="URI"/>
</entity> </entity>
<entity name="List" representedClassName="ListMO" syncable="YES"> <entity name="List" representedClassName="ListMO" syncable="YES">
<attribute name="exclusiveInternal" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="String"/> <attribute name="id" optional="YES" attributeType="String"/>
<attribute name="replyPolicyString" optional="YES" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String"/> <attribute name="title" optional="YES" attributeType="String"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>
@ -104,6 +110,7 @@
<attribute name="id" attributeType="String"/> <attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/> <attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/> <attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mentionsData" attributeType="Binary"/> <attribute name="mentionsData" attributeType="Binary"/>
@ -118,7 +125,8 @@
<attribute name="url" optional="YES" attributeType="URI"/> <attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/> <attribute name="visibilityString" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/> <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/> <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status"/>
<relationship name="reblogs" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/> <relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>

View File

@ -0,0 +1,51 @@
//
// NSCollectionLayoutSection+Readable.swift
// Tusker
//
// Created by Shadowfacts on 9/28/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
extension NSCollectionLayoutSection {
// The .readableContent insets reference has a bunch of weird behavior,
// so just calculate the content inset ourselves.
func readableContentInset(in environment: NSCollectionLayoutEnvironment) {
guard let maximumReadableWidth = environment.maximumReadableWidth else {
return
}
let inset = max(0, (environment.container.contentSize.width - maximumReadableWidth) / 2)
// make sure not to overwrite the vertical insets, which are non-zero for grouped styles
contentInsets.leading = inset
contentInsets.trailing = inset
}
}
extension NSCollectionLayoutEnvironment {
var maximumReadableWidth: CGFloat? {
switch traitCollection.preferredContentSizeCategory {
case .extraSmall:
return 560
case .small:
return 600
case .medium:
return 632
case .large:
return 664
case .extraLarge:
return 744
case .extraExtraLarge:
return 816
case .extraExtraExtraLarge:
return 896
case .accessibilityMedium:
return 1096
default:
// greater accessibility sizes don't have a limit
return nil
}
}
}

View File

@ -37,7 +37,12 @@ struct HTMLConverter {
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
mutAttrString.collapseWhitespace() mutAttrString.collapseWhitespace()
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange) // Wait until the end and then fill in the unset paragraph styles, to avoid clobbering the list style.
mutAttrString.enumerateAttribute(.paragraphStyle, in: mutAttrString.fullRange, options: .longestEffectiveRangeNotRequired) { value, range, stop in
if value == nil {
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
}
}
return mutAttrString return mutAttrString
} else { } else {
@ -56,6 +61,10 @@ struct HTMLConverter {
} }
return NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color]) return NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color])
case let node as Element: case let node as Element:
if node.tagName() == "ol" || node.tagName() == "ul" {
return attributedTextForList(node, usePreformattedText: usePreformattedText)
}
let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color]) let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color])
for child in node.getChildNodes() { for child in node.getChildNodes() {
var appendEllipsis = false var appendEllipsis = false
@ -77,6 +86,12 @@ struct HTMLConverter {
} }
} }
lazy var currentFont = if attributed.length == 0 {
font
} else {
attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
}
switch node.tagName() { switch node.tagName() {
case "br": case "br":
// need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which // need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which
@ -93,20 +108,8 @@ struct HTMLConverter {
case "p": case "p":
attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: font])) attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: font]))
case "em", "i": case "em", "i":
let currentFont: UIFont
if attributed.length == 0 {
currentFont = font
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange) attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange)
case "strong", "b": case "strong", "b":
let currentFont: UIFont
if attributed.length == 0 {
currentFont = font
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange) attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange)
case "del": case "del":
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
@ -115,25 +118,14 @@ struct HTMLConverter {
case "pre": case "pre":
attributed.append(NSAttributedString(string: "\n\n")) attributed.append(NSAttributedString(string: "\n\n"))
attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange) attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange)
case "ol", "ul": case "blockquote":
attributed.append(NSAttributedString(string: "\n\n")) let paragraphStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle
attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines) paragraphStyle.headIndent = 32
case "li": paragraphStyle.firstLineHeadIndent = 32
let parentEl = node.parent()! attributed.addAttributes([
let parentTag = parentEl.tagName() .font: currentFont.withTraits(.traitItalic)!,
let bullet: NSAttributedString .paragraphStyle: paragraphStyle,
if parentTag == "ol" { ], range: attributed.fullRange)
let index = (try? node.elementSiblingIndex()) ?? 0
// we use the monospace digit font so that the periods of all the list items line up
// TODO: this probably breaks with dynamic type
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: monospaceFont, .foregroundColor: color])
} else if parentTag == "ul" {
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font, .foregroundColor: color])
} else {
bullet = NSAttributedString()
}
attributed.insert(bullet, at: 0)
attributed.append(NSAttributedString(string: "\n", attributes: [.font: font]))
default: default:
break break
} }
@ -144,5 +136,37 @@ struct HTMLConverter {
} }
} }
private func attributedTextForList(_ element: Element, usePreformattedText: Bool) -> NSAttributedString {
let list = element.tagName() == "ol" ? OrderedNumberTextList(markerFormat: .decimal, options: 0) : NSTextList(markerFormat: .disc, options: 0)
let paragraphStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle
// I don't like that I can't just use paragraphStyle.textLists, because it makes the list markers
// not use the monospace digit font (it seems to just use whatever font attribute is set for the whole thing),
// and it doesn't right align the list markers.
// Unfortunately, doing it manually means the list markers are incldued in the selectable text.
paragraphStyle.headIndent = 32
paragraphStyle.firstLineHeadIndent = 0
// Use 2 tab stops, one for the list marker, the second for the content.
paragraphStyle.tabStops = [NSTextTab(textAlignment: .right, location: 28), NSTextTab(textAlignment: .natural, location: 32)]
let str = NSMutableAttributedString(string: "")
var item = 1
for child in element.children() where child.tagName() == "li" {
if let childStr = attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText) {
str.append(NSAttributedString(string: "\t\(list.marker(forItemNumber: item))\t", attributes: [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedDigitSystemFont(ofSize: 17, weight: .regular)),
]))
str.append(childStr)
str.append(NSAttributedString(string: "\n"))
item += 1
}
}
str.addAttribute(.paragraphStyle, value: paragraphStyle, range: str.fullRange)
return str
}
} }
private class OrderedNumberTextList: NSTextList {
override func marker(forItemNumber itemNumber: Int) -> String {
"\(super.marker(forItemNumber: itemNumber))."
}
}

View File

@ -7,12 +7,13 @@
// //
import Foundation import Foundation
import CoreData
private let decoder = PropertyListDecoder() private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder() private let encoder = PropertyListEncoder()
@propertyWrapper @propertyWrapper
public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> { public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> {
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?> private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
private let fallback: Value private let fallback: Value

View File

@ -41,22 +41,25 @@ struct MenuController {
static let prevSubTabCommand = UIKeyCommand(title: "Previous Sub Tab", action: #selector(TabbedPageViewController.selectPrevPage), input: "[", modifierFlags: [.command, .shift]) static let prevSubTabCommand = UIKeyCommand(title: "Previous Sub Tab", action: #selector(TabbedPageViewController.selectPrevPage), input: "[", modifierFlags: [.command, .shift])
static func buildMainMenu(builder: UIMenuBuilder) { static func buildMainMenu(builder: UIMenuBuilder) {
builder.replace(menu: .file, with: buildFileMenu()) builder.replace(menu: .file, with: buildFileMenu(builder: builder))
builder.insertChild(buildSubTabMenu(), atStartOfMenu: .view) builder.insertChild(buildSubTabMenu(), atStartOfMenu: .view)
builder.insertChild(buildSidebarShortcuts(), atStartOfMenu: .view) builder.insertChild(buildSidebarShortcuts(), atStartOfMenu: .view)
} }
private static func buildFileMenu() -> UIMenu { private static func buildFileMenu(builder: UIMenuBuilder) -> UIMenu {
var children: [UIMenuElement] = [
composeCommand,
refreshCommand(discoverabilityTitle: nil),
]
if let close = builder.menu(for: .close) {
children.append(close)
}
return UIMenu( return UIMenu(
title: "File", title: "File",
image: nil, image: nil,
identifier: nil, identifier: nil,
options: [], options: [],
children: [ children: children
composeCommand,
refreshCommand(discoverabilityTitle: nil),
UIKeyCommand(title: "Close", action: #selector(AppDelegate.closeWindow), input: "w", modifierFlags: .command),
]
) )
} }

View File

@ -0,0 +1,260 @@
//
// MultiColumnCollectionViewLayout.swift
// Tusker
//
// Created by Shadowfacts on 9/29/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
class MultiColumnCollectionViewLayout: UICollectionViewLayout {
private let numberOfColumns: Int
private let spacing: CGFloat
private let minimumColumnWidth: CGFloat
private var effectiveNumberOfColumns: Int!
private var attributes: [MultiColumnLayoutAttributes] = []
private var invalidatedItemIndices: IndexSet = []
var showSectionHeader = false {
didSet {
if showSectionHeader != oldValue {
invalidateLayout()
}
}
}
private var sectionHeaderAttributes: MultiColumnLayoutAttributes?
init(numberOfColumns: Int, columnSpacing: CGFloat, minimumColumnWidth: CGFloat) {
precondition(numberOfColumns >= 1)
self.numberOfColumns = numberOfColumns
self.effectiveNumberOfColumns = nil
self.spacing = columnSpacing
self.minimumColumnWidth = minimumColumnWidth
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override class var layoutAttributesClass: AnyClass {
MultiColumnLayoutAttributes.self
}
override class var invalidationContextClass: AnyClass {
MultiColumnLayoutInvalidationContext.self
}
override func prepare() {
guard let collectionView else { return }
precondition(collectionView.numberOfSections <= 1)
guard collectionView.numberOfSections == 1 else {
attributes = []
return
}
if effectiveNumberOfColumns == nil {
updateEffectiveNumberOfColumns()
}
var lastAttributesInEachColumn: [MultiColumnLayoutAttributes?] = Array(repeating: nil, count: effectiveNumberOfColumns)
func columnWithMinHeight() -> Int {
var min: Int?
for i in 0..<effectiveNumberOfColumns {
guard let attrs = lastAttributesInEachColumn[i] else {
return i
}
if min == nil || attrs.frame.maxY < lastAttributesInEachColumn[min!]!.frame.maxY {
min = i
}
}
return min!
}
let columnWidth = columnWidthForColumnCount(effectiveNumberOfColumns)
func minXForColumn(_ column: Int) -> CGFloat {
(CGFloat(column) * columnWidth) + ((CGFloat(column) + 1) * spacing)
}
let startY: CGFloat
if showSectionHeader {
let indexPath = IndexPath(item: 0, section: 0)
if sectionHeaderAttributes == nil {
sectionHeaderAttributes = .init(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: indexPath)
}
sectionHeaderAttributes!.frame = CGRect(x: 0, y: 0, width: collectionView.bounds.width, height: 0)
if let view = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: indexPath) {
let preferred = view.preferredLayoutAttributesFitting(sectionHeaderAttributes!)
sectionHeaderAttributes!.frame.size.height = preferred.frame.height
}
startY = sectionHeaderAttributes!.frame.height
} else {
startY = spacing
}
let numberOfItems = collectionView.numberOfItems(inSection: 0)
for item in 0..<numberOfItems {
let column = columnWithMinHeight()
let itemAttrs: MultiColumnLayoutAttributes
if attributes.count > item {
itemAttrs = attributes[item]
} else {
itemAttrs = MultiColumnLayoutAttributes(forCellWith: IndexPath(item: item, section: 0))
// estimate
itemAttrs.frame.size.height = 200
attributes.append(itemAttrs)
}
itemAttrs.column = column
itemAttrs.frame.size.width = columnWidth
if invalidatedItemIndices.contains(item) {
if let cell = collectionView.cellForItem(at: IndexPath(item: item, section: 0)) {
let preferred = cell.preferredLayoutAttributesFitting(itemAttrs.copy() as! UICollectionViewLayoutAttributes)
itemAttrs.frame.size.height = preferred.frame.height
}
}
if let lastInColumn = lastAttributesInEachColumn[column] {
itemAttrs.frame.origin = CGPoint(
x: lastInColumn.frame.minX,
y: lastInColumn.frame.maxY + spacing
)
} else {
itemAttrs.frame.origin = CGPoint(
x: minXForColumn(column),
y: startY
)
}
lastAttributesInEachColumn[column] = itemAttrs
}
if attributes.count > numberOfItems {
attributes.removeLast(attributes.count - numberOfItems)
}
invalidatedItemIndices = []
}
override var collectionViewContentSize: CGSize {
guard let collectionView else {
return .zero
}
let maxY = attributes.lazy.map(\.frame.maxY).max()
return CGSize(width: collectionView.bounds.width, height: (maxY ?? 0) + spacing)
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return attributes[indexPath.item]
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// TODO: optimize this
var attributes = attributes.filter { $0.frame.intersects(rect) }
if showSectionHeader,
let sectionHeaderAttributes,
rect.minY <= sectionHeaderAttributes.frame.maxY {
attributes.append(sectionHeaderAttributes)
}
return attributes
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
if elementKind == UICollectionView.elementKindSectionHeader,
showSectionHeader {
return sectionHeaderAttributes
} else {
return nil
}
}
override func indexPathsToInsertForSupplementaryView(ofKind elementKind: String) -> [IndexPath] {
if elementKind == UICollectionView.elementKindSectionHeader {
return [IndexPath(item: 0, section: 0)]
} else {
return []
}
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return newBounds.width != collectionView?.bounds.width
}
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! MultiColumnLayoutInvalidationContext
context.updateEffectiveNumberOfColumns = true
return context
}
// On landscape-to-portrait device rotations (though seemingly only when inside a UISplitViewController), the collection view
// doesn't go through the bounds-change codepath. But we may still need to update the effective number of columns,
// so override this private method (which is called) and configure the invalidation context appropriately.
override func _invalidationContext(forUpdatedLayoutMargins margins: UIEdgeInsets) -> UICollectionViewLayoutInvalidationContext {
let context = super._invalidationContext(forUpdatedLayoutMargins: margins) as! MultiColumnLayoutInvalidationContext
context.updateEffectiveNumberOfColumns = true
return context
}
override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
return preferredAttributes.frame.height != originalAttributes.frame.height
}
override func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes)
if let kind = preferredAttributes.representedElementKind {
context.invalidateSupplementaryElements(ofKind: kind, at: [preferredAttributes.indexPath])
} else {
context.invalidateItems(at: [preferredAttributes.indexPath])
}
return context
}
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
let context = context as! MultiColumnLayoutInvalidationContext
for indexPath in context.invalidatedItemIndexPaths ?? [] {
invalidatedItemIndices.insert(indexPath.item)
}
if context.invalidateEverything || context.updateEffectiveNumberOfColumns {
updateEffectiveNumberOfColumns()
}
super.invalidateLayout(with: context)
}
private func columnWidthForColumnCount(_ count: Int) -> CGFloat {
guard let collectionView else { return 0 }
let spacingTotal = spacing * (CGFloat(count) + 1)
return (collectionView.bounds.width - spacingTotal) / CGFloat(count)
}
private func updateEffectiveNumberOfColumns() {
var n = numberOfColumns
while n > 1 && columnWidthForColumnCount(n) < minimumColumnWidth {
n -= 1
}
effectiveNumberOfColumns = n
}
}
private class MultiColumnLayoutAttributes: UICollectionViewLayoutAttributes {
var column: Int = -1
override func copy(with zone: NSZone? = nil) -> Any {
let copy = super.copy(with: zone) as! MultiColumnLayoutAttributes
copy.column = column
return copy
}
}
private class MultiColumnLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext {
var updateEffectiveNumberOfColumns: Bool = false
}

View File

@ -112,7 +112,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
case .list(id: let id): case .list(id: let id):
let req = ListMO.fetchRequest(id: id) let req = ListMO.fetchRequest(id: id)
if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first { if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first {
return ListTimelineViewController(for: List(id: id, title: list.title), mastodonController: mastodonController) return ListTimelineViewController(for: list.apiList, mastodonController: mastodonController)
} else { } else {
return TimelineViewController(for: timeline, mastodonController: mastodonController) return TimelineViewController(for: timeline, mastodonController: mastodonController)
} }

View File

@ -91,7 +91,10 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
} }
private func updateTitle(draft: Draft) { private func updateTitle(draft: Draft) {
guard let scene = window?.windowScene, // Don't set the scene title on macOS since it shows both that and the VC title in the window titlebar
#if !targetEnvironment(macCatalyst)
guard !ProcessInfo.processInfo.isiOSAppOnMac,
let scene = window?.windowScene,
let mastodonController = scene.session.mastodonController else { let mastodonController = scene.session.mastodonController else {
return return
} }
@ -101,6 +104,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
} else { } else {
scene.title = "New Post" scene.title = "New Post"
} }
#endif
} }
@objc private func themePrefChanged() { @objc private func themePrefChanged() {

View File

@ -35,7 +35,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
window = UIWindow(windowScene: windowScene) window = UIWindow(windowScene: windowScene)
showAppOrOnboardingUI(session: session) showAppOrOnboardingUI(session: session)
if connectionOptions.urlContexts.count > 0 { if !connectionOptions.urlContexts.isEmpty {
self.scene(scene, openURLContexts: connectionOptions.urlContexts) self.scene(scene, openURLContexts: connectionOptions.urlContexts)
} }
@ -50,14 +50,21 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} }
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
if URLContexts.count > 1 { guard let url = URLContexts.first?.url,
fatalError("Cannot open more than 1 URL") var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let rootViewController else {
return
} }
let url = URLContexts.first!.url if components.host == "compose" {
if let mastodonController = window!.windowScene!.session.mastodonController {
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false), let draft = mastodonController.createDraft()
let rootViewController = rootViewController { let text = components.queryItems?.first(where: { $0.name == "text" })?.value
draft.text = text ?? ""
rootViewController.compose(editing: draft, animated: true, isDucked: false)
}
} else {
// Assume anything else is a search query
components.scheme = "https" components.scheme = "https"
let query = components.string! let query = components.string!
rootViewController.performSearch(query: query) rootViewController.performSearch(query: query)
@ -141,7 +148,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
minDate.addTimeInterval(-7 * 24 * 60 * 60) minDate.addTimeInterval(-7 * 24 * 60 * 60)
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest() let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate) statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (reblogs.@count = 0)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq) let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
deleteStatusReq.resultType = .resultTypeCount deleteStatusReq.resultType = .resultTypeCount
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult { if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {

View File

@ -56,9 +56,7 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.readableContentInset(in: environment)
section.contentInsetsReference = .readableContent
}
return section return section
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)

View File

@ -35,9 +35,7 @@ class AccountListViewController: UIViewController, CollectionViewController {
config.backgroundColor = .appGroupedBackground config.backgroundColor = .appGroupedBackground
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.readableContentInset(in: environment)
section.contentInsetsReference = .readableContent
}
return section return section
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)

View File

@ -15,6 +15,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
private let mastodonController: MastodonController private let mastodonController: MastodonController
private let mainStatusID: String private let mainStatusID: String
private let mainStatusState: CollapseState private let mainStatusState: CollapseState
private var mainStatusTranslation: Translation?
var statusIDToScrollToOnLoad: String var statusIDToScrollToOnLoad: String
var showStatusesAutomatically = false var showStatusesAutomatically = false
@ -59,10 +60,12 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
return config return config
} }
// we're not using contenetInsetsReference = .readableContent here because it always insets the cells even if let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
// the collection view's actual width is narrow enough to fit in the readable width, resulting in a bit of the let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
// background color always peeking through the edges section.readableContentInset(in: environment)
let layout = UICollectionViewCompositionalLayout.list(using: config) return section
}
viewRespectsSystemMinimumLayoutMargins = false
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
// something about the autoresizing mask breaks resizing the vc // something about the autoresizing mask breaks resizing the vc
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
@ -86,11 +89,14 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil) cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
cell.setShowThreadLinks(prev: item.2, next: item.3) cell.setShowThreadLinks(prev: item.2, next: item.3)
} }
let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Bool)> { [unowned self] cell, indexPath, item in let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Translation?, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self cell.delegate = self
cell.translateStatus = { [unowned self] in
self.translateMainStatus()
}
cell.showStatusAutomatically = self.showStatusesAutomatically cell.showStatusAutomatically = self.showStatusesAutomatically
cell.updateUI(statusID: item.0, state: item.1) cell.updateUI(statusID: item.0, state: item.1, translation: item.2)
cell.setShowThreadLinks(prev: item.2, next: false) cell.setShowThreadLinks(prev: item.3, next: false)
} }
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
cell.updateUI(childThreads: item.0, inline: item.1) cell.updateUI(childThreads: item.0, inline: item.1)
@ -102,7 +108,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
switch itemIdentifier { switch itemIdentifier {
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink): case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
if id == self.mainStatusID { if id == self.mainStatusID {
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink)) return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, self.mainStatusTranslation, prevLink))
} else { } else {
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink)) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink))
} }
@ -258,6 +264,30 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
} }
} }
private func translateMainStatus() {
Task { @MainActor in
let translation: Translation
do {
translation = try await mastodonController.run(Status.translate(mainStatusID)).0
} catch {
let config = ToastConfiguration(from: error, with: "Error Translating", in: self) { toast in
toast.dismissToast(animated: true)
self.translateMainStatus()
}
self.showToast(configuration: config, animated: true)
return
}
mainStatusTranslation = translation
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: .mainStatus))
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
} }
extension ConversationCollectionViewController { extension ConversationCollectionViewController {

View File

@ -112,7 +112,7 @@ class ConversationViewController: UIViewController {
appearance.configureWithDefaultBackground() appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance navigationItem.scrollEdgeAppearance = appearance
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
} }
private func updateVisibilityBarButtonItem() { private func updateVisibilityBarButtonItem() {
@ -145,8 +145,6 @@ class ConversationViewController: UIViewController {
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String], let statusIDs = userInfo["statusIDs"] as? [String],
case .localID(let mainStatusID) = mode else { case .localID(let mainStatusID) = mode else {
return return

View File

@ -59,14 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController! resultsController.exploreNavigationController = self.navigationController!
searchController = UISearchController(searchResultsController: resultsController) searchController = MastodonSearchController(searchResultsController: resultsController)
searchController.searchResultsUpdater = resultsController
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
definesPresentationContext = true definesPresentationContext = true
navigationItem.searchController = searchController navigationItem.searchController = searchController
@ -88,13 +81,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
) )
.sink { [unowned self] in self.updateHashtagsSection(followed: $0) } .sink { [unowned self] in self.updateHashtagsSection(followed: $0) }
.store(in: &cancellables) .store(in: &cancellables)
let a = PassthroughSubject<Int, Never>()
let b = PassthroughSubject<Int, Never>()
a.merge(with: b)
.sink(receiveValue: { print($0) })
.store(in: &cancellables)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -275,13 +261,16 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
} }
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) { private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
Task { @MainActor in Task {
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
if await service.run() { if await service.run() {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems([.list(list)]) snapshot.deleteItems([.list(list)])
await dataSource.apply(snapshot) await MainActor.run {
completion(true) dataSource.apply(snapshot, animatingDifferences: true) {
completion(true)
}
}
} else { } else {
completion(false) completion(false)
} }

View File

@ -34,14 +34,8 @@ class InlineTrendsViewController: UIViewController {
resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController) searchController = MastodonSearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = true searchController.obscuresBackgroundDuringPresentation = true
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
searchController.hidesNavigationBarDuringPresentation = false searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true definesPresentationContext = true

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/> <device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" minToolsVersion="11.0"/> <capability name="collection view cell content view" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -56,7 +56,7 @@
<color key="textColor" systemColor="secondaryLabelColor"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="749" scrollEnabled="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FJh-fd-fo8" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target"> <textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" verticalHuggingPriority="249" verticalCompressionResistancePriority="749" scrollEnabled="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FJh-fd-fo8" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="8" y="147" width="431" height="170"/> <rect key="frame" x="8" y="147" width="431" height="170"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string> <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/> <color key="textColor" systemColor="labelColor"/>
@ -113,7 +113,7 @@
<resources> <resources>
<image name="clock.arrow.circlepath" catalog="system" width="128" height="112"/> <image name="clock.arrow.circlepath" catalog="system" width="128" height="112"/>
<systemColor name="labelColor"> <systemColor name="labelColor">
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor> </systemColor>
<systemColor name="secondaryLabelColor"> <systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>

View File

@ -14,6 +14,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
var collectionView: UICollectionView! var collectionView: UICollectionView!
private var layout: MultiColumnCollectionViewLayout!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var state = State.unloaded private var state = State.unloaded
@ -33,30 +34,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
title = "Suggested Accounts" title = "Suggested Accounts"
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in layout = MultiColumnCollectionViewLayout(numberOfColumns: 2, columnSpacing: 16, minimumColumnWidth: 320)
switch dataSource.sectionIdentifier(for: sectionIndex) {
case nil:
fatalError()
case .loadingIndicator:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.showsSeparators = false
return .list(using: config, layoutEnvironment: environment)
case .accounts:
let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280))
let item = NSCollectionLayoutItem(layoutSize: size)
let item2 = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item, item2])
group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
group.interItemSpacing = .fixed(16)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 16
section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
return section
}
}
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.delegate = self collectionView.delegate = self
@ -75,21 +53,27 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
let accountCell = UICollectionView.CellRegistration<SuggestedProfileCardCollectionViewCell, (String, Suggestion.Source)>(cellNib: UINib(nibName: "SuggestedProfileCardCollectionViewCell", bundle: .main)) { cell, indexPath, item in let accountCell = UICollectionView.CellRegistration<SuggestedProfileCardCollectionViewCell, (String, Suggestion.Source)>(cellNib: UINib(nibName: "SuggestedProfileCardCollectionViewCell", bundle: .main)) { cell, indexPath, item in
cell.delegate = self cell.delegate = self
cell.updateUI(accountID: item.0, source: item.1) cell.updateUI(accountID: item.0, source: item.1)
} }
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier { switch itemIdentifier {
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
case .account(let id, let source): case .account(let id, let source):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: (id, source)) return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: (id, source))
} }
} }
let loadingView = UICollectionView.SupplementaryRegistration<LoadingCollectionViewCell>(elementKind: UICollectionView.elementKindSectionHeader) { view, elementKind, indexPath in
view.indicator.startAnimating()
}
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
if elementKind == UICollectionView.elementKindSectionHeader {
return collectionView.dequeueConfiguredReusableSupplementary(using: loadingView, for: indexPath)
} else {
return nil
}
}
return dataSource
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -107,9 +91,10 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
} }
state = .loading state = .loading
layout.showSectionHeader = true
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.loadingIndicator]) snapshot.appendSections([.accounts])
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot) await dataSource.apply(snapshot)
do { do {
@ -118,6 +103,8 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
await mastodonController.persistentContainer.addAll(accounts: suggestions.map(\.account)) await mastodonController.persistentContainer.addAll(accounts: suggestions.map(\.account))
layout.showSectionHeader = false
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) }) snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) })
@ -131,6 +118,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
await self?.loadInitial() await self?.loadInitial()
} }
showToast(configuration: config, animated: true) showToast(configuration: config, animated: true)
layout.showSectionHeader = false
} }
} }
@ -146,11 +134,9 @@ extension SuggestedProfilesViewController {
extension SuggestedProfilesViewController { extension SuggestedProfilesViewController {
enum Section { enum Section {
case loadingIndicator
case accounts case accounts
} }
enum Item: Hashable { enum Item: Hashable {
case loadingIndicator
case account(String, Suggestion.Source) case account(String, Suggestion.Source)
} }
} }

View File

@ -50,9 +50,17 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
case .links: case .links:
let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280)) let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280))
let item = NSCollectionLayoutItem(layoutSize: size) let group: NSCollectionLayoutGroup
let item2 = NSCollectionLayoutItem(layoutSize: size) if let maximumReadableWidth = environment.maximumReadableWidth,
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item, item2]) environment.container.contentSize.width >= maximumReadableWidth {
let width = (environment.container.contentSize.width - 48) / 2
group = .horizontal(layoutSize: size, subitems: [
NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(width), heightDimension: .estimated(280))),
NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(width), heightDimension: .estimated(280))),
])
} else {
group = .vertical(layoutSize: size, subitems: [NSCollectionLayoutItem(layoutSize: size)])
}
group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
group.interItemSpacing = .fixed(16) group.interItemSpacing = .fixed(16)
let section = NSCollectionLayoutSection(group: group) let section = NSCollectionLayoutSection(group: group)

View File

@ -62,9 +62,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.readableContentInset(in: environment)
section.contentInsetsReference = .readableContent
}
return section return section
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -104,7 +102,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -148,8 +146,6 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }

View File

@ -166,7 +166,9 @@ class FastAccountSwitcherViewController: UIViewController {
selectionChangedFeedbackGenerator = nil selectionChangedFeedbackGenerator = nil
hide() { hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount() if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate {
sceneDelegate.showAddAccount()
}
} }
} else { } else {
let account = UserAccountsManager.shared.accounts[newIndex - 1] let account = UserAccountsManager.shared.accounts[newIndex - 1]
@ -178,7 +180,9 @@ class FastAccountSwitcherViewController: UIViewController {
selectionChangedFeedbackGenerator = nil selectionChangedFeedbackGenerator = nil
hide() { hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).activateAccount(account, animated: true) if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate {
sceneDelegate.activateAccount(account, animated: true)
}
} }
} else { } else {
hide() hide()

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import UserAccounts import UserAccounts
import WebURL
class FastSwitchingAccountView: UIView { class FastSwitchingAccountView: UIView {
@ -126,7 +127,11 @@ class FastSwitchingAccountView: UIView {
private func setupAccount(account: UserAccountInfo) { private func setupAccount(account: UserAccountInfo) {
usernameLabel.text = account.username usernameLabel.text = account.username
instanceLabel.text = account.instanceURL.host! if let domain = WebURL.Domain(account.instanceURL.host!) {
instanceLabel.text = domain.render(.uncheckedUnicodeString)
} else {
instanceLabel.text = account.instanceURL.host!
}
let controller = MastodonController.getForAccount(account) let controller = MastodonController.getForAccount(account)
controller.getOwnAccount { [weak self] (result) in controller.getOwnAccount { [weak self] (result) in
guard let self = self, guard let self = self,
@ -140,7 +145,7 @@ class FastSwitchingAccountView: UIView {
} }
} }
accessibilityLabel = "\(account.username!)@\(account.instanceURL.host!)" accessibilityLabel = "\(account.username!)@\(instanceLabel.text!)"
} }
private func setupPlaceholder() { private func setupPlaceholder() {

View File

@ -16,7 +16,7 @@ protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get } var animationImage: UIImage? { get }
var activityItemsForSharing: [Any] { get } var activityItemsForSharing: [Any] { get }
var owner: LargeImageViewController? { get set } var owner: LargeImageViewController? { get set }
func setControlsVisible(_ controlsVisible: Bool) func setControlsVisible(_ controlsVisible: Bool, animated: Bool)
func grayscaleStateChanged() func grayscaleStateChanged()
} }
@ -75,13 +75,11 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func setControlsVisible(_ controlsVisible: Bool) { func setControlsVisible(_ controlsVisible: Bool, animated: Bool) {
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
if #available(iOS 16.0, *), if #available(iOS 16.0, *),
let analysisInteraction { let analysisInteraction {
// note: passing animated: true here doesn't seem to do anything by itself as of iOS 16.2 (20C5032e) analysisInteraction.setSupplementaryInterfaceHidden(!controlsVisible, animated: animated)
// so the LargeImageViewController handles animating, but we still need to pass true here otherwise it doesn't animate
analysisInteraction.setSupplementaryInterfaceHidden(!controlsVisible, animated: true)
} }
#endif #endif
} }
@ -138,7 +136,7 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func setControlsVisible(_ controlsVisible: Bool) { func setControlsVisible(_ controlsVisible: Bool, animated: Bool) {
} }
func grayscaleStateChanged() { func grayscaleStateChanged() {
@ -189,7 +187,7 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func setControlsVisible(_ controlsVisible: Bool) { func setControlsVisible(_ controlsVisible: Bool, animated: Bool) {
} }
func grayscaleStateChanged() { func grayscaleStateChanged() {

View File

@ -282,13 +282,17 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
self.controlsVisible = controlsVisible self.controlsVisible = controlsVisible
if animated { if animated {
UIView.animate(withDuration: 0.2) { UIView.animate(withDuration: 0.2) {
self.contentView.setControlsVisible(controlsVisible) // note: the value of animated: is passed to ImageAnalysisInteractino.setSupplementaryInterfaceHidden which (as of iOS 17.0.2 (21A350)):
// - does not animate with animated:false when wrapped in a UIView.animate block
// - does not animate with animated:true unless also wrapped in a UIView.animate block
self.contentView.setControlsVisible(controlsVisible, animated: true)
self.updateControlsView() self.updateControlsView()
} }
if controlsVisible && !descriptionTextView.isHidden { if controlsVisible && !descriptionTextView.isHidden {
descriptionTextView.flashScrollIndicators() descriptionTextView.flashScrollIndicators()
} }
} else { } else {
contentView.setControlsVisible(controlsVisible, animated: false)
updateControlsView() updateControlsView()
} }
} }

View File

@ -25,7 +25,14 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
} }
@objc func handleGesture(_ recognizer: UIPanGestureRecognizer) { @objc func handleGesture(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: recognizer.view!.superview!) guard let recognizerSuperview = recognizer.view?.superview else {
// Assume the gesture has ended b/c we don't have a view/superview anymore.
inProgress = false
direction = nil
cancel()
return
}
let translation = recognizer.translation(in: recognizerSuperview)
var progress = translation.y / 200 var progress = translation.y / 200
if let direction = direction { if let direction = direction {
progress *= direction progress *= direction
@ -63,7 +70,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
override func cancel() { override func cancel() {
super.cancel() super.cancel()
viewController.isInteractivelyAnimatingDismissal = false viewController?.isInteractivelyAnimatingDismissal = false
} }
} }

View File

@ -10,18 +10,21 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
class EditListAccountsViewController: EnhancedTableViewController { class EditListAccountsViewController: UIViewController, CollectionViewController {
private var list: List private var list: List
let mastodonController: MastodonController private let mastodonController: MastodonController
var changedAccounts = false private var state = State.unloaded
var dataSource: DataSource! private(set) var changedAccounts = false
var nextRange: RequestRange?
var searchResultsController: SearchResultsViewController! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var searchController: UISearchController! var collectionView: UICollectionView! { view as? UICollectionView }
private var nextRange: RequestRange?
private var searchResultsController: SearchResultsViewController!
private var searchController: UISearchController!
private var listRenamedCancellable: AnyCancellable? private var listRenamedCancellable: AnyCancellable?
@ -29,13 +32,12 @@ class EditListAccountsViewController: EnhancedTableViewController {
self.list = list self.list = list
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(style: .plain) super.init(nibName: nil, bundle: nil)
listChanged() listChanged()
listRenamedCancellable = mastodonController.$lists listRenamedCancellable = mastodonController.$lists
.compactMap { $0.first { $0.id == list.id } } .compactMap { $0.first { $0.id == list.id } }
.removeDuplicates(by: { $0.title == $1.title })
.sink { [unowned self] in .sink { [unowned self] in
self.list = $0 self.list = $0
self.listChanged() self.listChanged()
@ -46,29 +48,45 @@ class EditListAccountsViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemeneted") fatalError("init(coder:) has not been implemeneted")
} }
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
var config = sectionConfig
switch dataSource.itemIdentifier(for: indexPath)! {
case .loadingIndicator:
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
case .account(id: _):
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
return config
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in
switch dataSource.itemIdentifier(for: indexPath) {
case .account(id: let id):
let remove = UIContextualAction(style: .destructive, title: "Remove") { [unowned self] _, _, completion in
Task {
await self.removeAccount(id: id)
completion(true)
}
}
return UISwipeActionsConfiguration(actions: [remove])
default:
return nil
}
}
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.allowsSelection = false
collectionView.backgroundColor = .appGroupedBackground
dataSource = createDataSource()
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 66
tableView.allowsSelection = false
tableView.backgroundColor = .appGroupedBackground
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
guard case let .account(id) = item else { fatalError() }
let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell
cell.delegate = self
cell.updateUI(accountID: id)
cell.configurationUpdateHandler = { cell, state in
cell.backgroundConfiguration = .appListGroupedCell(for: state)
}
return cell
})
dataSource.editListAccountsController = self
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, scope: .people) searchResultsController = SearchResultsViewController(mastodonController: mastodonController, scope: .people)
searchResultsController.following = true searchResultsController.following = true
searchResultsController.delegate = self searchResultsController.delegate = self
@ -84,9 +102,54 @@ class EditListAccountsViewController: EnhancedTableViewController {
navigationItem.hidesSearchBarWhenScrolling = false navigationItem.hidesSearchBarWhenScrolling = false
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked navigationItem.preferredSearchBarPlacement = .stacked
}
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed)) navigationItem.renameDelegate = self
navigationItem.titleMenuProvider = { [unowned self] suggested in
var children = suggested
children.append(contentsOf: self.listSettingsMenuElements())
return UIMenu(children: children)
}
} else {
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [
// uncached so that menu always reflects the current state of the list
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
var elements = self.listSettingsMenuElements()
elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in
RenameListService(list: self.list, mastodonController: self.mastodonController, present: {
self.present($0, animated: true)
}).run()
}), at: 0)
elementHandler(elements)
})
]))
}
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(accountID: itemIdentifier)
cell.configurationUpdateHandler = { cell, state in
cell.backgroundConfiguration = .appListGroupedCell(for: state)
}
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
case .account(id: let id):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
Task { Task {
await loadAccounts() await loadAccounts()
@ -94,13 +157,48 @@ class EditListAccountsViewController: EnhancedTableViewController {
} }
private func listChanged() { private func listChanged() {
title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title) title = list.title
} }
func loadAccounts() async { private func listSettingsMenuElements() -> [UIMenuElement] {
var elements = [UIMenuElement]()
if mastodonController.instanceFeatures.listRepliesPolicy {
let actions = List.ReplyPolicy.allCases.map { policy in
UIAction(title: policy.actionTitle, state: list.replyPolicy == policy ? .on : .off) { [unowned self] _ in
self.setReplyPolicy(policy)
}
}
elements.append(UIMenu(title: "Show replies…", image: UIImage(systemName: "arrowshape.turn.up.left"), children: actions))
}
if mastodonController.instanceFeatures.exclusiveLists {
let actions = [
UIAction(title: "Hidden from Home", state: list.exclusive == true ? .on : .off) { [unowned self] _ in
self.setExclusive(true)
},
UIAction(title: "Shown on Home", state: list.exclusive == false ? .on : .off) { [unowned self] _ in
self.setExclusive(false)
},
]
elements.append(UIMenu(title: "Posts from this list are…", children: actions))
}
return elements
}
@MainActor
private func loadAccounts() async {
guard state == .unloaded else { return }
state = .loading
async let results = try await mastodonController.run(List.getAccounts(list.id))
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
do { do {
let request = List.getAccounts(list.id) let (accounts, pagination) = try await results
let (accounts, pagination) = try await mastodonController.run(request)
self.nextRange = pagination?.older self.nextRange = pagination?.older
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
@ -109,20 +207,61 @@ class EditListAccountsViewController: EnhancedTableViewController {
} }
} }
var snapshot = self.dataSource.snapshot() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
if snapshot.indexOfSection(.accounts) == nil { snapshot.appendSections([.accounts])
snapshot.appendSections([.accounts])
} else {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
}
snapshot.appendItems(accounts.map { .account(id: $0.id) }) snapshot.appendItems(accounts.map { .account(id: $0.id) })
await dataSource.apply(snapshot) await dataSource.apply(snapshot)
state = .loaded
} catch { } catch {
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self.loadAccounts() await self.loadAccounts()
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
state = .unloaded
await dataSource.apply(.init())
}
}
private func loadNextPage() async {
guard state == .loaded,
let nextRange else { return }
state = .loading
async let results = try await mastodonController.run(List.getAccounts(list.id, range: nextRange))
let origSnapshot = dataSource.snapshot()
var snapshot = origSnapshot
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
do {
let (accounts, pagination) = try await results
self.nextRange = pagination?.older
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(accounts: accounts) {
continuation.resume()
}
}
var snapshot = origSnapshot
snapshot.appendItems(accounts.map { .account(id: $0.id) })
await dataSource.apply(snapshot)
state = .loaded
} catch {
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.loadNextPage()
}
self.showToast(configuration: config, animated: true)
state = .loaded
await dataSource.apply(origSnapshot)
} }
} }
@ -157,43 +296,47 @@ class EditListAccountsViewController: EnhancedTableViewController {
} }
} }
// MARK: - Table view delegate private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) {
Task {
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
return .delete await service.run(replyPolicy: replyPolicy)
}
} }
// MARK: - Interaction private func setExclusive(_ exclusive: Bool) {
Task {
@objc func renameButtonPressed() { let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
RenameListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }).run() await service.run(exclusive: exclusive)
}
} }
} }
extension EditListAccountsViewController {
enum State {
case unloaded
case loading
case loaded
case loadingOlder
}
}
extension EditListAccountsViewController { extension EditListAccountsViewController {
enum Section: Hashable { enum Section: Hashable {
case accounts case accounts
} }
enum Item: Hashable { enum Item: Hashable {
case loadingIndicator
case account(id: String) case account(id: String)
} }
}
class DataSource: UITableViewDiffableDataSource<Section, Item> { extension EditListAccountsViewController: UICollectionViewDelegate {
weak var editListAccountsController: EditListAccountsViewController? func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if state == .loaded,
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { indexPath.item == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
return true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete,
case let .account(id) = itemIdentifier(for: indexPath) else {
return
}
Task { Task {
await self.editListAccountsController?.removeAccount(id: id) await loadNextPage()
} }
} }
} }
@ -216,3 +359,29 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
} }
} }
} }
extension EditListAccountsViewController: UINavigationItemRenameDelegate {
func navigationItem(_: UINavigationItem, shouldEndRenamingWith title: String) -> Bool {
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) {
Task {
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
await service.run(title: title)
}
}
}
private extension List.ReplyPolicy {
var actionTitle: String {
switch self {
case .followed:
"To accounts you follow"
case .list:
"To other list members"
case .none:
"Never"
}
}
}

View File

@ -59,7 +59,7 @@ class ListTimelineViewController: TimelineViewController {
func presentEdit(animated: Bool) { func presentEdit(animated: Bool) {
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController) let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) editListAccountsController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
let navController = UINavigationController(rootViewController: editListAccountsController) let navController = UINavigationController(rootViewController: editListAccountsController)
present(navController, animated: animated) present(navController, animated: animated)
} }

View File

@ -68,9 +68,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.readableContentInset(in: environment)
section.contentInsetsReference = .readableContent
}
return section return section
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -109,7 +107,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)")) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)"))
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext)
} }
@ -207,8 +205,6 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }

View File

@ -36,7 +36,7 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func updateUI(item: MainSidebarViewController.Item, account: UserAccountInfo) async { func updateUI(item: MainSidebarViewController.Item, account: UserAccountInfo) {
var config = defaultContentConfiguration() var config = defaultContentConfiguration()
config.text = item.title config.text = item.title
config.image = UIImage(systemName: item.imageName!) config.image = UIImage(systemName: item.imageName!)
@ -50,6 +50,12 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
] ]
} }
Task {
await updateAvatar(account: account)
}
}
private func updateAvatar(account: UserAccountInfo) async {
let mastodonController = MastodonController.getForAccount(account) let mastodonController = MastodonController.getForAccount(account)
guard let account = try? await mastodonController.getOwnAccount(), guard let account = try? await mastodonController.getOwnAccount(),
let avatar = account.avatar else { let avatar = account.avatar else {

View File

@ -139,9 +139,7 @@ class MainSidebarViewController: UIViewController {
} }
let myProfileCell = UICollectionView.CellRegistration<MainSidebarMyProfileCollectionViewCell, Item> { [unowned self] cell, indexPath, item in let myProfileCell = UICollectionView.CellRegistration<MainSidebarMyProfileCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
Task { cell.updateUI(item: item, account: self.mastodonController.accountInfo!)
await cell.updateUI(item: item, account: self.mastodonController.accountInfo!)
}
} }
let outlineHeaderCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in let outlineHeaderCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in

View File

@ -117,6 +117,8 @@ class MainSplitViewController: UISplitViewController {
guard mode != navigationMode else { guard mode != navigationMode else {
return return
} }
navigationMode = mode
let viewControllers = secondaryNavController.viewControllers let viewControllers = secondaryNavController.viewControllers
secondaryNavController.viewControllers = [] secondaryNavController.viewControllers = []
// Setting viewControllers = [] doesn't remove the VC views from their superviews immediately, // Setting viewControllers = [] doesn't remove the VC views from their superviews immediately,
@ -212,6 +214,10 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
if item == sidebar.selectedItem { if item == sidebar.selectedItem {
itemNavStack = secondaryNavController.viewControllers itemNavStack = secondaryNavController.viewControllers
secondaryNavController.viewControllers = [] secondaryNavController.viewControllers = []
// Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy
for vc in itemNavStack {
vc.viewIfLoaded?.removeFromSuperview()
}
} else { } else {
itemNavStack = navigationStacks[item] ?? [] itemNavStack = navigationStacks[item] ?? []
navigationStacks.removeValue(forKey: item) navigationStacks.removeValue(forKey: item)
@ -337,6 +343,11 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst) let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst)
navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst) navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst)
// Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy
for vc in viewControllersToMove {
vc.viewIfLoaded?.removeFromSuperview()
}
if let prepend = prepend { if let prepend = prepend {
navigationStacks[item] = [prepend] + viewControllersToMove navigationStacks[item] = [prepend] + viewControllersToMove
} else { } else {

View File

@ -139,19 +139,25 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
mastodonController.persistentContainer.account(for: $0.account.id) mastodonController.persistentContainer.account(for: $0.account.id)
} }
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() } let visibleAvatars = Array(people.lazy.compactMap(\.avatar).prefix(10))
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) { for (index, avatarURL) in visibleAvatars.enumerated() {
let imageView = CachedImageView(cache: .avatars) let imageView: CachedImageView
imageView.contentMode = .scaleAspectFill if index < avatarStack.arrangedSubviews.count {
imageView.layer.masksToBounds = true imageView = avatarStack.arrangedSubviews[index] as! CachedImageView
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 } else {
imageView.layer.cornerCurve = .continuous imageView = CachedImageView(cache: .avatars)
avatarStack.addArrangedSubview(imageView) imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous
avatarStack.addArrangedSubview(imageView)
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
}
imageView.update(for: avatarURL) imageView.update(for: avatarURL)
} }
NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map { while avatarStack.arrangedSubviews.count > visibleAvatars.count {
$0.widthAnchor.constraint(equalTo: $0.heightAnchor) avatarStack.arrangedSubviews.last!.removeFromSuperview()
}) }
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id) actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)

View File

@ -116,19 +116,25 @@ class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
}, identifier: group.id) }, identifier: group.id)
updateTimestamp() updateTimestamp()
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() } let visibleAvatars = Array(people.lazy.compactMap(\.avatar).prefix(10))
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) { for (index, avatarURL) in visibleAvatars.enumerated() {
let imageView = CachedImageView(cache: .avatars) let imageView: CachedImageView
imageView.contentMode = .scaleAspectFill if index < avatarStack.arrangedSubviews.count {
imageView.layer.masksToBounds = true imageView = avatarStack.arrangedSubviews[index] as! CachedImageView
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 } else {
imageView.layer.cornerCurve = .continuous imageView = CachedImageView(cache: .avatars)
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous
avatarStack.addArrangedSubview(imageView)
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
}
imageView.update(for: avatarURL) imageView.update(for: avatarURL)
avatarStack.addArrangedSubview(imageView)
} }
NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map { while avatarStack.arrangedSubviews.count > visibleAvatars.count {
$0.widthAnchor.constraint(equalTo: $0.heightAnchor) avatarStack.arrangedSubviews.last!.removeFromSuperview()
}) }
} }
private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString { private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {

View File

@ -23,6 +23,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
$0.contentMode = .scaleAspectFill $0.contentMode = .scaleAspectFill
$0.layer.masksToBounds = true $0.layer.masksToBounds = true
$0.layer.cornerCurve = .continuous $0.layer.cornerCurve = .continuous
$0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
$0.widthAnchor.constraint(equalTo: $0.heightAnchor), $0.widthAnchor.constraint(equalTo: $0.heightAnchor),
]) ])

View File

@ -25,6 +25,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
private(set) var collectionView: UICollectionView! private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange? private var older: RequestRange?
@ -91,9 +93,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.readableContentInset(in: environment)
section.contentInsetsReference = .readableContent
}
return section return section
} }
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -121,7 +121,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
self.reapplyFilters(actionsChanged: actionsChanged) self.reapplyFilters(actionsChanged: actionsChanged)
} }
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -257,8 +257,6 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }
@ -539,6 +537,10 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 { if indexPath.row == itemsInSection - 1 {
Task { Task {
// Because of grouping, all cells from the first load may fit on screen,
// in which case, we try to load older while still in the loadingInitial state.
// So, wait for that to finish before trying to load more.
await controller.finishPendingOperation()
await controller.loadOlder() await controller.loadOlder()
} }
} }
@ -662,6 +664,13 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
} }
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
}
} }
extension NotificationsCollectionViewController: UICollectionViewDragDelegate { extension NotificationsCollectionViewController: UICollectionViewDragDelegate {

View File

@ -150,7 +150,8 @@ class InstanceSelectorTableViewController: UITableViewController {
private func updateSpecificInstance(domain: String) { private func updateSpecificInstance(domain: String) {
activityIndicator.startAnimating() activityIndicator.startAnimating()
guard let components = parseURLComponents(input: domain) else { guard let components = parseURLComponents(input: domain),
let url = components.url else {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil { if snapshot.indexOfSection(.selected) != nil {
snapshot.deleteSections([.selected]) snapshot.deleteSections([.selected])
@ -159,10 +160,9 @@ class InstanceSelectorTableViewController: UITableViewController {
activityIndicator.stopAnimating() activityIndicator.stopAnimating()
return return
} }
let url = components.url!
let client = Client(baseURL: url, session: .appDefault) let client = Client(baseURL: url, session: .appDefault)
let request = Client.getInstance() let request = Client.getInstanceV1()
client.run(request) { (response) in client.run(request) { (response) in
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil { if snapshot.indexOfSection(.selected) != nil {
@ -309,7 +309,7 @@ extension InstanceSelectorTableViewController {
case recommendedInstances case recommendedInstances
} }
enum Item: Equatable, Hashable { enum Item: Equatable, Hashable {
case selected(URL, Instance) case selected(URL, InstanceV1)
case recommended(InstanceSelector.Instance) case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {

View File

@ -90,11 +90,11 @@ struct AppearancePrefsView : View {
@ViewBuilder @ViewBuilder
private var interfaceSection: some View { private var interfaceSection: some View {
if preferences.hasFeatureFlag(.iPadNavigationMode), if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Section(header: Text("Interface")) { Section(header: Text("Interface")) {
WidescreenNavigationPrefsView() WidescreenNavigationPrefsView()
} }
.appGroupedListRowBackground()
} }
} }
@ -118,11 +118,17 @@ struct AppearancePrefsView : View {
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) { Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
Text("Always Show Status Visibility Icons") Text("Always Show Status Visibility Icons")
} }
Toggle(isOn: $preferences.showLinkPreviews) {
Text("Show Link Previews")
}
Toggle(isOn: $preferences.showAttachmentsInTimeline) {
Text("Show Attachments on Timeline")
}
Toggle(isOn: $preferences.hideActionsInTimeline) { Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline") Text("Hide Actions on Timeline")
} }
Toggle(isOn: $preferences.showLinkPreviews) { Toggle(isOn: $preferences.underlineTextLinks) {
Text("Show Link Previews") Text("Underline Links")
} }
NavigationLink("Leading Swipe Actions") { NavigationLink("Leading Swipe Actions") {
SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions) SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions)

View File

@ -28,9 +28,11 @@ struct ComposingPrefsView: View {
var visibilitySection: some View { var visibilitySection: some View {
Section { Section {
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) { Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) {
ForEach(Visibility.allCases, id: \.self) { visibility in ForEach(PostVisibility.allCases, id: \.self) { visibility in
HStack { HStack {
Image(systemName: visibility.imageName) if let imageName = visibility.imageName {
Image(systemName: imageName)
}
Text(visibility.displayName) Text(visibility.displayName)
} }
.tag(visibility) .tag(visibility)
@ -38,7 +40,7 @@ struct ComposingPrefsView: View {
// navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291 // navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291
} }
Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) { Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) {
ForEach(Preferences.ReplyVisibility.allCases, id: \.self) { visibility in ForEach(ReplyVisibility.allCases, id: \.self) { visibility in
HStack { HStack {
if let imageName = visibility.imageName { if let imageName = visibility.imageName {
Image(systemName: imageName) Image(systemName: imageName)

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import UserAccounts import UserAccounts
import WebURL
struct PreferencesView: View { struct PreferencesView: View {
let mastodonController: MastodonController let mastodonController: MastodonController
@ -41,7 +42,12 @@ struct PreferencesView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(verbatim: account.username) Text(verbatim: account.username)
.foregroundColor(.primary) .foregroundColor(.primary)
Text(verbatim: account.instanceURL.host!) let instance = if let domain = WebURL.Domain(account.instanceURL.host!) {
domain.render(.uncheckedUnicodeString)
} else {
account.instanceURL.host!
}
Text(verbatim: instance)
.font(.caption) .font(.caption)
.foregroundColor(.primary) .foregroundColor(.primary)
} }

View File

@ -32,6 +32,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var headerCell: ProfileHeaderCollectionViewCell? private(set) var headerCell: ProfileHeaderCollectionViewCell?
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private(set) var state: State = .unloaded private(set) var state: State = .unloaded
init(accountID: String?, kind: Kind, owner: ProfileViewController) { init(accountID: String?, kind: Kind, owner: ProfileViewController) {
@ -65,18 +67,25 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
} }
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
guard let item = self.dataSource.itemIdentifier(for: indexPath) else { guard let item = self.dataSource.itemIdentifier(for: indexPath),
let section = self.dataSource.sectionIdentifier(for: indexPath.section) else {
return sectionSeparatorConfiguration return sectionSeparatorConfiguration
} }
var config = sectionSeparatorConfiguration var config = sectionSeparatorConfiguration
if item.hideSeparators { if item.hideSeparators {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} else if section == .header {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorInsets = .zero
} else if indexPath.row == 0 && (section == .pinned || section == .entries) {
// TODO: row == 0 isn't technically right, the top post could be filtered out
config.topSeparatorInsets = .zero
} else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item, } else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item,
filterer.isKnownHide(state: filterState) { filterer.isKnownHide(state: filterState) {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} else if case .status(_, _, _, _) = item { } else {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
} }
@ -86,12 +95,11 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
if case .header = dataSource.sectionIdentifier(for: sectionIndex) { if case .header = dataSource.sectionIdentifier(for: sectionIndex) {
var config = UICollectionLayoutListConfiguration(appearance: .plain) var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground config.backgroundColor = .appBackground
config.separatorConfiguration.bottomSeparatorInsets = .zero
return .list(using: config, layoutEnvironment: environment) return .list(using: config, layoutEnvironment: environment)
} else { } else {
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.readableContentInset(in: environment)
section.contentInsetsReference = .readableContent
}
return section return section
} }
} }
@ -148,7 +156,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.reapplyFilters(actionsChanged: actionsChanged) self.reapplyFilters(actionsChanged: actionsChanged)
} }
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -376,8 +384,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }
@ -627,6 +633,13 @@ extension ProfileStatusesViewController: UICollectionViewDelegate {
return true return true
} }
} }
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
}
} }
extension ProfileStatusesViewController: UICollectionViewDragDelegate { extension ProfileStatusesViewController: UICollectionViewDragDelegate {

View File

@ -0,0 +1,175 @@
//
// MastodonSearchController.swift
// Tusker
//
// Created by Shadowfacts on 10/1/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
private let acctRegex = try! NSRegularExpression(pattern: "[a-z0-9_]+(@[a-z0-9\\-\\.]+[a-z0-9]+)?$", options: .caseInsensitive)
private let dateLikeRegex = try! NSRegularExpression(pattern: "\\d{4}(-\\d{2}(-\\d{2})?)?$")
private let languageRegex = try! NSRegularExpression(pattern: "(?:language:)?(\\w{2,3})$", options: .caseInsensitive)
class MastodonSearchController: UISearchController {
override var delegate: UISearchControllerDelegate? {
willSet {
precondition(newValue === self)
}
}
override var searchResultsController: SearchResultsViewController {
super.searchResultsController as! SearchResultsViewController
}
init(searchResultsController: SearchResultsViewController) {
super.init(searchResultsController: searchResultsController)
searchResultsController.tokenHandler = { [unowned self] token, op in
self.addToken(token, operator: op)
}
delegate = self
searchResultsUpdater = searchResultsController
automaticallyShowsSearchResultsController = false
showsSearchResultsController = true
if #available(iOS 16.0, *) {
scopeBarActivation = .onSearchActivation
}
searchBar.autocapitalizationType = .none
searchBar.delegate = self
searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateTokenSuggestions(searchText: String, animated: Bool) {
guard searchResultsController.mastodonController.instanceFeatures.searchOperators else {
return
}
let searchText = searchText.trimmingCharacters(in: .whitespaces)
var suggestions: [(SearchOperatorType, [String])] = []
suggestions.append((.has, ["has:media", "has:poll", "has:embed"].filter {
searchText.isEmpty || $0.contains(searchText)
}))
suggestions.append((.is, ["is:reply", "is:sensitive"].filter {
searchText.isEmpty || $0.contains(searchText)
}))
var langSuggestions = [String]()
let defaultLanguage = searchResultsController.mastodonController.accountPreferences.serverDefaultLanguage ?? "en"
let languageToken = "language:\(defaultLanguage)"
if searchText.isEmpty || languageToken.contains(searchText) {
langSuggestions.append(languageToken)
}
if searchText != defaultLanguage,
let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let identifier = (searchText as NSString).substring(with: match.range(at: 1))
if #available(iOS 16.0, *) {
if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) {
langSuggestions.append("language:\(identifier)")
}
} else if searchText != "en" {
langSuggestions.append("language:\(searchText)")
}
}
suggestions.append((.language, langSuggestions))
var fromSuggestions = [String]()
if searchText.isEmpty || "from:me".contains(searchText) {
fromSuggestions.append("from:me")
}
if searchText != "me",
let match = acctRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let matched = (searchText as NSString).substring(with: match.range)
fromSuggestions.append("from:\(matched)")
}
suggestions.append((.from, fromSuggestions))
let components = Calendar.current.dateComponents([.year, .month], from: Date())
for op in [SearchOperatorType.before, .during, .after] {
if searchText.isEmpty {
suggestions.append((op, ["\(op.rawValue):\(components.year!)-\(components.month!)"]))
} else if let match = dateLikeRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let matched = (searchText as NSString).substring(with: match.range)
suggestions.append((op, ["\(op.rawValue):\(matched)"]))
}
}
suggestions.append((.in, ["in:all", "in:library"].filter {
searchText.isEmpty || $0.contains(searchText)
}))
searchResultsController.updateTokenSuggestions(suggestions, animated: animated)
}
private func addToken(_ token: String, operator: SearchOperatorType) {
let field = searchBar.searchTextField
if field.tokens.contains(where: { ($0.representedObject as? String) == token }) {
return
}
let searchToken = UISearchToken(icon: nil, text: token)
searchToken.representedObject = token
field.insertToken(searchToken, at: field.tokens.count)
field.text = ""
let tokenPos = field.positionOfToken(at: field.tokens.count - 1)
field.selectedTextRange = field.textRange(from: tokenPos, to: field.endOfDocument)
if let requiredScope = `operator`.requiredScope,
let index = searchBar.scopeButtonTitles?.firstIndex(of: requiredScope.title) {
searchBar.selectedScopeButtonIndex = index
searchBar(searchBar, selectedScopeButtonIndexDidChange: index)
}
}
}
extension MastodonSearchController: UISearchControllerDelegate {
func willPresentSearchController(_ searchController: UISearchController) {
updateTokenSuggestions(searchText: "", animated: false)
}
}
extension MastodonSearchController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
updateTokenSuggestions(searchText: searchText, animated: true)
}
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
searchResultsController.searchBarTextDidEndEditing(searchBar)
}
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
searchResultsController.searchBar(searchBar, selectedScopeButtonIndexDidChange: selectedScope)
}
}
extension UISearchBar {
var searchQueryWithOperators: String {
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }
let query = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !query.isEmpty {
parts.append(query)
}
return parts.joined(separator: " ")
}
}
private extension SearchOperatorType {
var requiredScope: SearchResultsViewController.Scope? {
switch self {
case .is, .from, .in:
return .posts
default:
return nil
}
}
}

View File

@ -33,6 +33,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
weak var exploreNavigationController: UINavigationController? weak var exploreNavigationController: UINavigationController?
weak var delegate: SearchResultsViewControllerDelegate? weak var delegate: SearchResultsViewControllerDelegate?
var tokenHandler: ((String, SearchOperatorType) -> Void)?
var collectionView: UICollectionView! { view as? UICollectionView } var collectionView: UICollectionView! { view as? UICollectionView }
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -42,8 +43,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
/// Whether to limit results to accounts the users is following. /// Whether to limit results to accounts the users is following.
var following: Bool? = nil var following: Bool? = nil
let searchSubject = PassthroughSubject<String?, Never>() private let searchSubject = PassthroughSubject<String?, Never>()
var currentQuery: String? private var searchCancellable: AnyCancellable?
private var currentQuery: String?
init(mastodonController: MastodonController, scope: Scope = .all) { init(mastodonController: MastodonController, scope: Scope = .all) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -60,29 +62,43 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
override func loadView() { override func loadView() {
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
var config = UICollectionLayoutListConfiguration(appearance: .grouped) let sectionIdentifier = self.dataSource.sectionIdentifier(for: sectionIndex)!
config.backgroundColor = .appGroupedBackground switch sectionIdentifier {
config.headerMode = .supplementary case .tokenSuggestions(_):
switch self.dataSource.sectionIdentifier(for: sectionIndex) { let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(100), heightDimension: .absolute(30))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.interGroupSpacing = 8
section.contentInsets = NSDirectionalEdgeInsets(top: sectionIndex == 0 ? 16 : 4, leading: 16, bottom: 4, trailing: 16)
return section
case .loadingIndicator: case .loadingIndicator:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.showsSeparators = false config.showsSeparators = false
config.headerMode = .none config.headerMode = .none
case .statuses: return .list(using: config, layoutEnvironment: environment)
case .accounts, .hashtags, .statuses:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.headerMode = .supplementary
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() (self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
} }
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() (self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
} }
config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets if sectionIdentifier == .statuses {
config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
default: config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
break }
// we don't use the readable content inset here, because it insets the entire cell, rather than just the content
// so the cell backgrounds not being full width looks weird
return .list(using: config, layoutEnvironment: environment)
} }
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
// we don't use the readable content inset here, because it insets the entire cell, rather than just the content
// so the cell backgrounds not being full width looks weird
return section
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self collectionView.delegate = self
@ -97,15 +113,14 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
_ = searchSubject searchCancellable = searchSubject
.debounce(for: .milliseconds(500), scheduler: RunLoop.main) .debounce(for: .seconds(1), scheduler: RunLoop.main)
.map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0 != self.currentQuery } .sink { [unowned self] in self.performSearch(query: $0) }
.sink(receiveValue: performSearch(query:))
userActivity = UserActivityManager.searchActivity(query: nil, accountID: mastodonController.accountInfo!.id) userActivity = UserActivityManager.searchActivity(query: nil, accountID: mastodonController.accountInfo!.id)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -115,6 +130,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
config.text = section.displayName config.text = section.displayName
supplementaryView.contentConfiguration = config supplementaryView.contentConfiguration = config
} }
let tokenSuggestionCell = UICollectionView.CellRegistration<SearchTokenSuggestionCollectionViewCell, String> { cell, indexPath, itemIdentifier in
cell.setText(itemIdentifier)
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating() cell.indicator.startAnimating()
} }
@ -132,6 +150,8 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
let cell: UICollectionViewCell let cell: UICollectionViewCell
switch itemIdentifier { switch itemIdentifier {
case .tokenSuggestion(let value):
return collectionView.dequeueConfiguredReusableCell(using: tokenSuggestionCell, for: indexPath, item: value)
case .loadingIndicator: case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
case .account(let accountID): case .account(let accountID):
@ -172,6 +192,45 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
return super.targetViewController(forAction: action, sender: sender) return super.targetViewController(forAction: action, sender: sender)
} }
func updateTokenSuggestions(_ suggestions: [(SearchOperatorType, [String])], animated: Bool) {
var snapshot = dataSource.snapshot()
var prev: Section?
for (op, values) in suggestions {
let section = Section.tokenSuggestions(op)
if values.isEmpty {
if snapshot.sectionIdentifiers.contains(section) {
snapshot.deleteSections([section])
}
} else {
if !snapshot.sectionIdentifiers.contains(section) {
if let prev {
snapshot.insertSections([section], afterSection: prev)
} else if let first = snapshot.sectionIdentifiers.first {
snapshot.insertSections([section], beforeSection: first)
} else {
snapshot.appendSections([section])
}
} else {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: section))
}
snapshot.appendItems(values.map { .tokenSuggestion($0) }, toSection: section)
prev = section
}
}
dataSource.apply(snapshot, animatingDifferences: animated)
}
func removeResults() {
var snapshot = dataSource.snapshot()
removeResults(from: &snapshot)
dataSource.apply(snapshot, animatingDifferences: false)
}
private func removeResults(from snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
snapshot.deleteSections([Section.loadingIndicator, .accounts, .hashtags, .statuses].filter { snapshot.sectionIdentifiers.contains($0) })
}
func loadResults(from source: SearchResultsViewController) { func loadResults(from source: SearchResultsViewController) {
currentQuery = source.currentQuery currentQuery = source.currentQuery
if let sourceDataSource = source.dataSource { if let sourceDataSource = source.dataSource {
@ -180,16 +239,18 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
} }
func performSearch(query: String?) { func performSearch(query: String?) {
guard isViewLoaded else { guard isViewLoaded,
query != currentQuery else {
return return
} }
guard let query = query, !query.isEmpty else { guard let query = query, !query.isEmpty else {
self.dataSource.apply(NSDiffableDataSourceSnapshot<Section, Item>()) removeResults()
return return
} }
self.currentQuery = query self.currentQuery = query
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = dataSource.snapshot()
removeResults(from: &snapshot)
snapshot.appendSections([.loadingIndicator]) snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator]) snapshot.appendItems([.loadingIndicator])
dataSource.apply(snapshot) dataSource.apply(snapshot)
@ -209,7 +270,8 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
} }
private func showSearchResults(_ results: SearchResults) { private func showSearchResults(_ results: SearchResults) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = dataSource.snapshot()
snapshot.deleteSections([.loadingIndicator])
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
let resultTypes = self.scope.resultTypes let resultTypes = self.scope.resultTypes
@ -247,8 +309,6 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }
@ -304,7 +364,8 @@ extension SearchResultsViewController {
} }
extension SearchResultsViewController { extension SearchResultsViewController {
enum Section: CaseIterable { enum Section: Hashable {
case tokenSuggestions(SearchOperatorType)
case loadingIndicator case loadingIndicator
case accounts case accounts
case hashtags case hashtags
@ -312,6 +373,8 @@ extension SearchResultsViewController {
var displayName: String? { var displayName: String? {
switch self { switch self {
case .tokenSuggestions:
return nil
case .loadingIndicator: case .loadingIndicator:
return nil return nil
case .accounts: case .accounts:
@ -324,6 +387,7 @@ extension SearchResultsViewController {
} }
} }
enum Item: Hashable { enum Item: Hashable {
case tokenSuggestion(String)
case loadingIndicator case loadingIndicator
case account(String) case account(String)
case hashtag(Hashtag) case hashtag(Hashtag)
@ -331,6 +395,9 @@ extension SearchResultsViewController {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case let .tokenSuggestion(value):
hasher.combine("tokenSuggestion")
hasher.combine(value)
case .loadingIndicator: case .loadingIndicator:
hasher.combine("loadingIndicator") hasher.combine("loadingIndicator")
case let .account(id): case let .account(id):
@ -347,6 +414,8 @@ extension SearchResultsViewController {
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.tokenSuggestion(let a), .tokenSuggestion(let b)):
return a == b
case (.loadingIndicator, .loadingIndicator): case (.loadingIndicator, .loadingIndicator):
return true return true
case (.account(let a), .account(let b)): case (.account(let a), .account(let b)):
@ -376,6 +445,12 @@ extension SearchResultsViewController: UICollectionViewDelegate {
switch dataSource.itemIdentifier(for: indexPath) { switch dataSource.itemIdentifier(for: indexPath) {
case nil, .loadingIndicator: case nil, .loadingIndicator:
return return
case .tokenSuggestion(let value):
guard case .tokenSuggestions(let op) = dataSource.sectionIdentifier(for: indexPath.section) else {
return
}
tokenHandler?(value, op)
collectionView.deselectItem(at: indexPath, animated: true)
case let .account(id): case let .account(id):
if let delegate { if let delegate {
delegate.selectedSearchResult(account: id) delegate.selectedSearchResult(account: id)
@ -403,7 +478,7 @@ extension SearchResultsViewController: UICollectionViewDelegate {
return nil return nil
} }
switch item { switch item {
case .loadingIndicator: case .loadingIndicator, .tokenSuggestion(_):
return nil return nil
case .account(let id): case .account(let id):
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
@ -436,7 +511,7 @@ extension SearchResultsViewController: UICollectionViewDragDelegate {
let url: URL let url: URL
let activity: NSUserActivity let activity: NSUserActivity
switch item { switch item {
case .loadingIndicator: case .loadingIndicator, .tokenSuggestion(_):
return [] return []
case .account(let id): case .account(let id):
guard let account = mastodonController.persistentContainer.account(for: id) else { guard let account = mastodonController.persistentContainer.account(for: id) else {
@ -464,18 +539,29 @@ extension SearchResultsViewController: UICollectionViewDragDelegate {
extension SearchResultsViewController: UISearchResultsUpdating { extension SearchResultsViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) { func updateSearchResults(for searchController: UISearchController) {
searchSubject.send(searchController.searchBar.text) searchSubject.send(searchController.searchBar.searchQueryWithOperators)
} }
} }
extension SearchResultsViewController: UISearchBarDelegate { extension SearchResultsViewController: UISearchBarDelegate {
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
var snapshot = dataSource.snapshot()
let allSuggestionSections = snapshot.sectionIdentifiers.filter {
if case .tokenSuggestions(_) = $0 {
return true
} else {
return false
}
}
snapshot.deleteSections(allSuggestionSections)
dataSource.apply(snapshot, animatingDifferences: true)
// perform a search immedaitely when the search button is pressed // perform a search immedaitely when the search button is pressed
performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines)) performSearch(query: searchBar.searchQueryWithOperators)
} }
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
let newQuery = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines) let newQuery = searchBar.searchQueryWithOperators
let newScope = Scope.allCases[selectedScope] let newScope = Scope.allCases[selectedScope]
if self.scope == .all && currentQuery == newQuery { if self.scope == .all && currentQuery == newQuery {
self.scope = newScope self.scope = newScope

View File

@ -0,0 +1,42 @@
//
// SearchTokenSuggestionCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/1/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
label.textColor = .tintColor
contentView.backgroundColor = .tintColor.withAlphaComponent(0.2)
contentView.layer.masksToBounds = true
contentView.layer.cornerRadius = 6
contentView.layer.cornerCurve = .continuous
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
label.topAnchor.constraint(equalTo: contentView.topAnchor),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setText(_ text: String) {
label.text = text
}
}

View File

@ -60,6 +60,7 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
return config return config
} }
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
let section: NSCollectionLayoutSection
switch dataSource.sectionIdentifier(for: sectionIndex)! { switch dataSource.sectionIdentifier(for: sectionIndex)! {
case .status: case .status:
var config = UICollectionLayoutListConfiguration(appearance: .grouped) var config = UICollectionLayoutListConfiguration(appearance: .grouped)
@ -71,14 +72,12 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
} }
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
return section
case .accounts: case .accounts:
return NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment) section = NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment)
} }
section.readableContentInset(in: environment)
return section
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self collectionView.delegate = self

View File

@ -84,7 +84,7 @@ class StatusActionAccountListViewController: UIViewController {
view.backgroundColor = .appBackground view.backgroundColor = .appBackground
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -99,8 +99,6 @@ class StatusActionAccountListViewController: UIViewController {
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }

View File

@ -61,13 +61,34 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
} }
private let contentContainer = StatusContentContainer<StatusEditContentTextView, StatusEditPollView>(useTopSpacer: false).configure { private lazy var contentContainer = StatusContentContainer(arrangedSubviews: [
$0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont contentTextView,
$0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont cardView,
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle attachmentsView,
pollView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical) $0.setContentHuggingPriority(.defaultLow, for: .vertical)
} }
private let contentTextView = StatusEditContentTextView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
$0.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
}
private let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
private let attachmentsView = AttachmentsContainerView()
private let pollView = StatusEditPollView()
weak var delegate: StatusEditCollectionViewCellDelegate? weak var delegate: StatusEditCollectionViewCellDelegate?
private var mastodonController: MastodonController! { delegate?.apiController } private var mastodonController: MastodonController! { delegate?.apiController }
@ -91,8 +112,76 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
// todo: accessibility // MARK: Accessibility
override var isAccessibilityElement: Bool {
get { true }
set {}
}
override var accessibilityAttributedLabel: NSAttributedString? {
get {
var str: AttributedString = ""
if statusState.collapsed ?? false {
if !edit.spoilerText.isEmpty {
str += AttributedString(edit.spoilerText)
str += ", "
}
str += "collapsed"
} else {
str += AttributedString(contentTextView.attributedText)
if edit.attachments.count > 0 {
let includeDescriptions: Bool
switch Preferences.shared.attachmentBlurMode {
case .useStatusSetting:
includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || edit.spoilerText.isEmpty
case .always:
includeDescriptions = true
case .never:
includeDescriptions = false
}
if includeDescriptions {
if edit.attachments.count == 1 {
let attachment = edit.attachments[0]
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment: \(desc)")
} else {
for (index, attachment) in edit.attachments.enumerated() {
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment \(index + 1): \(desc)")
}
}
} else {
str += AttributedString(", \(edit.attachments.count) attachment\(edit.attachments.count == 1 ? "" : "s")")
}
}
if edit.poll != nil {
str += ", poll"
}
}
return NSAttributedString(str)
}
set {}
}
override var accessibilityHint: String? {
get {
if statusState.collapsed ?? false {
return "Double tap to expand the post."
} else {
return nil
}
}
set {}
}
override func accessibilityActivate() -> Bool {
if statusState.collapsed ?? false {
collapseButtonPressed()
}
return true
}
// MARK: Configure UI // MARK: Configure UI
@ -102,13 +191,13 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt) timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt)
contentContainer.contentTextView.setTextFrom(edit: edit, index: index) contentTextView.setTextFrom(edit: edit, index: index)
contentContainer.contentTextView.navigationDelegate = delegate contentTextView.navigationDelegate = delegate
contentContainer.attachmentsView.delegate = self attachmentsView.delegate = self
contentContainer.attachmentsView.updateUI(attachments: edit.attachments) attachmentsView.updateUI(attachments: edit.attachments)
contentContainer.pollView.isHidden = edit.poll == nil pollView.isHidden = edit.poll == nil
contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis) pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
contentContainer.cardView.isHidden = true cardView.isHidden = true
contentWarningLabel.text = edit.spoilerText contentWarningLabel.text = edit.spoilerText
contentWarningLabel.isHidden = edit.spoilerText.isEmpty contentWarningLabel.isHidden = edit.spoilerText.isEmpty
@ -151,9 +240,9 @@ extension StatusEditCollectionViewCell: AttachmentViewDelegate {
guard let delegate else { guard let delegate else {
return nil return nil
} }
let attachments = contentContainer.attachmentsView.attachments! let attachments = attachmentsView.attachments!
let sourceViews = attachments.map { let sourceViews = attachments.map {
contentContainer.attachmentsView.getAttachmentView(for: $0) attachmentsView.getAttachmentView(for: $0)
} }
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index) let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
return gallery return gallery

View File

@ -54,9 +54,7 @@ class StatusEditHistoryViewController: UIViewController, CollectionViewControlle
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.readableContentInset(in: environment)
section.contentInsetsReference = .readableContent
}
return section return section
} }
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class StatusEditPollView: UIStackView, StatusContentPollView { class StatusEditPollView: UIStackView, StatusContentView {
private var titleLabels: [EmojiLabel] = [] private var titleLabels: [EmojiLabel] = []

View File

@ -20,6 +20,8 @@ class TimelineJumpButton: UIView {
var config = UIButton.Configuration.plain() var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: "arrow.up") config.image = UIImage(systemName: "arrow.up")
config.contentInsets = .zero config.contentInsets = .zero
// We don't want a background for this button, even when accessibility button shapes are enabled, because it's in the navbar.
config.background.backgroundColor = .clear
return UIButton(configuration: config) return UIButton(configuration: config)
}() }()

View File

@ -42,6 +42,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var collectionView: UICollectionView! private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>() private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>()
// the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing // the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing
@ -107,12 +109,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
return config return config
} }
// just setting layout.configuration.contentInsetsReference doesn't work with UICollectionViewCompositionalLayout.list // Use the sectionProvider closure, because the content inset depends on the environment.
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.readableContentInset(in: environment)
section.contentInsetsReference = .readableContent
}
return section return section
} }
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -162,7 +162,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
Preferences.shared.$showAttachmentsInTimeline
// skip the initial value
.dropFirst()
// publisher fires on willChange, wait the change is actually made
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
var snapshot = self.dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers)
self.dataSource.apply(snapshot, animatingDifferences: false)
}
.store(in: &cancellables)
if userActivity != nil { if userActivity != nil {
userActivityNeedsUpdate userActivityNeedsUpdate
@ -180,6 +192,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) { func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = self cell.delegate = self
cell.showAttachmentsInline = Preferences.shared.showAttachmentsInTimeline
if case .home = timeline { if case .home = timeline {
cell.showFollowedHashtags = true cell.showFollowedHashtags = true
} else { } else {
@ -407,7 +420,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
let hasStatusesToRestore = await loadStatusesToRestore(position: position) let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore { if hasStatusesToRestore {
applyItemsToRestore(position: position) await applyItemsToRestore(position: position)
loaded = true loaded = true
} }
case .mastodon: case .mastodon:
@ -430,7 +443,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
position.centerStatusID = centerStatusID position.centerStatusID = centerStatusID
let hasStatusesToRestore = await loadStatusesToRestore(position: position) let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore { if hasStatusesToRestore {
applyItemsToRestore(position: position) await applyItemsToRestore(position: position)
} }
mastodonController.persistentContainer.viewContext.delete(position) mastodonController.persistentContainer.viewContext.delete(position)
} }
@ -440,7 +453,25 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
let originalPositionStatusIDs = position.statusIDs let originalPositionStatusIDs = position.statusIDs
let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil }) var unloaded = [String]()
for id in position.statusIDs {
if let status = mastodonController.persistentContainer.status(for: id) {
// touch the status so that, even if it's old, it doesn't get pruned when we go into the background
status.touch()
// there was a bug where th the reblogged status would get pruned even when it was still refernced by the reblog
// as a temporary workaround, until there are no longer user db's in this state,
// check if the reblog is invalid and reload the status if so
if let reblog = status.reblog,
// force the fault to fire
case _ = reblog.id,
reblog.isDeleted {
unloaded.append(id)
}
} else {
unloaded.append(id)
}
}
guard !unloaded.isEmpty else { guard !unloaded.isEmpty else {
return true return true
} }
@ -501,7 +532,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
@MainActor @MainActor
private func applyItemsToRestore(position: TimelinePosition) { private func applyItemsToRestore(position: TimelinePosition) async {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
let statusIDs = position.statusIDs let statusIDs = position.statusIDs
@ -514,14 +545,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
"statusIDs": position.statusIDs "statusIDs": position.statusIDs
] ]
SentrySDK.addBreadcrumb(crumb) SentrySDK.addBreadcrumb(crumb)
dataSource.apply(snapshot, animatingDifferences: false) { await apply(snapshot, animatingDifferences: false)
if let centerStatusID, if let centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID) { let index = statusIDs.firstIndex(of: centerStatusID) {
self.scrollToItem(item: items[index]) self.scrollToItem(item: items[index])
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)") stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else { } else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
} }
} }
@ -566,6 +596,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
var count = 0 var count = 0
while count < 5 { while count < 5 {
count += 1 count += 1
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
crumb.message = "scrollToItem, attempt=\(count)"
SentrySDK.addBreadcrumb(crumb)
let origOffset = self.collectionView.contentOffset let origOffset = self.collectionView.contentOffset
self.collectionView.layoutIfNeeded() self.collectionView.layoutIfNeeded()
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
@ -718,10 +753,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@objc func refresh() { @objc func refresh() {
Task { @MainActor in Task { @MainActor in
if case .notLoadedInitial = controller.state { if case .idle = controller.state,
await controller.loadInitial() !dataSource.snapshot().itemIdentifiers(inSection: .statuses).isEmpty {
} else {
await controller.loadNewer() await controller.loadNewer()
} else {
await controller.loadInitial()
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing() collectionView.refreshControl?.endRefreshing()
@ -929,8 +965,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }
@ -1157,7 +1191,10 @@ extension TimelineViewController {
let addedItems: Bool let addedItems: Bool
let statusItems = snapshot.itemIdentifiers(inSection: .statuses) let statusItems = snapshot.itemIdentifiers(inSection: .statuses)
let gapIndex = statusItems.firstIndex(of: .gap)! guard let gapIndex = statusItems.firstIndex(of: .gap) else {
// Not sure how this is reachable (maybe the gap cell was tapped twice and the requests raced?) but w/e
return
}
switch direction { switch direction {
case .above: case .above:
@ -1284,6 +1321,9 @@ extension TimelineViewController: UICollectionViewDelegate {
selected(status: status.reblog?.id ?? id, state: collapseState.copy()) selected(status: status.reblog?.id ?? id, state: collapseState.copy())
} }
case .gap: case .gap:
guard controller.state == .idle else {
return
}
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
cell.showsIndicator = true cell.showsIndicator = true
Task { Task {
@ -1317,6 +1357,11 @@ extension TimelineViewController: UICollectionViewDelegate {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
userActivityNeedsUpdate.send() userActivityNeedsUpdate.send()
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
} }
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {

View File

@ -1,92 +0,0 @@
//
// EnhancedTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/10/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import SafariServices
class EnhancedTableViewController: UITableViewController {
var dragEnabled = false
override func viewDidLoad() {
super.viewDidLoad()
if dragEnabled {
tableView.dragDelegate = self
}
}
// MARK: Table View Delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell {
cell.didSelectCell()
}
}
}
extension EnhancedTableViewController {
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
if let cell = tableView.cellForRow(at: indexPath) as? UITableViewCell & MenuPreviewProvider {
let cellLocation = cell.convert(point, from: tableView)
guard let (previewProvider, actionsProvider) = cell.getPreviewProviders(for: cellLocation, sourceViewController: self) else {
return nil
}
let actionProvider: UIContextMenuActionProvider = { (_) in
let suggested = self.getSuggestedContextMenuActions(tableView: tableView, indexPath: indexPath, point: point)
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: suggested + actionsProvider())
}
return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider)
} else {
return nil
}
}
// todo: replace this with the UIKit suggested actions, if possible
@objc open func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
return []
}
override func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: self)
} else {
self.show(viewController, sender: nil)
}
}
}
}
}
extension EnhancedTableViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let cell = tableView.cellForRow(at: indexPath) as? DraggableTableViewCell else {
return []
}
return cell.dragItemsForBeginning(session: session)
}
}
extension EnhancedTableViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
tableView.scrollToTop()
}
}
extension EnhancedTableViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
tableView.scrollToTop()
return .stop
}
}

View File

@ -21,6 +21,8 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon
var collectionView: UICollectionView! { get } var collectionView: UICollectionView! { get }
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get } var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
var reconfigureVisibleItemsOnEndDecelerating: Bool { get set }
} }
protocol TimelineLikeCollectionViewSection: Hashable, Sendable { protocol TimelineLikeCollectionViewSection: Hashable, Sendable {
@ -125,6 +127,18 @@ extension TimelineLikeCollectionViewController {
var config: ToastConfiguration var config: ToastConfiguration
if let error = error as? Self.Error, if let error = error as? Self.Error,
error == .allCaughtUp { error == .allCaughtUp {
// Reconfigure visible items to update timestamps.
#if targetEnvironment(macCatalyst)
let isRefreshing = false
#else
let isRefreshing = collectionView.refreshControl?.isRefreshing ?? false
#endif
if isRefreshing {
reconfigureVisibleItemsOnEndDecelerating = true
} else {
reconfigureVisibleCells()
}
config = ToastConfiguration(title: "You're all caught up") config = ToastConfiguration(title: "You're all caught up")
config.edge = .top config.edge = .top
config.dismissAutomaticallyAfter = 2 config.dismissAutomaticallyAfter = 2
@ -198,10 +212,19 @@ extension TimelineLikeCollectionViewController {
// apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods // apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods
// but we always want to update the data source on the main thread for consistency, so this method does that // but we always want to update the data source on the main thread for consistency, so this method does that
func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async { func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async {
let task = Task { @MainActor in await MainActor.run {
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) dataSource?.apply(snapshot, animatingDifferences: animatingDifferences)
}
}
@MainActor
func reconfigureVisibleCells() {
let items = collectionView.indexPathsForVisibleItems.compactMap { dataSource.itemIdentifier(for: $0) }
if !items.isEmpty {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(items)
dataSource.apply(snapshot, animatingDifferences: false)
} }
await task.value
} }
func registerTimelineLikeCells() { func registerTimelineLikeCells() {

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import OSLog import OSLog
import Combine
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject { protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
associatedtype TimelineItem: Sendable associatedtype TimelineItem: Sendable
@ -42,7 +43,7 @@ class TimelineLikeController<Item: Sendable> {
private unowned var delegate: any TimelineLikeControllerDelegate<Item> private unowned var delegate: any TimelineLikeControllerDelegate<Item>
private let ownerType: String private let ownerType: String
private(set) var state = State.notLoadedInitial { @AsyncObservable private(set) var state = State.notLoadedInitial {
willSet { willSet {
guard state.canTransition(to: newValue) else { guard state.canTransition(to: newValue) else {
logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)") logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
@ -57,6 +58,19 @@ class TimelineLikeController<Item: Sendable> {
self.ownerType = ownerType self.ownerType = ownerType
} }
/// Waits for the controller to finish the current operation and arrive at the idle state.
///
/// If the current state is `notLoadedInitial`, this will wait until the controller
/// settles after the initial load.
func finishPendingOperation() async {
guard state != .idle else {
return
}
for await state in $state where state == .idle {
break
}
}
func loadInitial() async { func loadInitial() async {
guard state == .notLoadedInitial || state == .idle else { guard state == .notLoadedInitial || state == .idle else {
return return
@ -369,3 +383,17 @@ enum TimelineGapDirection {
} }
} }
} }
// I would love to be able to do this with @Observable, but it's not clear how to do so.
@propertyWrapper
private class AsyncObservable<Value>: ObservableObject {
@Published var wrappedValue: Value
var projectedValue: AsyncPublisher<Published<Value>.Publisher> {
$wrappedValue.values
}
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}

View File

@ -3,6 +3,7 @@
// //
#import <Foundation/Foundation.h> #import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_INLINE NSException * _Nullable catchNSException(void(^_Nonnull tryBlock)(void)) { NS_INLINE NSException * _Nullable catchNSException(void(^_Nonnull tryBlock)(void)) {
@try { @try {
@ -13,3 +14,8 @@ NS_INLINE NSException * _Nullable catchNSException(void(^_Nonnull tryBlock)(void
} }
return nil; return nil;
} }
// Define this private method so we can override it from MultiColumnCollectionViewLayout.
@interface UICollectionViewLayout (Tusker_Hacks)
-(UICollectionViewLayoutInvalidationContext *)_invalidationContextForUpdatedLayoutMargins:(UIEdgeInsets)newMargins;
@end

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