Compare commits
84 Commits
53611d80d6
...
5a4323067a
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 5a4323067a | |
Shadowfacts | 43d8434e17 | |
Shadowfacts | e8576277e0 | |
Shadowfacts | 7f0a9d8d5a | |
Shadowfacts | 51f4a780e2 | |
Shadowfacts | 180a8eb18d | |
Shadowfacts | eb61043867 | |
Shadowfacts | e09935125f | |
Shadowfacts | e8ef9345e9 | |
Shadowfacts | 28c1a9092b | |
Shadowfacts | 5e609aa40d | |
Shadowfacts | 158940f8e6 | |
Shadowfacts | 141e8b96a5 | |
Shadowfacts | 108a02826f | |
Shadowfacts | be1ca70ebf | |
Shadowfacts | 34edd8a13f | |
Shadowfacts | 23f383a7f9 | |
Shadowfacts | 99caaa0f28 | |
Shadowfacts | 0f70c9059e | |
Shadowfacts | 6d7074e71d | |
Shadowfacts | 13809b91d1 | |
Shadowfacts | 16f6dc84c9 | |
Shadowfacts | cdfb06f4a7 | |
Shadowfacts | 4e98e569eb | |
Shadowfacts | 6d3ffd7dd3 | |
Shadowfacts | ca7fe74a90 | |
Shadowfacts | 380f878d81 | |
Shadowfacts | 1c36312850 | |
Shadowfacts | de946be008 | |
Shadowfacts | b40d815274 | |
Shadowfacts | bc7500bde9 | |
Shadowfacts | 676e603ffc | |
Shadowfacts | cb47443649 | |
Shadowfacts | 86862825f6 | |
Shadowfacts | e6f1968609 | |
Shadowfacts | 4c5da1b5a9 | |
Shadowfacts | e57ef210fd | |
Shadowfacts | dcdfe853e1 | |
Shadowfacts | 34e57c297b | |
Shadowfacts | 6c2c2e6ae7 | |
Shadowfacts | aae3bd0bba | |
Shadowfacts | 2b5d4681e3 | |
Shadowfacts | e4eff2d362 | |
Shadowfacts | 37311e5f17 | |
Shadowfacts | af5a0b7bbd | |
Shadowfacts | 3aa45cb365 | |
Shadowfacts | a07b398cbe | |
Shadowfacts | 2ccec2f4df | |
Shadowfacts | 0de9a9fd37 | |
Shadowfacts | bd21e88e8b | |
Shadowfacts | 2464e2530f | |
Shadowfacts | 44021d3ad2 | |
Shadowfacts | a46eaafbcf | |
Shadowfacts | eb496243c7 | |
Shadowfacts | 6e5e0c3bb5 | |
Shadowfacts | dfc8234908 | |
Shadowfacts | 157c8629a9 | |
Shadowfacts | bde21fbc6c | |
Shadowfacts | 74820e8922 | |
Shadowfacts | f7a9075b77 | |
Shadowfacts | 4af56e48bf | |
Shadowfacts | c4bf5d406d | |
Shadowfacts | 53d43b5707 | |
Shadowfacts | b1564d822e | |
Shadowfacts | a8a2f0a26c | |
Shadowfacts | 46e1205327 | |
Shadowfacts | 6a2de2be55 | |
Shadowfacts | db6ba0c62c | |
Shadowfacts | 16029dc161 | |
Shadowfacts | 31a0db014a | |
Shadowfacts | 5be8005e24 | |
Shadowfacts | ad4e112e96 | |
Shadowfacts | 7a2dc7d3c4 | |
Shadowfacts | 0948371f83 | |
Shadowfacts | 3ba1a00257 | |
Shadowfacts | 1b42cd7816 | |
Shadowfacts | a2fe0dfb78 | |
Shadowfacts | bf1ed57180 | |
Shadowfacts | 6821f1b9a0 | |
Shadowfacts | 7ae741cd83 | |
Shadowfacts | fe9ad83ddc | |
Shadowfacts | 6b7c828cc9 | |
Shadowfacts | 2be1ee19de | |
Shadowfacts | 3f15a453bd |
|
@ -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.
|
||||||
|
|
||||||
|
|
70
CHANGELOG.md
70
CHANGELOG.md
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
do {
|
||||||
return try await mastodonController.run(req).0
|
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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
// 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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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!)))
|
||||||
|
|
|
@ -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"/>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,16 +45,13 @@ 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 {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var languageCode: Binding<Locale.LanguageCode> {
|
private var languageCode: Binding<Locale.LanguageCode> {
|
||||||
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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`
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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" */ = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true) {
|
||||||
completion(true)
|
completion(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"/>
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
if let domain = WebURL.Domain(account.instanceURL.host!) {
|
||||||
|
instanceLabel.text = domain.render(.uncheckedUnicodeString)
|
||||||
|
} else {
|
||||||
instanceLabel.text = account.instanceURL.host!
|
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() {
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.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)
|
||||||
|
})
|
||||||
|
]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
|
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> {
|
|
||||||
weak var editListAccountsController: EditListAccountsViewController?
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension EditListAccountsViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||||
|
if state == .loaded,
|
||||||
|
indexPath.item == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
if index < avatarStack.arrangedSubviews.count {
|
||||||
|
imageView = avatarStack.arrangedSubviews[index] as! CachedImageView
|
||||||
|
} else {
|
||||||
|
imageView = CachedImageView(cache: .avatars)
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
imageView.layer.masksToBounds = true
|
imageView.layer.masksToBounds = true
|
||||||
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
|
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
|
||||||
imageView.layer.cornerCurve = .continuous
|
imageView.layer.cornerCurve = .continuous
|
||||||
avatarStack.addArrangedSubview(imageView)
|
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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
if index < avatarStack.arrangedSubviews.count {
|
||||||
|
imageView = avatarStack.arrangedSubviews[index] as! CachedImageView
|
||||||
|
} else {
|
||||||
|
imageView = CachedImageView(cache: .avatars)
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
imageView.layer.masksToBounds = true
|
imageView.layer.masksToBounds = true
|
||||||
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
|
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
|
||||||
imageView.layer.cornerCurve = .continuous
|
imageView.layer.cornerCurve = .continuous
|
||||||
imageView.update(for: avatarURL)
|
|
||||||
avatarStack.addArrangedSubview(imageView)
|
avatarStack.addArrangedSubview(imageView)
|
||||||
|
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
|
||||||
|
}
|
||||||
|
imageView.update(for: avatarURL)
|
||||||
|
}
|
||||||
|
while avatarStack.arrangedSubviews.count > visibleAvatars.count {
|
||||||
|
avatarStack.arrangedSubviews.last!.removeFromSuperview()
|
||||||
}
|
}
|
||||||
NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map {
|
|
||||||
$0.widthAnchor.constraint(equalTo: $0.heightAnchor)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
|
private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {
|
||||||
|
|
|
@ -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),
|
||||||
])
|
])
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
let sectionIdentifier = self.dataSource.sectionIdentifier(for: sectionIndex)!
|
||||||
|
switch sectionIdentifier {
|
||||||
|
case .tokenSuggestions(_):
|
||||||
|
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:
|
||||||
|
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||||
|
config.backgroundColor = .appGroupedBackground
|
||||||
|
config.showsSeparators = false
|
||||||
|
config.headerMode = .none
|
||||||
|
return .list(using: config, layoutEnvironment: environment)
|
||||||
|
|
||||||
|
case .accounts, .hashtags, .statuses:
|
||||||
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||||
config.backgroundColor = .appGroupedBackground
|
config.backgroundColor = .appGroupedBackground
|
||||||
config.headerMode = .supplementary
|
config.headerMode = .supplementary
|
||||||
switch self.dataSource.sectionIdentifier(for: sectionIndex) {
|
|
||||||
case .loadingIndicator:
|
|
||||||
config.showsSeparators = false
|
|
||||||
config.headerMode = .none
|
|
||||||
case .statuses:
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
if sectionIdentifier == .statuses {
|
||||||
config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
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
|
// 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
|
// so the cell backgrounds not being full width looks weird
|
||||||
return section
|
return .list(using: config, layoutEnvironment: environment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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] = []
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -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,7 +545,7 @@ 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])
|
||||||
|
@ -523,7 +554,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func restoreStatusesFromMarkerPosition() async -> Bool {
|
private func restoreStatusesFromMarkerPosition() async -> Bool {
|
||||||
|
@ -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) {
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue