diff --git a/CHANGELOG-release.md b/CHANGELOG-release.md index d027d219..cf8087c8 100644 --- a/CHANGELOG-release.md +++ b/CHANGELOG-release.md @@ -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 This update adds support for iOS 17 and includes some minor changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9efdee82..a1ca7146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 2024.1 (111) +This build contains a complete rewrite of the HTML parsing pipeline for displaying posts. If you notice any issues with how post text appears—especially when it differs from on the web—please report it! + +## 2023.8 (110) +Bugfixes: +- Fix potential crash after deleting List on Explore screen + +## 2023.8 (109) +Features/Improvements: +- Add Translate action to conversations (on supported Mastodon instances) +- Improve share extension launch speed +- Add preference for hiding attachments in timelines + +Bugfixes: +- Fix crash during state restoration when reblogged statuses are present +- Fix timeline state restoration using incorrect scroll position in certain circumstances +- Fix status that is reblogged and contains a followed hashtag not showing reblogger label +- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar +- macOS: Fix images copied from Safari not pasting on Compose screen + +## 2023.8 (107) +Features/Improvements: +- Style blockquotes in statuses +- Use server language preference for search operator suggestions +- Render IDN domains in the account switcher + +Bugfixes: +- Fix crash when showing trending hashtags with improper history data +- Fix crash when uploading attachment w/o file extension +- Fix status deletions not being handled properly in logged out views +- Fix status history entries not having VoiceOver descriptions +- Fix avatars in follow request notifications not being rounded +- Fix potential crash if the app is dismissed while fast account switcher is animating +- Fix error decoding certain statuses on Pixelfed + ## 2023.8 (106) Bugfixes: - Fix being able to set post language to multiple/undefined diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index 6733c17d..c7de164d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -114,13 +114,9 @@ class PostService: ObservableObject { } catch let error as DraftAttachment.ExportError { throw Error.attachmentData(index: index, cause: error) } - do { - let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription) - attachments.append(uploaded.id) - currentStep += 1 - } catch let error as Client.Error { - throw Error.attachmentUpload(index: index, cause: error) - } + let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription) + attachments.append(uploaded.id) + currentStep += 1 } return attachments } @@ -138,10 +134,21 @@ class PostService: ObservableObject { } } - private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment { - let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)") + private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment { + guard let mimeType = utType.preferredMIMEType else { + throw Error.attachmentMissingMimeType(index: index, type: utType) + } + var filename = "file" + if let ext = utType.preferredFilenameExtension { + filename.append(".\(ext)") + } + let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename) let req = Client.upload(attachment: formAttachment, description: description) - return try await mastodonController.run(req).0 + do { + return try await mastodonController.run(req).0 + } catch let error as Client.Error { + throw Error.attachmentUpload(index: index, cause: error) + } } private func textForPosting() -> String { @@ -170,6 +177,7 @@ class PostService: ObservableObject { enum Error: Swift.Error, LocalizedError { case attachmentData(index: Int, cause: DraftAttachment.ExportError) + case attachmentMissingMimeType(index: Int, type: UTType) case attachmentUpload(index: Int, cause: Client.Error) case posting(Client.Error) @@ -177,6 +185,8 @@ class PostService: ObservableObject { switch self { case let .attachmentData(index: index, cause: cause): return "Attachment \(index + 1): \(cause.localizedDescription)" + case let .attachmentMissingMimeType(index: index, type: type): + return "Attachment \(index + 1): unknown MIME type for \(type.identifier)" case let .attachmentUpload(index: index, cause: cause): return "Attachment \(index + 1): \(cause.localizedDescription)" case let .posting(error): diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift index a81227eb..71976520 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift @@ -90,7 +90,7 @@ class ToolbarController: ViewController { cwButton MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly) - #if !os(visionOS) + #if !targetEnvironment(macCatalyst) && !os(visionOS) // the button has a bunch of extra space by default, but combined with what we add it's too much .padding(.horizontal, -8) #endif @@ -99,7 +99,9 @@ class ToolbarController: ViewController { if composeController.mastodonController.instanceFeatures.localOnlyPosts { localOnlyPicker - #if !os(visionOS) + #if targetEnvironment(macCatalyst) + .padding(.leading, 4) + #elseif !os(visionOS) .padding(.horizontal, -8) #endif .disabled(draft.editedStatusID != nil) @@ -125,7 +127,6 @@ class ToolbarController: ViewController { LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection) } } - .buttonStyle(.borderless) } private var cwButton: some View { diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift index 0def2175..64ecf945 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift @@ -157,10 +157,11 @@ extension DraftAttachment: NSItemProviderReading { var data = data var type = UTType(typeIdentifier)! - // this seems to only occur when the item is a UIImage, rather than just image data, - // which seems to only occur when sharing a screenshot directly from the markup screen + // the type is .image in certain circumstances: + // - macOS: image copied from macOS Safari -> only UIImage(data: data) works + // - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works if type == .image, - let image = 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() { data = pngData type = .png diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index cfd35891..5324ee1c 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -21,7 +21,8 @@ public class InstanceFeatures: ObservableObject { @Published public private(set) var charsReservedPerURL = 23 @Published public private(set) var maxPollOptionChars: Int? @Published public private(set) var maxPollOptionsCount: Int? - @Published public private(set) var mediaAttachmentsConfiguration: Instance.MediaAttachmentsConfiguration? + @Published public private(set) var mediaAttachmentsConfiguration: InstanceV1.MediaAttachmentsConfiguration? + @Published public private(set) var translation: Bool = false public var localOnlyPosts: Bool { switch instanceType { @@ -240,6 +241,7 @@ public class InstanceFeatures: ObservableObject { maxPollOptionsCount = pollsConfig.maxOptions } mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments + translation = instance.translation _featuresUpdated.send() } diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceInfo.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceInfo.swift index d4a5e984..5685867b 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceInfo.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceInfo.swift @@ -9,26 +9,39 @@ import Foundation import Pachyderm public struct InstanceInfo { - public let version: String - public let maxStatusCharacters: Int? - public let configuration: Instance.Configuration? - public let pollsConfiguration: Instance.PollsConfiguration? + public var version: String + public var maxStatusCharacters: Int? + public var configuration: InstanceV1.Configuration? + public var pollsConfiguration: InstanceV1.PollsConfiguration? + public var translation: Bool - public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) { + public init( + version: String, + maxStatusCharacters: Int?, + configuration: InstanceV1.Configuration?, + pollsConfiguration: InstanceV1.PollsConfiguration?, + translation: Bool + ) { self.version = version self.maxStatusCharacters = maxStatusCharacters self.configuration = configuration self.pollsConfiguration = pollsConfiguration + self.translation = translation } } extension InstanceInfo { - public init(instance: Instance) { + public init(v1 instance: InstanceV1) { self.init( version: instance.version, maxStatusCharacters: instance.maxStatusCharacters, configuration: instance.configuration, - pollsConfiguration: instance.pollsConfiguration + pollsConfiguration: instance.pollsConfiguration, + translation: false ) } + + public mutating func update(v2: InstanceV2) { + translation = v2.configuration.translation.enabled + } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index f4bed14c..c94473df 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -231,8 +231,12 @@ public class Client { return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts) } - public static func getInstance() -> Request { - return Request(method: .get, path: "/api/v1/instance") + public static func getInstanceV1() -> Request { + return Request(method: .get, path: "/api/v1/instance") + } + + public static func getInstanceV2() -> Request { + return Request(method: .get, path: "/api/v2/instance") } public static func getCustomEmoji() -> Request<[Emoji]> { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Instance.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV1.swift similarity index 94% rename from Packages/Pachyderm/Sources/Pachyderm/Model/Instance.swift rename to Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV1.swift index b90ca413..57b380bc 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Instance.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV1.swift @@ -1,5 +1,5 @@ // -// Instance.swift +// InstanceV1.swift // Pachyderm // // Created by Shadowfacts on 9/9/18. @@ -8,7 +8,7 @@ import Foundation -public struct Instance: Decodable, Sendable { +public struct InstanceV1: Decodable, Sendable { public let uri: String public let title: String public let description: String @@ -92,7 +92,7 @@ public struct Instance: Decodable, Sendable { } } -extension Instance { +extension InstanceV1 { public struct Stats: Decodable, Sendable { public let domainCount: Int? public let statusCount: Int? @@ -106,7 +106,7 @@ extension Instance { } } -extension Instance { +extension InstanceV1 { public struct Configuration: Codable, Sendable { public let statuses: StatusesConfiguration 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 let maxCharacters: 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 let supportedMIMETypes: [String] 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 let maxOptions: 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 let id: String public let text: String diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV2.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV2.swift new file mode 100644 index 00000000..b1ebe70c --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/InstanceV2.swift @@ -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 + } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index 3806dd22..f4c60bbf 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -66,7 +66,8 @@ public final class Status: StatusProtocol, Decodable, Sendable { self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID) self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID) self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog) - self.content = try container.decode(String.self, forKey: .content) + // 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.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? [] self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount) @@ -176,6 +177,10 @@ public final class Status: StatusProtocol, Decodable, Sendable { return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history") } + public static func translate(_ statusID: String) -> Request { + return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate") + } + private enum CodingKeys: String, CodingKey { case id case uri diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Translation.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Translation.swift new file mode 100644 index 00000000..546abf26 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Translation.swift @@ -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 + } +} diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift index 21c0c8e9..08f67c1e 100644 --- a/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift @@ -26,13 +26,26 @@ public struct MenuPicker: UIViewRepresentable { } public func makeUIView(context: Context) -> UIButton { - let button = UIButton() + let button = UIButton(configuration: makeConfiguration()) button.showsMenuAsPrimaryAction = true button.setContentHuggingPriority(.required, for: .horizontal) return button } 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() if #available(iOS 16.0, *) { config.indicator = .popup @@ -43,16 +56,10 @@ public struct MenuPicker: UIViewRepresentable { if buttonStyle.hasLabel { config.title = selectedOption.title } - button.configuration = config - 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 + #if targetEnvironment(macCatalyst) + config.macIdiomStyle = .bordered + #endif + return config } public struct Option { diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift index 6a099dd1..92cbb5ae 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift @@ -62,6 +62,7 @@ public final class Preferences: Codable, ObservableObject { self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions 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 if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { self.defaultPostVisibility = .visibility(existing) @@ -127,6 +128,7 @@ public final class Preferences: Codable, ObservableObject { try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) 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(defaultReplyVisibility, forKey: .defaultReplyVisibility) @@ -182,6 +184,7 @@ public final class Preferences: Codable, ObservableObject { private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode @Published public var underlineTextLinks = false + @Published public var showAttachmentsInTimeline = true // MARK: Composing @Published public var defaultPostVisibility = PostVisibility.serverDefault @@ -253,6 +256,7 @@ public final class Preferences: Codable, ObservableObject { case trailingStatusSwipeActions case widescreenNavigationMode case underlineTextLinks + case showAttachmentsInTimeline case defaultPostVisibility case defaultReplyVisibility diff --git a/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift index 1565dc90..95c0134d 100644 --- a/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift +++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountInfo.swift @@ -16,6 +16,11 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable { public private(set) var username: 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" static func id(instanceURL: URL, username: String?) -> String { @@ -47,21 +52,47 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable { self.accessToken = accessToken } - init?(userDefaultsDict dict: [String: String]) { - guard let id = dict["id"], - let instanceURL = dict["instanceURL"], + init?(userDefaultsDict dict: [String: Any]) { + guard let id = dict["id"] as? String, + let instanceURL = dict["instanceURL"] as? String, let url = URL(string: instanceURL), - let clientID = dict["clientID"], - let secret = dict["clientSecret"], - let accessToken = dict["accessToken"] else { + let clientID = dict["clientID"] as? String, + let secret = dict["clientSecret"] as? String, + let accessToken = dict["accessToken"] as? String else { return nil } self.id = id self.instanceURL = url self.clientID = clientID self.clientSecret = secret - self.username = dict["username"] + self.username = dict["username"] as? String 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 diff --git a/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift b/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift index e69973ae..d5752409 100644 --- a/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift +++ b/Packages/UserAccounts/Sources/UserAccounts/UserAccountsManager.swift @@ -46,19 +46,7 @@ public class UserAccountsManager: ObservableObject { } set { objectWillChange.send() - let array = newValue.map { (info) -> [String: String] in - 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 - } + let array = newValue.map(\.userDefaultsDict) defaults.set(array, forKey: accountsKey) } } @@ -146,6 +134,17 @@ public class UserAccountsManager: ObservableObject { public func setMostRecentAccount(_ account: UserAccountInfo?) { 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 + } } diff --git a/ShareExtension/ShareMastodonContext.swift b/ShareExtension/ShareMastodonContext.swift index f9914a12..f8b80c1a 100644 --- a/ShareExtension/ShareMastodonContext.swift +++ b/ShareExtension/ShareMastodonContext.swift @@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject { self.instanceFeatures = InstanceFeatures() 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 self.client.nodeInfo { response in switch response { @@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject { } }) 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 diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 8deb9778..1f5bb3ec 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -53,18 +53,14 @@ class ShareViewController: UIViewController { private func createDraft(account: UserAccountInfo) async -> Draft { async let (text, attachments) = getDraftConfigurationFromExtensionContext() - // TODO: I really don't like that there's a network request in the hot path here, but we don't have easy access to AccountPreferences :/ - let serverPrefs = try? await Client(baseURL: account.instanceURL, accessToken: account.accessToken).run(Client.getPreferences()).0 - let visibility = Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverPrefs?.postingDefaultVisibility) - let draft = DraftsPersistentContainer.shared.createDraft( accountID: account.id, text: await text, contentWarning: "", inReplyToID: nil, - visibility: visibility, - language: serverPrefs?.postingDefaultLanguage, - localOnly: !(serverPrefs?.postingDefaultFederation ?? true) + visibility: Preferences.shared.defaultPostVisibility.resolved(withServerDefault: account.serverDefaultVisibility.flatMap(Visibility.init(rawValue:))), + language: account.serverDefaultLanguage, + localOnly: !(account.serverDefaultFederation ?? true) ) for attachment in await attachments { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index bef1ba2f..d5cd342c 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -32,7 +32,7 @@ D608470F2A245D1F00C17380 /* ActiveInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D608470E2A245D1F00C17380 /* ActiveInstance.swift */; }; D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; }; D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; }; - D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; }; + D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D60BB3932B30076F00DAEA65 /* HTMLStreamer */; }; D60E2F272442372B005F8713 /* StatusMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F232442372B005F8713 /* StatusMO.swift */; }; D60E2F292442372B005F8713 /* AccountMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F252442372B005F8713 /* AccountMO.swift */; }; D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */; }; @@ -303,7 +303,6 @@ D6D79F262A0C8D2700AB2315 /* FetchStatusSourceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */; }; D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */; }; D6D79F2B2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */; }; - D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */; }; D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */; }; D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */; }; D6D79F572A1160B800AB2315 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */; }; @@ -712,7 +711,6 @@ D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchStatusSourceService.swift; sourceTree = ""; }; D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = ""; }; D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditCollectionViewCell.swift; sourceTree = ""; }; - D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditContentTextView.swift; sourceTree = ""; }; D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditPollView.swift; sourceTree = ""; }; D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableButton.swift; sourceTree = ""; }; D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = ""; }; @@ -786,10 +784,10 @@ D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, D659F35E2953A212002D944A /* TTTKit in Frameworks */, D6B0026E29B5248800C70BE2 /* UserAccounts in Frameworks */, - D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */, D60088EF2980D8B5005B4D00 /* StoreKit.framework in Frameworks */, D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */, + D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */, @@ -1574,7 +1572,6 @@ children = ( D6D79F282A0D596B00AB2315 /* StatusEditHistoryViewController.swift */, D6D79F2A2A0D5D5C00AB2315 /* StatusEditCollectionViewCell.swift */, - D6D79F2C2A0D61B400AB2315 /* StatusEditContentTextView.swift */, D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */, ); path = "Status Edit History"; @@ -1699,7 +1696,6 @@ ); name = Tusker; packageProductDependencies = ( - D60CFFDA24A290BA00D00083 /* SwiftSoup */, D6676CA427A8D0020052936B /* WebURLFoundationExtras */, D674A50827F9128D00BA03AC /* Pachyderm */, D6552366289870790048A653 /* ScreenCorners */, @@ -1711,6 +1707,7 @@ D635237029B78A7D009ED5E7 /* TuskerComponents */, D6BD395829B64426005FFD2B /* ComposeUI */, D6CA6ED129EF6091003EC5DF /* TuskerPreferences */, + D60BB3932B30076F00DAEA65 /* HTMLStreamer */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -1821,10 +1818,10 @@ ); mainGroup = D6D4DDC3212518A000E1C4BB; packageReferences = ( - D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */, D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */, D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */, D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */, + D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */, ); productRefGroup = D6D4DDCD212518A000E1C4BB /* Products */; projectDirPath = ""; @@ -2182,7 +2179,6 @@ D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */, D68A76F129539116001DA1B3 /* FlipView.swift in Sources */, D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */, - D6D79F2D2A0D61B400AB2315 /* StatusEditContentTextView.swift in Sources */, D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */, D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */, D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */, @@ -2455,6 +2451,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TuskerUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2814,6 +2811,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TuskerUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2833,6 +2831,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TuskerUITests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2964,12 +2963,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { + D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/scinfu/SwiftSoup.git"; + repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 2.3.2; + minimumVersion = 0.1.0; }; }; D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { @@ -2977,7 +2976,7 @@ repositoryURL = "https://github.com/getsentry/sentry-cocoa.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 8.0.0; + minimumVersion = 8.15.0; }; }; D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = { @@ -2999,10 +2998,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - D60CFFDA24A290BA00D00083 /* SwiftSoup */ = { + D60BB3932B30076F00DAEA65 /* HTMLStreamer */ = { isa = XCSwiftPackageProductDependency; - package = D60CFFD924A290BA00D00083 /* XCRemoteSwiftPackageReference "SwiftSoup" */; - productName = SwiftSoup; + package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */; + productName = HTMLStreamer; }; D61ABEFB28F105DE00B29151 /* Pachyderm */ = { isa = XCSwiftPackageProductDependency; diff --git a/Tusker/API/DeleteStatusService.swift b/Tusker/API/DeleteStatusService.swift index 881b9b4d..8525a769 100644 --- a/Tusker/API/DeleteStatusService.swift +++ b/Tusker/API/DeleteStatusService.swift @@ -35,8 +35,7 @@ class DeleteStatusService { reblogIDs = reblogs.map(\.id) } - NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [ - "accountID": mastodonController.accountInfo!.id, + NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [ "statusIDs": [status.id] + reblogIDs, ]) } catch { diff --git a/Tusker/API/FetchStatusService.swift b/Tusker/API/FetchStatusService.swift index bf944b6d..07a1366d 100644 --- a/Tusker/API/FetchStatusService.swift +++ b/Tusker/API/FetchStatusService.swift @@ -36,11 +36,6 @@ class FetchStatusService { } private func handleStatusNotFound() { - // todo: what about when browsing on another instance? - guard let accountID = mastodonController.accountInfo?.id else { - return - } - var reblogIDs = [String]() if let cached = mastodonController.persistentContainer.status(for: statusID) { let reblogsReq = StatusMO.fetchRequest() @@ -50,8 +45,7 @@ class FetchStatusService { } } - NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [ - "accountID": accountID, + NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [ "statusIDs": [statusID] + reblogIDs ]) } diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index abe79175..dc9e2935 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -55,7 +55,8 @@ class MastodonController: ObservableObject { let instanceFeatures = InstanceFeatures() @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 nodeInfo: NodeInfo! @Published private(set) var lists: [List] = [] @@ -65,7 +66,7 @@ class MastodonController: ObservableObject { private var cancellables = Set() - private var pendingOwnInstanceRequestCallbacks = [(Result) -> Void]() + private var pendingOwnInstanceRequestCallbacks = [(Result) -> Void]() private var ownInstanceRequest: URLSessionTask? var loggedIn: Bool { @@ -111,9 +112,14 @@ class MastodonController: ObservableObject { $instance .compactMap { $0 } - .sink { [unowned self] in - self.updateActiveInstance(from: $0) - self.instanceInfo = InstanceInfo(instance: $0) + .combineLatest($instanceV2) + .sink {[unowned self] (instance, v2) in + var info = InstanceInfo(v1: instance) + if let v2 { + info.update(v2: v2) + } + self.instanceInfo = info + self.updateActiveInstance(from: info) } .store(in: &cancellables) @@ -221,6 +227,10 @@ class MastodonController: ObservableObject { _ = try await (ownAccount, ownInstance) + if instanceFeatures.hasMastodonVersion(4, 0, 0) { + async let _ = try? getOwnInstanceV2() + } + loadLists() _ = await loadFilters() await loadServerPreferences() @@ -281,7 +291,7 @@ class MastodonController: ObservableObject { } } - func getOwnInstance(completion: ((Instance) -> Void)? = nil) { + func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) { getOwnInstanceInternal(retryAttempt: 0) { if case let .success(instance) = $0 { completion?(instance) @@ -290,7 +300,7 @@ class MastodonController: ObservableObject { } @MainActor - func getOwnInstance() async throws -> Instance { + func getOwnInstance() async throws -> InstanceV1 { return try await withCheckedThrowingContinuation({ continuation in getOwnInstanceInternal(retryAttempt: 0) { result in continuation.resume(with: result) @@ -298,7 +308,7 @@ class MastodonController: ObservableObject { }) } - private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result) -> Void)?) { + private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result) -> Void)?) { // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks assert(Thread.isMainThread) @@ -310,7 +320,7 @@ class MastodonController: ObservableObject { } if ownInstanceRequest == nil { - let request = Client.getInstance() + let request = Client.getInstanceV1() ownInstanceRequest = run(request) { (response) in switch response { case .failure(let error): @@ -365,6 +375,10 @@ class MastodonController: ObservableObject { } } + 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 { @@ -375,15 +389,18 @@ class MastodonController: ObservableObject { 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 instance: Instance) { + private func updateActiveInstance(from info: InstanceInfo) { persistentContainer.performBackgroundTask { context in if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { - existing.update(from: instance) + existing.update(from: info) } else { let new = ActiveInstance(context: context) - new.update(from: instance) + new.update(from: info) } if context.hasChanges { try? context.save() diff --git a/Tusker/Activities/StatusActivityItemSource.swift b/Tusker/Activities/StatusActivityItemSource.swift index 82bda548..4255295c 100644 --- a/Tusker/Activities/StatusActivityItemSource.swift +++ b/Tusker/Activities/StatusActivityItemSource.swift @@ -8,7 +8,7 @@ import UIKit import LinkPresentation -import SwiftSoup +import HTMLStreamer class StatusActivityItemSource: NSObject, UIActivityItemSource { let status: StatusMO @@ -33,8 +33,8 @@ class StatusActivityItemSource: NSObject, UIActivityItemSource { let metadata = LPLinkMetadata() metadata.originalURL = status.url! metadata.url = status.url! - let doc = try! SwiftSoup.parse(status.content) - let content = try! doc.text() + var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) + let content = converter.convert(html: status.content) metadata.title = "\(status.account.displayName): \"\(content)\"" if let avatar = status.account.avatar, let data = ImageCache.avatars.getData(avatar), diff --git a/Tusker/CoreData/ActiveInstance.swift b/Tusker/CoreData/ActiveInstance.swift index 1a2a36b9..45f5a453 100644 --- a/Tusker/CoreData/ActiveInstance.swift +++ b/Tusker/CoreData/ActiveInstance.swift @@ -22,18 +22,20 @@ public final class ActiveInstance: NSManagedObject { @NSManaged public var maxStatusCharacters: Int @NSManaged private var configurationData: Data? @NSManaged private var pollsConfigurationData: Data? + @NSManaged public var translation: Bool @LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil) - public var configuration: Instance.Configuration? + public var configuration: InstanceV1.Configuration? @LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil) - public var pollsConfiguration: Instance.PollsConfiguration? + public var pollsConfiguration: InstanceV1.PollsConfiguration? - func update(from instance: Instance) { - self.version = instance.version - self.maxStatusCharacters = instance.maxStatusCharacters ?? 500 - self.configuration = instance.configuration - self.pollsConfiguration = instance.pollsConfiguration + func update(from info: InstanceInfo) { + self.version = info.version + self.maxStatusCharacters = info.maxStatusCharacters ?? 500 + self.configuration = info.configuration + self.pollsConfiguration = info.pollsConfiguration + self.translation = info.translation } } @@ -43,7 +45,8 @@ extension InstanceInfo { version: activeInstance.version, maxStatusCharacters: activeInstance.maxStatusCharacters, configuration: activeInstance.configuration, - pollsConfiguration: activeInstance.pollsConfiguration + pollsConfiguration: activeInstance.pollsConfiguration, + translation: activeInstance.translation ) } } diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index e37f64e5..364480cc 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -54,6 +54,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol { @NSManaged public var reblog: StatusMO? @NSManaged public var localOnly: Bool @NSManaged public var lastFetchedAt: Date? + @NSManaged public var language: String? @LazilyDecoding(arrayFrom: \StatusMO.attachmentsData) public var attachments: [Attachment] @@ -139,6 +140,7 @@ extension StatusMO { self.visibility = status.visibility self.poll = status.poll self.localOnly = status.localOnly ?? false + self.language = status.language if let existing = container.account(for: status.account.id, in: context) { existing.updateFrom(apiAccount: status.account, container: container) diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index ba52345a..42e959ed 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -41,6 +41,7 @@ + @@ -109,6 +110,7 @@ + @@ -123,7 +125,8 @@ - + + diff --git a/Tusker/Extensions/AttributedString+Helpers.swift b/Tusker/Extensions/AttributedString+Helpers.swift index 9c244695..5dd038ba 100644 --- a/Tusker/Extensions/AttributedString+Helpers.swift +++ b/Tusker/Extensions/AttributedString+Helpers.swift @@ -30,20 +30,31 @@ extension NSAttributedString { extension NSMutableAttributedString { func trimLeadingCharactersInSet(_ charSet: CharacterSet) { - var range = (string as NSString).rangeOfCharacter(from: charSet) - - while range.length != 0 && range.location == 0 { - replaceCharacters(in: range, with: "") - range = (string as NSString).rangeOfCharacter(from: charSet) + var end = string.startIndex + while end < string.endIndex && charSet.contains(string.unicodeScalars[end]) { + end = string.unicodeScalars.index(after: end) + } + if end > string.startIndex { + let length = string.utf16.distance(from: string.startIndex, to: end) + replaceCharacters(in: NSRange(location: 0, length: length), with: "") } } func trimTrailingCharactersInSet(_ charSet: CharacterSet) { - var range = (string as NSString).rangeOfCharacter(from: charSet, options: .backwards) - - while range.length != 0 && range.length + range.location == length { - replaceCharacters(in: range, with: "") - range = (string as NSString).rangeOfCharacter(from: charSet, options: .backwards) + if string.isEmpty { + return + } + var start = string.index(before: string.endIndex) + while start > string.startIndex && charSet.contains(string.unicodeScalars[start]) { + start = string.unicodeScalars.index(before: start) + } + if start < string.endIndex { + if start != string.startIndex || !charSet.contains(string.unicodeScalars[start]) { + start = string.unicodeScalars.index(after: start) + } + let location = string.utf16.distance(from: string.startIndex, to: start) + let length = string.utf16.distance(from: start, to: string.endIndex) + replaceCharacters(in: NSRange(location: location, length: length), with: "") } } diff --git a/Tusker/Filterer.swift b/Tusker/Filterer.swift index 77b33e8e..fa1a2b9a 100644 --- a/Tusker/Filterer.swift +++ b/Tusker/Filterer.swift @@ -42,7 +42,7 @@ class Filterer { var filtersChanged: ((Bool) -> Void)? - var htmlConverter = HTMLConverter() + private var htmlConverter: HTMLConverter private var hasSetup = false private var matchers = [(NSRegularExpression, Result)]() private var cancellables = Set() @@ -55,9 +55,10 @@ class Filterer { // are no longer valid, without needing to go through and update each of them private var generation = 0 - init(mastodonController: MastodonController, context: FilterV1.Context) { + init(mastodonController: MastodonController, context: FilterV1.Context, htmlConverter: HTMLConverter) { self.mastodonController = mastodonController self.context = context + self.htmlConverter = htmlConverter self.hideReblogsInTimelines = Preferences.shared.hideReblogsInTimelines self.hideRepliesInTimelines = Preferences.shared.hideRepliesInTimelines diff --git a/Tusker/HTMLConverter.swift b/Tusker/HTMLConverter.swift index 22504752..1c22984a 100644 --- a/Tusker/HTMLConverter.swift +++ b/Tusker/HTMLConverter.swift @@ -7,11 +7,11 @@ // import UIKit -import SwiftSoup +import HTMLStreamer import WebURL import WebURLFoundationExtras -struct HTMLConverter { +class HTMLConverter { static let defaultFont = UIFont.systemFont(ofSize: 17) static let defaultMonospaceFont = UIFont.monospacedSystemFont(ofSize: 17, weight: .regular) @@ -23,148 +23,47 @@ struct HTMLConverter { return style }() - var font: UIFont = defaultFont - var monospaceFont: UIFont = defaultMonospaceFont - var color: UIColor = defaultColor - var paragraphStyle: NSParagraphStyle = defaultParagraphStyle + private var converter: AttributedStringConverter + init(font: UIFont, monospaceFont: UIFont, color: UIColor, paragraphStyle: NSParagraphStyle) { + let config = AttributedStringConverterConfiguration(font: font, monospaceFont: monospaceFont, color: color, paragraphStyle: paragraphStyle) + self.converter = AttributedStringConverter(configuration: config) + } + func convert(_ html: String) -> NSAttributedString { - let doc = try! SwiftSoup.parseBodyFragment(html) - let body = doc.body()! - - if let attributedText = attributedTextForHTMLNode(body) { - let mutAttrString = NSMutableAttributedString(attributedString: attributedText) - mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) - mutAttrString.collapseWhitespace() - - // 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 - } else { - return NSAttributedString() - } + converter.convert(html: html) } - - private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString? { - switch node { - case let node as TextNode: - let text: String - if usePreformattedText { - text = node.getWholeText() - } else { - text = node.text() - } - return NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color]) - 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]) - for child in node.getChildNodes() { - var appendEllipsis = false - if node.tagName() == "a", - let el = child as? Element { - if el.hasClass("invisible") { - continue - } else if el.hasClass("ellipsis") { - appendEllipsis = true - } - } - - if let childText = attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre") { - attributed.append(childText) - } - - if appendEllipsis { - attributed.append(NSAttributedString("…")) - } - } - - switch node.tagName() { - case "br": - // need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which - // screws up its determination of the line height making multiple lines of emojis squash together - attributed.append(NSAttributedString(string: "\n", attributes: [.font: font])) - case "a": - let href = try! node.attr("href") - if let webURL = WebURL(href), - let url = URL(webURL) { - attributed.addAttribute(.link, value: url, range: attributed.fullRange) - } else if let url = URL(string: href) { - attributed.addAttribute(.link, value: url, range: attributed.fullRange) - } - case "p": - attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: font])) - 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) - 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) - case "del": - attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) - case "code": - attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange) - case "pre": - attributed.append(NSAttributedString(string: "\n\n")) - attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange) - default: - break - } - - return attributed - default: - return nil - } - } - - 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))." +extension HTMLConverter { + struct Callbacks: HTMLConversionCallbacks { + static func makeURL(string: String) -> URL? { + // Converting WebURL to URL is a small but non-trivial expense (since it works by + // serializing the WebURL as a string and then having Foundation parse it again), + // so, if available, use the system parser which doesn't require another round trip. + if #available(iOS 16.0, macOS 13.0, *), + let url = try? URL.ParseStrategy().parse(string) { + url + } else if let web = WebURL(string), + let url = URL(web) { + url + } else { + URL(string: string) + } + } + + static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { + guard name == "span" else { + return .default + } + let clazz = attributes.attributeValue(for: "class") + if clazz == "invisible" { + return .skip + } else if clazz == "ellipsis" { + return .append("…") + } else { + return .default + } + } } } diff --git a/Tusker/LazilyDecoding.swift b/Tusker/LazilyDecoding.swift index 7d402eab..16c291c8 100644 --- a/Tusker/LazilyDecoding.swift +++ b/Tusker/LazilyDecoding.swift @@ -7,12 +7,13 @@ // import Foundation +import CoreData private let decoder = PropertyListDecoder() private let encoder = PropertyListEncoder() @propertyWrapper -public struct LazilyDecoding { +public struct LazilyDecoding { private let keyPath: ReferenceWritableKeyPath private let fallback: Value diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index af367161..5976c7eb 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -150,7 +150,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate minDate.addTimeInterval(-7 * 24 * 60 * 60) let statusReq: NSFetchRequest = 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) deleteStatusReq.resultType = .resultTypeCount if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult { diff --git a/Tusker/Screens/Compose/ComposeReplyContentView.swift b/Tusker/Screens/Compose/ComposeReplyContentView.swift index 199a1f28..d7c458a1 100644 --- a/Tusker/Screens/Compose/ComposeReplyContentView.swift +++ b/Tusker/Screens/Compose/ComposeReplyContentView.swift @@ -26,11 +26,9 @@ struct ComposeReplyContentView: UIViewRepresentable { view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) view.adjustsFontForContentSizeCategory = true - view.defaultFont = TimelineStatusCollectionViewCell.contentFont - view.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont view.overrideMastodonController = mastodonController - view.setTextFrom(status: status) + view.attributedText = TimelineStatusCollectionViewCell.htmlConverter.convert(status.content) return view } diff --git a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift index 8b2a790b..088c7db0 100644 --- a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift +++ b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift @@ -15,6 +15,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont private let mastodonController: MastodonController private let mainStatusID: String private let mainStatusState: CollapseState + private var mainStatusTranslation: Translation? var statusIDToScrollToOnLoad: String var showStatusesAutomatically = false @@ -88,11 +89,14 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil) cell.setShowThreadLinks(prev: item.2, next: item.3) } - let mainStatusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + let mainStatusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self + cell.translateStatus = { [unowned self] in + self.translateMainStatus() + } cell.showStatusAutomatically = self.showStatusesAutomatically - cell.updateUI(statusID: item.0, state: item.1) - cell.setShowThreadLinks(prev: item.2, next: false) + cell.updateUI(statusID: item.0, state: item.1, translation: item.2) + cell.setShowThreadLinks(prev: item.3, next: false) } let expandThreadCell = UICollectionView.CellRegistration { cell, indexPath, item in cell.updateUI(childThreads: item.0, inline: item.1) @@ -104,7 +108,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont switch itemIdentifier { case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink): if id == self.mainStatusID { - return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink)) + return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, self.mainStatusTranslation, prevLink)) } else { return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink)) } @@ -260,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 { diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 3e3a5ddc..764c9f13 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -112,7 +112,7 @@ class ConversationViewController: UIViewController { appearance.configureWithDefaultBackground() 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() { @@ -145,8 +145,6 @@ class ConversationViewController: UIViewController { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String], case .localID(let mainStatusID) = mode else { return diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 7c1f9384..3bebfc02 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -261,13 +261,16 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect } 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) }) if await service.run() { var snapshot = dataSource.snapshot() snapshot.deleteItems([.list(list)]) - await dataSource.apply(snapshot) - completion(true) + await MainActor.run { + dataSource.apply(snapshot, animatingDifferences: true) { + completion(true) + } + } } else { completion(false) } diff --git a/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift b/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift index d17cfeeb..2418e631 100644 --- a/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift +++ b/Tusker/Screens/Explore/FeaturedProfileCollectionViewCell.swift @@ -34,8 +34,6 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell { displayNameLabel.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) displayNameLabel.adjustsFontForContentSizeCategory = true - noteTextView.defaultFont = .preferredFont(forTextStyle: .body) - noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) noteTextView.adjustsFontForContentSizeCategory = true noteTextView.textContainer.lineBreakMode = .byTruncatingTail noteTextView.textContainerInset = UIEdgeInsets(top: 16, left: 4, bottom: 16, right: 4) @@ -60,7 +58,7 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell { displayNameLabel.updateForAccountDisplayName(account: account) - noteTextView.setTextFromHtml(account.note) + noteTextView.setBodyTextFromHTML(account.note) noteTextView.setEmojis(account.emojis, identifier: account.id) avatarImageView.image = nil diff --git a/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift b/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift index 3c1a5d5b..7d2477d7 100644 --- a/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift +++ b/Tusker/Screens/Explore/SuggestedProfileCardCollectionViewCell.swift @@ -54,8 +54,6 @@ class SuggestedProfileCardCollectionViewCell: UICollectionViewCell { usernameLabel.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 15, weight: .light)) usernameLabel.adjustsFontForContentSizeCategory = true - noteTextView.defaultFont = .preferredFont(forTextStyle: .body) - noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) noteTextView.adjustsFontForContentSizeCategory = true noteTextView.textContainer.lineBreakMode = .byTruncatingTail @@ -86,7 +84,7 @@ class SuggestedProfileCardCollectionViewCell: UICollectionViewCell { avatarImageView.update(for: account.avatar) headerImageView.update(for: account.header) usernameLabel.text = "@\(account.acct)" - noteTextView.setTextFromHtml(account.note) + noteTextView.setBodyTextFromHTML(account.note) var config = UIButton.Configuration.plain() config.image = source.image diff --git a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift index 3236c8fb..0604595d 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift +++ b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift @@ -9,7 +9,7 @@ import UIKit import Pachyderm import WebURLFoundationExtras -import SwiftSoup +import HTMLStreamer class TrendingLinkCardCollectionViewCell: UICollectionViewCell { @@ -78,8 +78,8 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell { let provider = card.providerName!.trimmingCharacters(in: .whitespacesAndNewlines) providerLabel.text = provider - let description = try! SwiftSoup.parseBodyFragment(card.description).text() - descriptionLabel.text = description + var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) + descriptionLabel.text = converter.convert(html: card.description) descriptionLabel.isHidden = description.isEmpty let sorted = card.history!.sorted(by: { $0.day < $1.day }) diff --git a/Tusker/Screens/Explore/TrendingStatusesViewController.swift b/Tusker/Screens/Explore/TrendingStatusesViewController.swift index 447054ae..f8bc4e73 100644 --- a/Tusker/Screens/Explore/TrendingStatusesViewController.swift +++ b/Tusker/Screens/Explore/TrendingStatusesViewController.swift @@ -23,10 +23,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController init(mastodonController: MastodonController) { self.mastodonController = mastodonController - self.filterer = Filterer(mastodonController: mastodonController, context: .public) - self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont - self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont - self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle + self.filterer = Filterer(mastodonController: mastodonController, context: .public, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter) super.init(nibName: nil, bundle: nil) @@ -102,7 +99,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController override func 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) { @@ -146,8 +143,6 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift index b804164c..26e34b22 100644 --- a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift +++ b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift @@ -172,7 +172,9 @@ class FastAccountSwitcherViewController: UIViewController { #endif hide() { - (self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount() + if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate { + sceneDelegate.showAddAccount() + } } } else { let account = UserAccountsManager.shared.accounts[newIndex - 1] @@ -186,7 +188,9 @@ class FastAccountSwitcherViewController: UIViewController { #endif 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 { hide() diff --git a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift index e800ecbc..61b5b47a 100644 --- a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift +++ b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift @@ -8,6 +8,7 @@ import UIKit import UserAccounts +import WebURL class FastSwitchingAccountView: UIView { @@ -126,7 +127,11 @@ class FastSwitchingAccountView: UIView { private func setupAccount(account: UserAccountInfo) { usernameLabel.text = account.username - instanceLabel.text = account.instanceURL.host! + if let domain = WebURL.Domain(account.instanceURL.host!) { + instanceLabel.text = domain.render(.uncheckedUnicodeString) + } else { + instanceLabel.text = account.instanceURL.host! + } let controller = MastodonController.getForAccount(account) controller.getOwnAccount { [weak self] (result) in 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() { diff --git a/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift b/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift index a28b4059..92939bf9 100644 --- a/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift +++ b/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift @@ -107,7 +107,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont 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) } @@ -205,8 +205,6 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift index 447cca45..a261dfe5 100644 --- a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift @@ -8,7 +8,7 @@ import UIKit import Pachyderm -import SwiftSoup +import HTMLStreamer class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { @@ -161,9 +161,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id) - // todo: use htmlconverter - let doc = try! SwiftSoup.parseBodyFragment(status.content) - statusContentLabel.text = try! doc.text() + var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) + statusContentLabel.text = converter.convert(html: status.content) } @objc private func updateUIForPreferences() { diff --git a/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift b/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift index 371a7978..0d33e147 100644 --- a/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift @@ -23,6 +23,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell { $0.contentMode = .scaleAspectFill $0.layer.masksToBounds = true $0.layer.cornerCurve = .continuous + $0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 NSLayoutConstraint.activate([ $0.widthAnchor.constraint(equalTo: $0.heightAnchor), ]) diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index 7aac09c2..dea4490f 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -36,10 +36,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle self.allowedTypes = allowedTypes self.mastodonController = mastodonController - self.filterer = Filterer(mastodonController: mastodonController, context: .notifications) - self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont - self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont - self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle + self.filterer = Filterer(mastodonController: mastodonController, context: .notifications, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter) super.init(nibName: nil, bundle: nil) @@ -123,7 +120,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle 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 { @@ -259,8 +256,6 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Notifications/PollFinishedNotificationCollectionViewCell.swift b/Tusker/Screens/Notifications/PollFinishedNotificationCollectionViewCell.swift index 2c9758d1..a4fabca4 100644 --- a/Tusker/Screens/Notifications/PollFinishedNotificationCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/PollFinishedNotificationCollectionViewCell.swift @@ -8,7 +8,7 @@ import UIKit import Pachyderm -import SwiftSoup +import HTMLStreamer class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell { @@ -124,9 +124,8 @@ class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell { updateTimestamp() updateDisplayName(account: account) - // todo: use htmlconverter - let doc = try! SwiftSoup.parseBodyFragment(status.content) - contentLabel.text = try! doc.text() + var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) + contentLabel.text = converter.convert(html: status.content) pollView.mastodonController = mastodonController pollView.delegate = delegate diff --git a/Tusker/Screens/Notifications/StatusUpdatedNotificationCollectionViewCell.swift b/Tusker/Screens/Notifications/StatusUpdatedNotificationCollectionViewCell.swift index 42d27395..ccc88628 100644 --- a/Tusker/Screens/Notifications/StatusUpdatedNotificationCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/StatusUpdatedNotificationCollectionViewCell.swift @@ -8,7 +8,7 @@ import UIKit import Pachyderm -import SwiftSoup +import HTMLStreamer class StatusUpdatedNotificationCollectionViewCell: UICollectionViewListCell { @@ -120,9 +120,8 @@ class StatusUpdatedNotificationCollectionViewCell: UICollectionViewListCell { updateTimestamp() updateDisplayName(account: account) - // todo: use htmlconverter - let doc = try! SwiftSoup.parseBodyFragment(status.content) - contentLabel.text = try! doc.text() + var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) + contentLabel.text = converter.convert(html: status.content) } @objc private func updateUIForPreferences() { diff --git a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift index 3628ebae..537f049e 100644 --- a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift +++ b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift @@ -164,7 +164,7 @@ class InstanceSelectorTableViewController: UITableViewController { } let client = Client(baseURL: url, session: .appDefault) - let request = Client.getInstance() + let request = Client.getInstanceV1() client.run(request) { (response) in var snapshot = self.dataSource.snapshot() if snapshot.indexOfSection(.selected) != nil { @@ -311,7 +311,7 @@ extension InstanceSelectorTableViewController { case recommendedInstances } enum Item: Equatable, Hashable { - case selected(URL, Instance) + case selected(URL, InstanceV1) case recommended(InstanceSelector.Instance) static func ==(lhs: Item, rhs: Item) -> Bool { diff --git a/Tusker/Screens/Preferences/AcknowledgementsView.swift b/Tusker/Screens/Preferences/AcknowledgementsView.swift index 239f004f..33c2db7b 100644 --- a/Tusker/Screens/Preferences/AcknowledgementsView.swift +++ b/Tusker/Screens/Preferences/AcknowledgementsView.swift @@ -60,31 +60,6 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -^[[SwiftSoup](https://github.com/scinfu/swiftsoup)](headingLevel: 2) -Copyright (c) 2016 Nabil Chatbi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -Symbols -Symbol outline not available for this file -To inspect a symbol, try clicking on the symbol directly in the code view. -Code navigation supports a limited number of languages. See which languages are supported. - ^[[swift-url](https://github.com/karwa/swift-url)](headingLevel: 2) Apache License diff --git a/Tusker/Screens/Preferences/AppearancePrefsView.swift b/Tusker/Screens/Preferences/AppearancePrefsView.swift index 10c46457..409c2086 100644 --- a/Tusker/Screens/Preferences/AppearancePrefsView.swift +++ b/Tusker/Screens/Preferences/AppearancePrefsView.swift @@ -121,12 +121,15 @@ struct AppearancePrefsView : View { Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) { Text("Always Show Status Visibility Icons") } - Toggle(isOn: $preferences.hideActionsInTimeline) { - Text("Hide Actions on Timeline") - } Toggle(isOn: $preferences.showLinkPreviews) { Text("Show Link Previews") } + Toggle(isOn: $preferences.showAttachmentsInTimeline) { + Text("Show Attachments on Timeline") + } + Toggle(isOn: $preferences.hideActionsInTimeline) { + Text("Hide Actions on Timeline") + } Toggle(isOn: $preferences.underlineTextLinks) { Text("Underline Links") } diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index 6ee2f61a..6739d89e 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -7,6 +7,7 @@ import SwiftUI import UserAccounts +import WebURL struct PreferencesView: View { let mastodonController: MastodonController @@ -41,7 +42,12 @@ struct PreferencesView: View { VStack(alignment: .leading) { Text(verbatim: account.username) .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) .foregroundColor(.primary) } diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index 1b26ab7f..2241c035 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -41,10 +41,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie self.kind = kind self.owner = owner self.mastodonController = owner.mastodonController - self.filterer = Filterer(mastodonController: mastodonController, context: .account) - self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont - self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont - self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle + self.filterer = Filterer(mastodonController: mastodonController, context: .account, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter) super.init(nibName: nil, bundle: nil) @@ -67,18 +64,25 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } 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 } var config = sectionSeparatorConfiguration if item.hideSeparators { config.topSeparatorVisibility = .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, filterer.isKnownHide(state: filterState) { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden - } else if case .status(_, _, _, _) = item { + } else { config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } @@ -88,6 +92,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie if case .header = dataSource.sectionIdentifier(for: sectionIndex) { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground + config.separatorConfiguration.bottomSeparatorInsets = .zero return .list(using: config, layoutEnvironment: environment) } else { let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) @@ -148,7 +153,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie 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 { @@ -376,8 +381,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Report/ReportStatusView.swift b/Tusker/Screens/Report/ReportStatusView.swift index c9410f9e..2cad3df8 100644 --- a/Tusker/Screens/Report/ReportStatusView.swift +++ b/Tusker/Screens/Report/ReportStatusView.swift @@ -7,9 +7,13 @@ // import SwiftUI -import SwiftSoup -private var converter = HTMLConverter() +private var converter = HTMLConverter( + font: .preferredFont(forTextStyle: .body), + monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)), + color: .label, + paragraphStyle: .default +) struct ReportStatusView: View { let status: StatusMO diff --git a/Tusker/Screens/Search/MastodonSearchController.swift b/Tusker/Screens/Search/MastodonSearchController.swift index d227d0dc..b35eb034 100644 --- a/Tusker/Screens/Search/MastodonSearchController.swift +++ b/Tusker/Screens/Search/MastodonSearchController.swift @@ -65,12 +65,13 @@ class MastodonSearchController: UISearchController { searchText.isEmpty || $0.contains(searchText) })) - // TODO: use default language from preferences var langSuggestions = [String]() - if searchText.isEmpty || "language:en".contains(searchText) { - langSuggestions.append("language:en") + let defaultLanguage = searchResultsController.mastodonController.accountPreferences.serverDefaultLanguage ?? "en" + let languageToken = "language:\(defaultLanguage)" + if searchText.isEmpty || languageToken.contains(searchText) { + langSuggestions.append(languageToken) } - if searchText != "en", + 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, *) { @@ -87,7 +88,7 @@ class MastodonSearchController: UISearchController { if searchText.isEmpty || "from:me".contains(searchText) { fromSuggestions.append("from:me") } - if searchText != "me", + if searchText != "me" && searchText != "from: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)") diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index 826a7c99..33822847 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -46,6 +46,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController { private let searchSubject = PassthroughSubject() private var searchCancellable: AnyCancellable? private var currentQuery: String? + private var currentSearchResults: SearchResults? init(mastodonController: MastodonController, scope: Scope = .all) { self.mastodonController = mastodonController @@ -122,7 +123,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController { 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 { @@ -262,7 +263,14 @@ class SearchResultsViewController: UIViewController, CollectionViewController { switch response { case let .success(results, _): guard self.currentQuery == query else { return } - self.showSearchResults(results) + self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in + addAccounts(results.accounts) + addStatuses(results.statuses) + } completion: { + DispatchQueue.main.async { + self.showSearchResults(results) + } + } case let .failure(error): DispatchQueue.main.async { self.showSearchError(error) @@ -271,31 +279,29 @@ class SearchResultsViewController: UIViewController, CollectionViewController { } } + @MainActor private func showSearchResults(_ results: SearchResults) { + self.currentSearchResults = results + var snapshot = dataSource.snapshot() snapshot.deleteSections([.loadingIndicator]) + removeResults(from: &snapshot) - self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in - let resultTypes = self.scope.resultTypes - if !results.accounts.isEmpty && resultTypes.contains(.accounts) { - snapshot.appendSections([.accounts]) - snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts) - addAccounts(results.accounts) - } - if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) { - snapshot.appendSections([.hashtags]) - snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags) - } - if !results.statuses.isEmpty && resultTypes.contains(.statuses) { - snapshot.appendSections([.statuses]) - snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses) - addStatuses(results.statuses) - } - }, completion: { - DispatchQueue.main.async { - self.dataSource.apply(snapshot) - } - }) + let resultTypes = self.scope.resultTypes + if !results.accounts.isEmpty && resultTypes.contains(.accounts) { + snapshot.appendSections([.accounts]) + snapshot.appendItems(results.accounts.map { .account($0.id) }, toSection: .accounts) + } + if !results.hashtags.isEmpty && resultTypes.contains(.hashtags) { + snapshot.appendSections([.hashtags]) + snapshot.appendItems(results.hashtags.map { .hashtag($0) }, toSection: .hashtags) + } + if !results.statuses.isEmpty && resultTypes.contains(.statuses) { + snapshot.appendSections([.statuses]) + snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses) + } + + dataSource.apply(snapshot) } private func showSearchError(_ error: Client.Error) { @@ -311,8 +317,6 @@ class SearchResultsViewController: UIViewController, CollectionViewController { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } @@ -567,19 +571,25 @@ extension SearchResultsViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { let newQuery = searchBar.searchQueryWithOperators let newScope = Scope.allCases[selectedScope] - if self.scope == .all && currentQuery == newQuery { - self.scope = newScope - var snapshot = dataSource.snapshot() - if snapshot.sectionIdentifiers.contains(.accounts) && scope != .people { - snapshot.deleteSections([.accounts]) + if currentQuery == newQuery, + let currentSearchResults { + if self.scope == .all { + self.scope = newScope + var snapshot = dataSource.snapshot() + if snapshot.sectionIdentifiers.contains(.accounts) && scope != .people { + snapshot.deleteSections([.accounts]) + } + if snapshot.sectionIdentifiers.contains(.hashtags) && scope != .hashtags { + snapshot.deleteSections([.hashtags]) + } + if snapshot.sectionIdentifiers.contains(.statuses) && scope != .posts { + snapshot.deleteSections([.statuses]) + } + dataSource.apply(snapshot) + } else { + self.scope = newScope + showSearchResults(currentSearchResults) } - if snapshot.sectionIdentifiers.contains(.hashtags) && scope != .hashtags { - snapshot.deleteSections([.hashtags]) - } - if snapshot.sectionIdentifiers.contains(.statuses) && scope != .posts { - snapshot.deleteSections([.statuses]) - } - dataSource.apply(snapshot) } else { self.scope = newScope performSearch(query: newQuery) diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift index ea1e4523..d1e4a501 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -84,7 +84,7 @@ class StatusActionAccountListViewController: UIViewController { 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) { @@ -99,8 +99,6 @@ class StatusActionAccountListViewController: UIViewController { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift index 536928d1..bc40c964 100644 --- a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift +++ b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift @@ -61,13 +61,31 @@ class StatusEditCollectionViewCell: UICollectionViewListCell { $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) } - private let contentContainer = StatusContentContainer(useTopSpacer: false).configure { - $0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont - $0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont - $0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle + private lazy var contentContainer = StatusContentContainer(arrangedSubviews: [ + contentTextView, + cardView, + attachmentsView, + pollView, + ] as! [any StatusContentView], useTopSpacer: false).configure { $0.setContentHuggingPriority(.defaultLow, for: .vertical) } + private let contentTextView = ContentTextView().configure { + $0.adjustsFontForContentSizeCategory = true + $0.isScrollEnabled = false + $0.backgroundColor = nil + $0.isEditable = false + $0.isSelectable = false + } + + 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? private var mastodonController: MastodonController! { delegate?.apiController } @@ -91,8 +109,76 @@ class StatusEditCollectionViewCell: UICollectionViewListCell { 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 @@ -102,13 +188,14 @@ class StatusEditCollectionViewCell: UICollectionViewListCell { timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt) - contentContainer.contentTextView.setTextFrom(edit: edit, index: index) - contentContainer.contentTextView.navigationDelegate = delegate - contentContainer.attachmentsView.delegate = self - contentContainer.attachmentsView.updateUI(attachments: edit.attachments) - contentContainer.pollView.isHidden = edit.poll == nil - contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis) - contentContainer.cardView.isHidden = true + contentTextView.attributedText = TimelineStatusCollectionViewCell.htmlConverter.convert(edit.content) + contentTextView.setEmojis(edit.emojis, identifier: index) + contentTextView.navigationDelegate = delegate + attachmentsView.delegate = self + attachmentsView.updateUI(attachments: edit.attachments) + pollView.isHidden = edit.poll == nil + pollView.updateUI(poll: edit.poll, emojis: edit.emojis) + cardView.isHidden = true contentWarningLabel.text = edit.spoilerText contentWarningLabel.isHidden = edit.spoilerText.isEmpty @@ -151,9 +238,9 @@ extension StatusEditCollectionViewCell: AttachmentViewDelegate { guard let delegate else { return nil } - let attachments = contentContainer.attachmentsView.attachments! + let attachments = attachmentsView.attachments! let sourceViews = attachments.map { - contentContainer.attachmentsView.getAttachmentView(for: $0) + attachmentsView.getAttachmentView(for: $0) } let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index) return gallery diff --git a/Tusker/Screens/Status Edit History/StatusEditContentTextView.swift b/Tusker/Screens/Status Edit History/StatusEditContentTextView.swift deleted file mode 100644 index b3c97552..00000000 --- a/Tusker/Screens/Status Edit History/StatusEditContentTextView.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// StatusEditContentTextView.swift -// Tusker -// -// Created by Shadowfacts on 5/11/23. -// Copyright © 2023 Shadowfacts. All rights reserved. -// - -import UIKit -import Pachyderm -import WebURL - -class StatusEditContentTextView: ContentTextView { - - func setTextFrom(edit: StatusEdit, index: Int) { - setTextFromHtml(edit.content) - setEmojis(edit.emojis, identifier: index) - } - - // mention links aren't included in the edit content, nothing else to do - -} diff --git a/Tusker/Screens/Status Edit History/StatusEditPollView.swift b/Tusker/Screens/Status Edit History/StatusEditPollView.swift index 67ac9762..50767e57 100644 --- a/Tusker/Screens/Status Edit History/StatusEditPollView.swift +++ b/Tusker/Screens/Status Edit History/StatusEditPollView.swift @@ -9,7 +9,7 @@ import UIKit import Pachyderm -class StatusEditPollView: UIStackView, StatusContentPollView { +class StatusEditPollView: UIStackView, StatusContentView { private var titleLabels: [EmojiLabel] = [] diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index a8e52dc4..74fd4c17 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -32,7 +32,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro let timeline: Timeline weak var mastodonController: MastodonController! - let filterer: Filterer + private let filterer: Filterer var persistsState = false @@ -61,10 +61,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro default: filterContext = .public } - self.filterer = Filterer(mastodonController: mastodonController, context: filterContext) - self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont - self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont - self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle + self.filterer = Filterer(mastodonController: mastodonController, context: filterContext, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter) super.init(nibName: nil, bundle: nil) @@ -164,7 +161,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } } .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 { userActivityNeedsUpdate @@ -182,6 +191,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro // separate method because InstanceTimelineViewController needs to be able to customize it func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) { cell.delegate = self + cell.showAttachmentsInline = Preferences.shared.showAttachmentsInTimeline if case .home = timeline { cell.showFollowedHashtags = true } else { @@ -376,7 +386,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro _ = try await mastodonController.run(req) } catch { stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))") + #if canImport(Sentry) + if let error = error as? Client.Error, + case .networkError(_) = error.type { + return + } let event = Event(error: error) event.message = SentryMessage(formatted: "Failed to update timeline marker: \(String(describing: error))") SentrySDK.capture(event: event) @@ -411,7 +426,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } let hasStatusesToRestore = await loadStatusesToRestore(position: position) if hasStatusesToRestore { - applyItemsToRestore(position: position) + await applyItemsToRestore(position: position) loaded = true } case .mastodon: @@ -436,7 +451,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro position.centerStatusID = centerStatusID let hasStatusesToRestore = await loadStatusesToRestore(position: position) if hasStatusesToRestore { - applyItemsToRestore(position: position) + await applyItemsToRestore(position: position) } mastodonController.persistentContainer.viewContext.delete(position) } @@ -451,6 +466,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro 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) } @@ -519,7 +544,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } @MainActor - private func applyItemsToRestore(position: TimelinePosition) { + private func applyItemsToRestore(position: TimelinePosition) async { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) let statusIDs = position.statusIDs @@ -534,14 +559,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro ] SentrySDK.addBreadcrumb(crumb) #endif - dataSource.apply(snapshot, animatingDifferences: false) { - if let centerStatusID, - let index = statusIDs.firstIndex(of: centerStatusID) { - self.scrollToItem(item: items[index]) - stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)") - } else { - stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") - } + await apply(snapshot, animatingDifferences: false) + if let centerStatusID, + let index = statusIDs.firstIndex(of: centerStatusID) { + self.scrollToItem(item: items[index]) + stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)") + } else { + stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") } } @@ -570,7 +594,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return true } catch { stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))") + #if canImport(Sentry) + if let error = error as? Client.Error, + case .networkError(_) = error.type { + return false + } let event = Event(error: error) event.message = SentryMessage(formatted: "Failed to load from timeline marker: \(String(describing: error))") SentrySDK.capture(event: event) @@ -959,8 +988,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Views/Account Cell/AccountCollectionViewCell.swift b/Tusker/Views/Account Cell/AccountCollectionViewCell.swift index 18c989f1..f7cede79 100644 --- a/Tusker/Views/Account Cell/AccountCollectionViewCell.swift +++ b/Tusker/Views/Account Cell/AccountCollectionViewCell.swift @@ -7,7 +7,7 @@ // import UIKit -import SwiftSoup +import HTMLStreamer class AccountCollectionViewCell: UICollectionViewListCell { @@ -134,8 +134,8 @@ class AccountCollectionViewCell: UICollectionViewListCell { displayNameLabel.setEmojis(account.emojis, identifier: account.id) } - let doc = try! SwiftSoup.parseBodyFragment(account.note) - noteLabel.text = try! doc.text() + var converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) + noteLabel.text = converter.convert(html: account.note) noteLabel.setEmojis(account.emojis, identifier: account.id) } diff --git a/Tusker/Views/Attachments/AttachmentsContainerView.swift b/Tusker/Views/Attachments/AttachmentsContainerView.swift index bd449c9b..f75b7937 100644 --- a/Tusker/Views/Attachments/AttachmentsContainerView.swift +++ b/Tusker/Views/Attachments/AttachmentsContainerView.swift @@ -30,8 +30,8 @@ class AttachmentsContainerView: UIView { } } - var blurView: UIVisualEffectView? - var hideButtonView: UIVisualEffectView? + private var blurView: UIVisualEffectView? + private var hideButtonView: UIVisualEffectView? var contentHidden: Bool! { didSet { guard let blurView = blurView, @@ -42,6 +42,8 @@ class AttachmentsContainerView: UIView { } } + private var label: UILabel? + override init(frame: CGRect) { super.init(frame: frame) commonInit() @@ -67,21 +69,26 @@ class AttachmentsContainerView: UIView { // MARK: - User Interaface - func updateUI(attachments: [Attachment]) { + func updateUI(attachments: [Attachment], labelOnly: Bool = false) { let newTokens = attachments.map { AttachmentToken(attachment: $0) } + guard !labelOnly else { + self.attachments = attachments + self.attachmentTokens = newTokens + updateLabel(attachments: attachments) + return + } + guard self.attachmentTokens != newTokens else { + self.isHidden = attachments.isEmpty return } self.attachments = attachments self.attachmentTokens = newTokens - attachmentViews.allObjects.forEach { $0.removeFromSuperview() } - attachmentViews.removeAllObjects() - attachmentStacks.allObjects.forEach { $0.removeFromSuperview() } - attachmentStacks.removeAllObjects() - moreView?.removeFromSuperview() + removeAttachmentViews() + hideButtonView?.isHidden = false var accessibilityElements = [Any]() @@ -284,6 +291,14 @@ class AttachmentsContainerView: UIView { self.accessibilityElements = accessibilityElements } + private func removeAttachmentViews() { + attachmentViews.allObjects.forEach { $0.removeFromSuperview() } + attachmentViews.removeAllObjects() + attachmentStacks.allObjects.forEach { $0.removeFromSuperview() } + attachmentStacks.removeAllObjects() + moreView?.removeFromSuperview() + } + private func createAttachmentView(index: Int, hSize: RelativeSize, vSize: RelativeSize) -> AttachmentView { let attachmentView = AttachmentView(attachment: attachments[index], index: index) attachmentView.delegate = delegate @@ -396,6 +411,35 @@ class AttachmentsContainerView: UIView { ]) } + private func updateLabel(attachments: [Attachment]) { + removeAttachmentViews() + blurView?.isHidden = true + hideButtonView?.isHidden = true + aspectRatioConstraint?.isActive = false + + if label == nil { + if attachments.isEmpty { + accessibilityElements = [] + return + } + label = UILabel() + label!.font = .preferredFont(forTextStyle: .body) + label!.adjustsFontForContentSizeCategory = true + label!.textColor = .secondaryLabel + label!.translatesAutoresizingMaskIntoConstraints = false + addSubview(label!) + NSLayoutConstraint.activate([ + label!.leadingAnchor.constraint(equalTo: leadingAnchor), + label!.trailingAnchor.constraint(equalTo: trailingAnchor), + label!.topAnchor.constraint(equalTo: topAnchor), + label!.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + label!.text = "\(attachments.count) attachment\(attachments.count == 1 ? "" : "s")" + accessibilityElements = [label!] + } + // MARK: - Interaction @objc func blurViewTapped() { diff --git a/Tusker/Views/ConfirmReblogStatusPreviewView.swift b/Tusker/Views/ConfirmReblogStatusPreviewView.swift index 8a7b77e4..2ac6a3c2 100644 --- a/Tusker/Views/ConfirmReblogStatusPreviewView.swift +++ b/Tusker/Views/ConfirmReblogStatusPreviewView.swift @@ -10,6 +10,13 @@ import UIKit class ConfirmReblogStatusPreviewView: UIView { + private static let htmlConverter = HTMLConverter( + font: .preferredFont(forTextStyle: .caption2), + monospaceFont: UIFontMetrics(forTextStyle: .caption2).scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)), + color: .label, + paragraphStyle: .default + ) + private var avatarTask: Task? init(status: StatusMO) { @@ -60,17 +67,13 @@ class ConfirmReblogStatusPreviewView: UIView { vStack.addArrangedSubview(displayNameLabel) let contentView = StatusContentTextView() - contentView.defaultFont = .preferredFont(forTextStyle: .caption2) - contentView.monospaceFont = UIFontMetrics(forTextStyle: .caption2).scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) contentView.isUserInteractionEnabled = false contentView.isScrollEnabled = false contentView.backgroundColor = nil contentView.textContainerInset = .zero contentView.adjustsFontForContentSizeCategory = true - // remove the extra line spacing applied by StatusContentTextView because, since we're using a smaller font, the regular 2pt looks big - contentView.paragraphStyle = .default // TODO: line limit - contentView.setTextFrom(status: status) + contentView.setTextFrom(status: status, content: ConfirmReblogStatusPreviewView.htmlConverter.convert(status.content)) contentView.translatesAutoresizingMaskIntoConstraints = false vStack.addArrangedSubview(contentView) NSLayoutConstraint.activate([ diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 29b2d2de..a2cf4c11 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -7,7 +7,6 @@ // import UIKit -import SwiftSoup import Pachyderm import SafariServices import WebURL @@ -22,31 +21,20 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { weak var navigationDelegate: TuskerNavigationDelegate? weak var overrideMastodonController: MastodonController? var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController } - - private var htmlConverter = HTMLConverter() - var defaultFont: UIFont { - _read { yield htmlConverter.font } - _modify { yield &htmlConverter.font } - } - var monospaceFont: UIFont { - _read { yield htmlConverter.monospaceFont } - _modify { yield &htmlConverter.monospaceFont } - } - var defaultColor: UIColor { - _read { yield htmlConverter.color } - _modify { yield &htmlConverter.color } - } - var paragraphStyle: NSParagraphStyle { - _read { yield htmlConverter.paragraphStyle } - _modify { yield &htmlConverter.paragraphStyle } - } + private static let defaultBodyHTMLConverter = HTMLConverter( + font: .preferredFont(forTextStyle: .body), + monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)), + color: .label, + paragraphStyle: .default + ) + private(set) var hasEmojis = false var emojiIdentifier: AnyHashable? var emojiRequests: [ImageCache.Request] = [] - var emojiFont: UIFont { defaultFont } - var emojiTextColor: UIColor { defaultColor } + var emojiFont: UIFont = .preferredFont(forTextStyle: .body) + var emojiTextColor: UIColor = .label // The link range currently being previewed private var currentPreviewedLinkRange: NSRange? @@ -126,8 +114,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { } // MARK: - HTML Parsing - func setTextFromHtml(_ html: String) { - self.attributedText = htmlConverter.convert(html) + func setBodyTextFromHTML(_ html: String) { + self.attributedText = ContentTextView.defaultBodyHTMLConverter.convert(html) } // MARK: - Interaction @@ -213,10 +201,12 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? { let text = (self.text as NSString).substring(with: range) - if let mention = getMention(for: url, text: text) { - return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!) - } else if let tag = getHashtag(for: url, text: text) { - return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!) + if let mention = getMention(for: url, text: text), + let mastodonController { + return ProfileViewController(accountID: mention.id, mastodonController: mastodonController) + } else if let tag = getHashtag(for: url, text: text), + let mastodonController { + return HashtagTimelineViewController(for: tag, mastodonController: mastodonController) } else if url.scheme == "https" || url.scheme == "http" { let vc = SFSafariViewController(url: url) #if !os(visionOS) diff --git a/Tusker/Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift b/Tusker/Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift index 9bde0085..a0de3bf8 100644 --- a/Tusker/Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift +++ b/Tusker/Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift @@ -67,7 +67,8 @@ class TrendingHashtagCollectionViewCell: UICollectionViewCell { historyView.setHistory(hashtag.history) historyView.isHidden = hashtag.history == nil || hashtag.history!.count < 2 - if let history = hashtag.history { + if let history = hashtag.history, + history.count >= 2 { let sorted = history.sorted(by: { $0.day < $1.day }) let lastTwo = sorted[(sorted.count - 2)...] let accounts = lastTwo.map(\.accounts).reduce(0, +) diff --git a/Tusker/Views/Instance Cell/InstanceTableViewCell.swift b/Tusker/Views/Instance Cell/InstanceTableViewCell.swift index d398d655..d2108147 100644 --- a/Tusker/Views/Instance Cell/InstanceTableViewCell.swift +++ b/Tusker/Views/Instance Cell/InstanceTableViewCell.swift @@ -16,7 +16,7 @@ class InstanceTableViewCell: UITableViewCell { @IBOutlet weak var adultLabel: UILabel! @IBOutlet weak var descriptionTextView: ContentTextView! - var instance: Instance? + var instance: InstanceV1? var selectorInstance: InstanceSelector.Instance? var thumbnailURL: URL? @@ -34,8 +34,6 @@ class InstanceTableViewCell: UITableViewCell { adultLabel.layer.masksToBounds = true adultLabel.layer.cornerRadius = 0.5 * adultLabel.bounds.height - descriptionTextView.defaultFont = .preferredFont(forTextStyle: .body) - descriptionTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) descriptionTextView.adjustsFontForContentSizeCategory = true } @@ -49,17 +47,17 @@ class InstanceTableViewCell: UITableViewCell { domainLabel.text = instance.domain adultLabel.isHidden = instance.category != "adult" - descriptionTextView.setTextFromHtml(instance.description) + descriptionTextView.setBodyTextFromHTML(instance.description) updateThumbnail(url: instance.proxiedThumbnailURL) } - func updateUI(instance: Instance) { + func updateUI(instance: InstanceV1) { self.instance = instance self.selectorInstance = nil domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri adultLabel.isHidden = true - descriptionTextView.setTextFromHtml(instance.shortDescription ?? instance.description) + descriptionTextView.setBodyTextFromHTML(instance.shortDescription ?? instance.description) if let thumbnail = instance.thumbnail { updateThumbnail(url: thumbnail) diff --git a/Tusker/Views/Poll/StatusPollView.swift b/Tusker/Views/Poll/StatusPollView.swift index cfa26b62..dfe566e2 100644 --- a/Tusker/Views/Poll/StatusPollView.swift +++ b/Tusker/Views/Poll/StatusPollView.swift @@ -9,7 +9,7 @@ import UIKit import Pachyderm -class StatusPollView: UIView, StatusContentPollView { +class StatusPollView: UIView, StatusContentView { private static let formatter: DateComponentsFormatter = { let f = DateComponentsFormatter() diff --git a/Tusker/Views/Profile Header/ProfileFieldValueView.swift b/Tusker/Views/Profile Header/ProfileFieldValueView.swift index 0981343a..c535f6b3 100644 --- a/Tusker/Views/Profile Header/ProfileFieldValueView.swift +++ b/Tusker/Views/Profile Header/ProfileFieldValueView.swift @@ -14,12 +14,12 @@ import SafariServices class ProfileFieldValueView: UIView { weak var navigationDelegate: TuskerNavigationDelegate? - private static let converter: HTMLConverter = { - var converter = HTMLConverter() - converter.font = .preferredFont(forTextStyle: .body) - converter.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) - return converter - }() + private static let converter = HTMLConverter( + font: .preferredFont(forTextStyle: .body), + monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)), + color: .label, + paragraphStyle: .default + ) private let account: AccountMO private let field: Account.Field diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.swift b/Tusker/Views/Profile Header/ProfileHeaderView.swift index 1ea57ab1..7ea7719d 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderView.swift @@ -95,8 +95,6 @@ class ProfileHeaderView: UIView { relationshipLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14)) relationshipLabel.adjustsFontForContentSizeCategory = true - noteTextView.defaultFont = .preferredFont(forTextStyle: .body) - noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) noteTextView.adjustsFontForContentSizeCategory = true pagesSegmentedControl = ScrollingSegmentedControl(frame: .zero) @@ -140,7 +138,7 @@ class ProfileHeaderView: UIView { moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? []) noteTextView.navigationDelegate = delegate - noteTextView.setTextFromHtml(account.note) + noteTextView.setBodyTextFromHTML(account.note) noteTextView.setEmojis(account.emojis, identifier: account.id) if accountID == mastodonController.account?.id { diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.xib b/Tusker/Views/Profile Header/ProfileHeaderView.xib index 2d375314..92df2514 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.xib +++ b/Tusker/Views/Profile Header/ProfileHeaderView.xib @@ -1,9 +1,9 @@ - + - + @@ -46,7 +46,7 @@