From 9990d50e3e5cba3e4897f6c0f8c60aef56c4fdb5 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 7 Dec 2024 12:56:26 -0500 Subject: [PATCH 01/18] Revert "Use text view for profile field value view" This reverts commit c88076eec0599d90b80d059a69f7703863d8fed9. Closes #521 --- .../ProfileFieldValueView.swift | 166 ++++++++++++------ 1 file changed, 108 insertions(+), 58 deletions(-) diff --git a/Tusker/Views/Profile Header/ProfileFieldValueView.swift b/Tusker/Views/Profile Header/ProfileFieldValueView.swift index cfac804b3..beeba3297 100644 --- a/Tusker/Views/Profile Header/ProfileFieldValueView.swift +++ b/Tusker/Views/Profile Header/ProfileFieldValueView.swift @@ -12,11 +12,7 @@ import SwiftUI import SafariServices class ProfileFieldValueView: UIView { - weak var navigationDelegate: TuskerNavigationDelegate? { - didSet { - textView.navigationDelegate = navigationDelegate - } - } + weak var navigationDelegate: TuskerNavigationDelegate? private static let converter = HTMLConverter( font: .preferredFont(forTextStyle: .body), @@ -28,8 +24,9 @@ class ProfileFieldValueView: UIView { private let account: AccountMO private let field: Account.Field + private var link: (String, URL)? - private let textView = ContentTextView() + private let label = EmojiLabel() private var iconView: UIView? private var currentTargetedPreview: UITargetedPreview? @@ -42,28 +39,34 @@ class ProfileFieldValueView: UIView { let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value)) - #if os(visionOS) - textView.linkTextAttributes = [ - .foregroundColor: UIColor.link - ] - #else - textView.linkTextAttributes = [ - .foregroundColor: UIColor.tintColor - ] - #endif - textView.backgroundColor = nil - textView.isScrollEnabled = false - textView.isSelectable = false - textView.isEditable = false - textView.font = .preferredFont(forTextStyle: .body) - updateTextContainerInset() - textView.adjustsFontForContentSizeCategory = true - textView.attributedText = converted - textView.setEmojis(account.emojis, identifier: account.id) - textView.isUserInteractionEnabled = true - textView.setContentCompressionResistancePriority(.required, for: .vertical) - textView.translatesAutoresizingMaskIntoConstraints = false - addSubview(textView) + var range = NSRange(location: 0, length: 0) + if converted.length != 0, + let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL { + link = (converted.attributedSubstring(from: range).string, url) + label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped))) + label.addInteraction(UIContextMenuInteraction(delegate: self)) + label.isUserInteractionEnabled = true + + converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in + guard value != nil else { return } + #if os(visionOS) + converted.addAttribute(.foregroundColor, value: UIColor.link, range: range) + #else + converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range) + #endif + // the .link attribute in a UILabel always makes the color blue >.> + converted.removeAttribute(.link, range: range) + } + } + + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.attributedText = converted + label.setEmojis(account.emojis, identifier: account.id) + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) let labelTrailingConstraint: NSLayoutConstraint @@ -80,20 +83,20 @@ class ProfileFieldValueView: UIView { icon.isPointerInteractionEnabled = true icon.accessibilityLabel = "Verified link" addSubview(icon) - labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor) + labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor) NSLayoutConstraint.activate([ - icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor), + icon.centerYAnchor.constraint(equalTo: label.centerYAnchor), icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), ]) } else { - labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor) + labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor) } NSLayoutConstraint.activate([ - textView.leadingAnchor.constraint(equalTo: leadingAnchor), + label.leadingAnchor.constraint(equalTo: leadingAnchor), labelTrailingConstraint, - textView.topAnchor.constraint(equalTo: topAnchor), - textView.bottomAnchor.constraint(equalTo: bottomAnchor), + label.topAnchor.constraint(equalTo: topAnchor), + label.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } @@ -102,36 +105,37 @@ class ProfileFieldValueView: UIView { } override func sizeThatFits(_ size: CGSize) -> CGSize { - var size = textView.sizeThatFits(size) + var size = label.sizeThatFits(size) if let iconView { size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width } return size } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { - updateTextContainerInset() - } - } - - private func updateTextContainerInset() { - // blergh - switch traitCollection.preferredContentSizeCategory { - case .extraSmall: - textView.textContainerInset = UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 0) - case .small: - textView.textContainerInset = UIEdgeInsets(top: 3, left: 0, bottom: 0, right: 0) - case .medium, .large: - textView.textContainerInset = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) - default: - textView.textContainerInset = .zero - } - } - func setTextAlignment(_ alignment: NSTextAlignment) { - textView.textAlignment = alignment + label.textAlignment = alignment + } + + func getHashtagOrURL() -> (Hashtag?, URL)? { + guard let (text, url) = link else { + return nil + } + if text.starts(with: "#") { + return (Hashtag(name: String(text.dropFirst()), url: url), url) + } else { + return (nil, url) + } + } + + @objc private func linkTapped() { + guard let (hashtag, url) = getHashtagOrURL() else { + return + } + if let hashtag { + navigationDelegate?.selected(tag: hashtag) + } else { + navigationDelegate?.selected(url: url) + } } @objc private func verifiedIconTapped() { @@ -141,7 +145,7 @@ class ProfileFieldValueView: UIView { let view = ProfileFieldVerificationView( acct: account.acct, verifiedAt: field.verifiedAt!, - linkText: textView.text ?? "", + linkText: label.text ?? "", navigationDelegate: navigationDelegate ) let host = UIHostingController(rootView: view) @@ -165,3 +169,49 @@ class ProfileFieldValueView: UIView { navigationDelegate.present(toPresent, animated: true) } } + +extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + guard let (hashtag, url) = getHashtagOrURL(), + let navigationDelegate else { + return nil + } + if let hashtag { + return UIContextMenuConfiguration { + HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController) + } actionProvider: { _ in + UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self))) + } + } else { + return UIContextMenuConfiguration { + let vc = SFSafariViewController(url: url) + #if !os(visionOS) + vc.preferredControlTintColor = Preferences.shared.accentColor.color + #endif + return vc + } actionProvider: { _ in + UIMenu(children: self.actionsForURL(url, source: .view(self))) + } + } + } + + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? { + var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0) + // the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account + rect.origin.x = 0 + rect.origin.y = (bounds.height - rect.height) / 2 + let parameters = UIPreviewParameters(textLineRects: [rect as NSValue]) + let preview = UITargetedPreview(view: label, parameters: parameters) + currentTargetedPreview = preview + return preview + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? { + return currentTargetedPreview + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!) + } +} From 20692b0630c468480660633f7df03182daf23b9a Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 7 Dec 2024 13:00:07 -0500 Subject: [PATCH 02/18] Fix links in profile field values not at the beginning of the string not being tappable Fixes #501 --- .../ProfileFieldValueView.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Tusker/Views/Profile Header/ProfileFieldValueView.swift b/Tusker/Views/Profile Header/ProfileFieldValueView.swift index beeba3297..0d379b602 100644 --- a/Tusker/Views/Profile Header/ProfileFieldValueView.swift +++ b/Tusker/Views/Profile Header/ProfileFieldValueView.swift @@ -39,24 +39,24 @@ class ProfileFieldValueView: UIView { let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value)) - var range = NSRange(location: 0, length: 0) - if converted.length != 0, - let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL { - link = (converted.attributedSubstring(from: range).string, url) + converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in + guard value != nil else { return } + if self.link == nil { + self.link = (converted.attributedSubstring(from: range).string, value as! URL) + } + #if os(visionOS) + converted.addAttribute(.foregroundColor, value: UIColor.link, range: range) + #else + converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range) + #endif + // the .link attribute in a UILabel always makes the color blue >.> + converted.removeAttribute(.link, range: range) + } + + if link != nil { label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped))) label.addInteraction(UIContextMenuInteraction(delegate: self)) label.isUserInteractionEnabled = true - - converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in - guard value != nil else { return } - #if os(visionOS) - converted.addAttribute(.foregroundColor, value: UIColor.link, range: range) - #else - converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range) - #endif - // the .link attribute in a UILabel always makes the color blue >.> - converted.removeAttribute(.link, range: range) - } } label.numberOfLines = 0 From 242c60d74dc5dfb977ed3b128982d709842d32d9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 15 Dec 2024 13:18:47 -0500 Subject: [PATCH 03/18] Workaround for tab bar content VC not being in responder chain almost ever Closes #544, #179 --- .../ConversationViewController.swift | 8 ++++++++ .../Main/BaseMainTabBarViewController.swift | 16 ++++++++++++++++ .../Screens/Main/MainSplitViewController.swift | 13 +++++++++++++ .../NotificationsPageViewController.swift | 6 ++++++ .../Screens/Profile/ProfileViewController.swift | 6 ++++++ .../Timeline/TimelinesPageViewController.swift | 6 ++++++ 6 files changed, 55 insertions(+) diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 5b00a9d5e..53c8b92bc 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -484,3 +484,11 @@ extension ConversationViewController: StatusBarTappableViewController { } } } + +extension ConversationViewController: RefreshableViewController { + func refresh() { + Task { + await refreshContext() + } + } +} diff --git a/Tusker/Screens/Main/BaseMainTabBarViewController.swift b/Tusker/Screens/Main/BaseMainTabBarViewController.swift index 3019d6a41..f3eb83136 100644 --- a/Tusker/Screens/Main/BaseMainTabBarViewController.swift +++ b/Tusker/Screens/Main/BaseMainTabBarViewController.swift @@ -151,6 +151,22 @@ class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewC return false #endif // !os(visionOS) } + + // MARK: Keyboard shortcuts + + override func target(forAction action: Selector, withSender sender: Any?) -> Any? { + // This is a silly workaround for when the sidebar is focused (and therefore first responder), which, + // unfortunately, is almost always. Because the content view controller then isn't in the responder chain, + // we manually delegate to the top view controller if possible. + if action == #selector(RefreshableViewController.refresh), + let selected = selectedViewController as? NavigationControllerProtocol, + let top = selected.topViewController as? RefreshableViewController { + return top + } else { + return super.target(forAction: action, withSender: sender) + } + } + } extension BaseMainTabBarViewController: TuskerNavigationDelegate { diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 687be122b..e1c37957f 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -219,6 +219,19 @@ class MainSplitViewController: UISplitViewController { @objc func handleComposeKeyCommand() { compose(editing: nil) } + + override func target(forAction action: Selector, withSender sender: Any?) -> Any? { + // This is a silly workaround for when the sidebar is focused (and therefore first responder), which, + // unfortunately, is almost always. Because the content view controller then isn't in the responder chain, + // we manually delegate to the top view controller if possible. + if action == #selector(RefreshableViewController.refresh), + traitCollection.horizontalSizeClass == .regular, + let top = secondaryNavController.topViewController as? RefreshableViewController { + return top + } else { + return super.target(forAction: action, withSender: sender) + } + } } diff --git a/Tusker/Screens/Notifications/NotificationsPageViewController.swift b/Tusker/Screens/Notifications/NotificationsPageViewController.swift index d40cf7094..500ebdc1f 100644 --- a/Tusker/Screens/Notifications/NotificationsPageViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsPageViewController.swift @@ -180,3 +180,9 @@ extension NotificationsPageViewController: StateRestorableViewController { return currentPage.userActivity(accountID: mastodonController.accountInfo!.id) } } + +extension NotificationsPageViewController: RefreshableViewController { + func refresh() { + (currentViewController as? RefreshableViewController)?.refresh() + } +} diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift index 70633e577..b6617dc1c 100644 --- a/Tusker/Screens/Profile/ProfileViewController.swift +++ b/Tusker/Screens/Profile/ProfileViewController.swift @@ -393,3 +393,9 @@ extension ProfileViewController: StatusBarTappableViewController { return currentViewController.handleStatusBarTapped(xPosition: xPosition) } } + +extension ProfileViewController: RefreshableViewController { + func refresh() { + currentViewController.refresh() + } +} diff --git a/Tusker/Screens/Timeline/TimelinesPageViewController.swift b/Tusker/Screens/Timeline/TimelinesPageViewController.swift index 7ebe5f9c8..81123ba7b 100644 --- a/Tusker/Screens/Timeline/TimelinesPageViewController.swift +++ b/Tusker/Screens/Timeline/TimelinesPageViewController.swift @@ -212,3 +212,9 @@ extension TimelinesPageViewController: StateRestorableViewController { return (currentViewController as? TimelineViewController)?.stateRestorationActivity() } } + +extension TimelinesPageViewController: RefreshableViewController { + func refresh() { + (currentViewController as? RefreshableViewController)?.refresh() + } +} From 82ec1208715c26d23867742232b055d3a4d39dca Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 15 Dec 2024 13:27:09 -0500 Subject: [PATCH 04/18] Include rate limit reset date in error message Closes #548 --- .../Pachyderm/Sources/Pachyderm/Client.swift | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index 906cf2eed..6065e8419 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -25,27 +25,30 @@ public struct Client: Sendable { public var timeoutInterval: TimeInterval = 60 - static let decoder: JSONDecoder = { - let decoder = JSONDecoder() - + private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ" formatter.timeZone = TimeZone(abbreviation: "UTC") formatter.locale = Locale(identifier: "en_US_POSIX") - let iso8601 = ISO8601DateFormatter() + return formatter + }() + private static let iso8601Formatter = ISO8601DateFormatter() + private static func decodeDate(string: String) -> Date? { + // for the next time mastodon accidentally changes date formats >.> + return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string) + } + + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom({ (decoder) in let container = try decoder.singleValueContainer() let str = try container.decode(String.self) - // for the next time mastodon accidentally changes date formats >.> - if let date = formatter.date(from: str) { - return date - } else if let date = iso8601.date(from: str) { + if let date = Self.decodeDate(string: str) { return date } else { throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)")) } }) - return decoder }() @@ -105,6 +108,15 @@ public struct Client: Sendable { return task } + private func error(from response: HTTPURLResponse) -> ErrorType { + if response.statusCode == 429, + let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) { + return .rateLimited(date) + } else { + return .unexpectedStatus(response.statusCode) + } + } + @discardableResult public func run(_ request: Request) async throws -> (Result, Pagination?) { return try await withCheckedThrowingContinuation { continuation in @@ -575,6 +587,8 @@ extension Client { return "Invalid Model" case .mastodonError(let code, let error): return "Server Error (\(code)): \(error)" + case .rateLimited(let reset): + return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))" } } } @@ -585,6 +599,7 @@ extension Client { case invalidResponse case invalidModel(Swift.Error) case mastodonError(Int, String) + case rateLimited(Date) } enum NodeInfoError: LocalizedError { From e469d207b48bd963ba2dbd595af21054b7bb1a98 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 15 Dec 2024 13:31:03 -0500 Subject: [PATCH 05/18] Make "no alt" badge all caps --- Tusker/Views/Attachments/AttachmentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index 0a7edb4ff..0db1800dc 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -402,7 +402,7 @@ class AttachmentView: GIFImageView { makeBadgeView(text: "ALT") } if badges.contains(.noAlt) { - makeBadgeView(text: "No ALT") + makeBadgeView(text: "NO ALT") } let first = stack.arrangedSubviews.first! From 572c5a082405c1fc8006ed7672affd656b38b894 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 15 Dec 2024 13:52:26 -0500 Subject: [PATCH 06/18] Fix NotificationGroupTests not compiling --- .../NotificationGroupTests.swift | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Packages/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift b/Packages/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift index aa0798754..0c83834fe 100644 --- a/Packages/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift +++ b/Packages/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift @@ -197,72 +197,72 @@ class NotificationGroupTests: XCTestCase { func testGroupSimple() { let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite]) - XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!]) + XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!]) } func testGroupWithOtherGroupableInBetween() { let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite]) XCTAssertEqual(groups, [ - NotificationGroup(notifications: [likeA1, likeA2])!, - NotificationGroup(notifications: [likeB])!, + NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!, + NotificationGroup(notifications: [likeB], kind: .favourite)!, ]) } func testDontGroupWithUngroupableInBetween() { let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite]) XCTAssertEqual(groups, [ - NotificationGroup(notifications: [likeA1])!, - NotificationGroup(notifications: [mentionB])!, - NotificationGroup(notifications: [likeA2])!, + NotificationGroup(notifications: [likeA1], kind: .favourite)!, + NotificationGroup(notifications: [mentionB], kind: .mention)!, + NotificationGroup(notifications: [likeA2], kind: .favourite)!, ]) } func testMergeSimpleGroups() { - let group1 = NotificationGroup(notifications: [likeA1])! - let group2 = NotificationGroup(notifications: [likeA2])! + let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)! + let group2 = NotificationGroup(notifications: [likeA2], kind: .favourite)! let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite]) XCTAssertEqual(merged, [ - NotificationGroup(notifications: [likeA1, likeA2])! + NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)! ]) } func testMergeGroupsWithOtherGroupableInBetween() { - let group1 = NotificationGroup(notifications: [likeA1])! - let group2 = NotificationGroup(notifications: [likeB])! - let group3 = NotificationGroup(notifications: [likeA2])! + let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)! + let group2 = NotificationGroup(notifications: [likeB], kind: .favourite)! + let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)! let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite]) XCTAssertEqual(merged, [ - NotificationGroup(notifications: [likeA1, likeA2])!, - NotificationGroup(notifications: [likeB])!, + NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!, + NotificationGroup(notifications: [likeB], kind: .favourite)!, ]) let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite]) XCTAssertEqual(merged2, [ - NotificationGroup(notifications: [likeA1, likeA2])!, - NotificationGroup(notifications: [likeB])!, + NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!, + NotificationGroup(notifications: [likeB], kind: .favourite)!, ]) - let group4 = NotificationGroup(notifications: [likeB2])! - let group5 = NotificationGroup(notifications: [mentionB])! + let group4 = NotificationGroup(notifications: [likeB2], kind: .favourite)! + let group5 = NotificationGroup(notifications: [mentionB], kind: .mention)! let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite]) print(merged3.count) XCTAssertEqual(merged3, [ group1, group5, - NotificationGroup(notifications: [likeB, likeB2]), + NotificationGroup(notifications: [likeB, likeB2], kind: .favourite), group3 ]) } func testDontMergeWithUngroupableInBetween() { - let group1 = NotificationGroup(notifications: [likeA1])! - let group2 = NotificationGroup(notifications: [mentionB])! - let group3 = NotificationGroup(notifications: [likeA2])! + let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)! + let group2 = NotificationGroup(notifications: [mentionB], kind: .mention)! + let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)! let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite]) XCTAssertEqual(merged, [ - NotificationGroup(notifications: [likeA1])!, - NotificationGroup(notifications: [mentionB])!, - NotificationGroup(notifications: [likeA2])!, + NotificationGroup(notifications: [likeA1], kind: .favourite)!, + NotificationGroup(notifications: [mentionB], kind: .mention)!, + NotificationGroup(notifications: [likeA2], kind: .favourite)!, ]) } From adaf8dc217f0857bef9b8dcc7fae0efc3c08bc3d Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 15 Dec 2024 14:20:48 -0500 Subject: [PATCH 07/18] Replace WebURL with URL.ParseStrategy Closes #170 --- .../NotificationService.swift | 16 +--- Packages/Pachyderm/Package.swift | 4 +- .../Pachyderm/Sources/Pachyderm/Client.swift | 5 +- .../Pachyderm/Model/Announcement.swift | 5 +- .../Sources/Pachyderm/Model/Card.swift | 25 +++--- .../Sources/Pachyderm/Model/Emoji.swift | 16 +--- .../Sources/Pachyderm/Model/Hashtag.swift | 8 +- .../Sources/Pachyderm/Model/Mention.swift | 12 +-- .../Pachyderm/Model/Notification.swift | 5 +- .../Pachyderm/Model/PushNotification.swift | 5 +- .../Sources/Pachyderm/Model/Status.swift | 10 +-- .../Utilities/NotificationGroup.swift | 3 +- .../Pachyderm/Utilities/URLDecoder.swift | 82 +++++++++++++++++++ .../Tests/PachydermTests/URLTests.swift | 21 ++--- ShareExtension/ShareHostingController.swift | 3 +- Tusker.xcodeproj/project.pbxproj | 25 ------ Tusker/CoreData/FollowedHashtag.swift | 3 +- Tusker/CoreData/SavedHashtag.swift | 3 +- Tusker/CoreData/StatusMO.swift | 3 +- Tusker/HTMLConverter.swift | 15 +--- .../AnnouncementContentTextView.swift | 5 +- .../Announcements/AnnouncementListRow.swift | 5 +- .../ConversationViewController.swift | 6 +- .../Explore/ExploreViewController.swift | 6 +- .../TrendingHashtagsViewController.swift | 6 +- .../TrendingLinkCardCollectionViewCell.swift | 3 +- .../Explore/TrendingLinkCardView.swift | 1 - .../Explore/TrendingLinksViewController.swift | 14 ++-- .../Explore/TrendsViewController.swift | 19 +---- .../FastSwitchingAccountView.swift | 7 +- ...nNotificationGroupCollectionViewCell.swift | 2 +- ...otificationsCollectionViewController.swift | 2 +- .../Appearance/MockStatusView.swift | 5 +- .../Preferences/PrefsAccountView.swift | 8 +- .../Search/SearchResultsViewController.swift | 3 +- ...tatusActionAccountListViewController.swift | 3 +- Tusker/Screens/Utilities/Previewing.swift | 17 +--- Tusker/Views/AccountDisplayNameView.swift | 3 +- Tusker/Views/BaseEmojiLabel.swift | 7 +- Tusker/Views/ContentTextView.swift | 2 - Tusker/Views/CustomEmojiImageView.swift | 3 +- Tusker/Views/Status/StatusCardView.swift | 24 +++--- Tusker/Views/StatusContentTextView.swift | 5 +- 43 files changed, 187 insertions(+), 238 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift diff --git a/NotificationExtension/NotificationService.swift b/NotificationExtension/NotificationService.swift index a2754ee4b..9e00a67de 100644 --- a/NotificationExtension/NotificationService.swift +++ b/NotificationExtension/NotificationService.swift @@ -14,7 +14,6 @@ import OSLog import Pachyderm import Intents import HTMLStreamer -import WebURL import UIKit import TuskerPreferences @@ -238,8 +237,7 @@ class NotificationService: UNNotificationServiceExtension { for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() { let emojiName = (content.body as NSString).substring(with: match.range(at: 1)) guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }), - let url = URL(emoji.url), - let (data, _) = try? await URLSession.shared.data(from: url), + let (data, _) = try? await URLSession.shared.data(from: emoji.url), let image = UIImage(data: data) else { continue } @@ -368,17 +366,7 @@ private func decodeBase64URL(_ s: String) -> Data? { // copied from HTMLConverter.Callbacks, blergh private struct HTMLCallbacks: 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 let url = try? URL.ParseStrategy().parse(string) { - url - } else if let web = WebURL(string), - let url = URL(web) { - url - } else { - nil - } + try? URL.ParseStrategy().parse(string) } static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { diff --git a/Packages/Pachyderm/Package.swift b/Packages/Pachyderm/Package.swift index a1be7f1d9..959bd7b1a 100644 --- a/Packages/Pachyderm/Package.swift +++ b/Packages/Pachyderm/Package.swift @@ -7,6 +7,7 @@ let package = Package( name: "Pachyderm", platforms: [ .iOS(.v16), + .macOS(.v13), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. @@ -16,7 +17,6 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -24,8 +24,6 @@ let package = Package( .target( name: "Pachyderm", dependencies: [ - .product(name: "WebURL", package: "swift-url"), - .product(name: "WebURLFoundationExtras", package: "swift-url"), ], swiftSettings: [ .swiftLanguageMode(.v5) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index 6065e8419..f5f157e58 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -7,7 +7,6 @@ // import Foundation -import WebURL /** The base Mastodon API client. @@ -202,8 +201,8 @@ public struct Client: Sendable { let wellKnown = Request(method: .get, path: "/.well-known/nodeinfo") let wellKnownResults = try await run(wellKnown).0 if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), - let href = WebURL(url.href), - href.host == WebURL(self.baseURL)?.host { + let href = try? URL.ParseStrategy().parse(url.href), + href.host == self.baseURL.host() { let nodeInfo = Request(method: .get, path: Endpoint(stringLiteral: href.path)) return try await run(nodeInfo).0 } else { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift index b4735994d..1d7c61f13 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift @@ -6,7 +6,6 @@ // import Foundation -import WebURL public struct Announcement: Decodable, Sendable, Hashable, Identifiable { public let id: String @@ -60,7 +59,7 @@ extension Announcement { public struct Account: Decodable, Sendable, Hashable { public let id: String public let username: String - public let url: WebURL + @URLDecoder public var url: URL public let acct: String } } @@ -68,7 +67,7 @@ extension Announcement { extension Announcement { public struct Status: Decodable, Sendable, Hashable { public let id: String - public let url: WebURL + @URLDecoder public var url: URL } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift index f3dbd14e6..f60756a2f 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift @@ -7,18 +7,17 @@ // import Foundation -import WebURL public struct Card: Codable, Sendable { - public let url: WebURL + @URLDecoder public var url: URL public let title: String public let description: String - public let image: WebURL? + @OptionalURLDecoder public var image: URL? public let kind: Kind public let authorName: String? - public let authorURL: WebURL? + @OptionalURLDecoder public var authorURL: URL? public let providerName: String? - public let providerURL: WebURL? + @OptionalURLDecoder public var providerURL: URL? public let html: String? public let width: Int? public let height: Int? @@ -27,15 +26,15 @@ public struct Card: Codable, Sendable { public let history: [History]? public init( - url: WebURL, + url: URL, title: String, description: String, - image: WebURL? = nil, + image: URL? = nil, kind: Card.Kind, authorName: String? = nil, - authorURL: WebURL? = nil, + authorURL: URL? = nil, providerName: String? = nil, - providerURL: WebURL? = nil, + providerURL: URL? = nil, html: String? = nil, width: Int? = nil, height: Int? = nil, @@ -61,15 +60,15 @@ public struct Card: Codable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.url = try container.decode(WebURL.self, forKey: .url) + self._url = try container.decode(URLDecoder.self, forKey: .url) self.title = try container.decode(String.self, forKey: .title) self.description = try container.decode(String.self, forKey: .description) self.kind = try container.decode(Kind.self, forKey: .kind) - self.image = try? container.decodeIfPresent(WebURL.self, forKey: .image) + self._image = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .image) ?? nil self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName) - self.authorURL = try? container.decodeIfPresent(WebURL.self, forKey: .authorURL) + self._authorURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .authorURL) ?? nil self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName) - self.providerURL = try? container.decodeIfPresent(WebURL.self, forKey: .providerURL) + self._providerURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .providerURL) ?? nil self.html = try? container.decodeIfPresent(String.self, forKey: .html) self.width = try? container.decodeIfPresent(Int.self, forKey: .width) self.height = try? container.decodeIfPresent(Int.self, forKey: .height) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift index 8a11f139d..1d08e99c9 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift @@ -7,14 +7,11 @@ // import Foundation -import WebURL public struct Emoji: Codable, Sendable { public let shortcode: String - // these shouldn't need to be WebURLs as they're not external resources, - // but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient - public let url: WebURL - public let staticURL: WebURL + @URLDecoder public var url: URL + @URLDecoder public var staticURL: URL public let visibleInPicker: Bool public let category: String? @@ -22,13 +19,8 @@ public struct Emoji: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) self.shortcode = try container.decode(String.self, forKey: .shortcode) - do { - self.url = try container.decode(WebURL.self, forKey: .url) - } catch { - let s = try? container.decode(String.self, forKey: .url) - throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "")'", underlyingError: error)) - } - self.staticURL = try container.decode(WebURL.self, forKey: .staticURL) + self._url = try container.decode(URLDecoder.self, forKey: .url) + self._staticURL = try container.decode(URLDecoder.self, forKey: .staticURL) self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker) self.category = try container.decodeIfPresent(String.self, forKey: .category) } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift index 85b2c8775..3d86209af 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift @@ -7,12 +7,10 @@ // import Foundation -import WebURL -import WebURLFoundationExtras public struct Hashtag: Codable, Sendable { public let name: String - public let url: WebURL + @URLDecoder public var url: URL /// Only present when returned from the trending hashtags endpoint public let history: [History]? /// Only present on Mastodon >= 4 and when logged in @@ -20,7 +18,7 @@ public struct Hashtag: Codable, Sendable { public init(name: String, url: URL) { self.name = name - self.url = WebURL(url)! + self.url = url self.history = nil self.following = nil } @@ -29,7 +27,7 @@ public struct Hashtag: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decode(String.self, forKey: .name) // pixelfed (possibly others) don't fully escape special characters in the hashtag url - self.url = try container.decode(WebURL.self, forKey: .url) + self._url = try container.decode(URLDecoder.self, forKey: .url) self.history = try container.decodeIfPresent([History].self, forKey: .history) self.following = try container.decodeIfPresent(Bool.self, forKey: .following) } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift index f92d3c462..d48e13c73 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift @@ -7,10 +7,9 @@ // import Foundation -import WebURL public struct Mention: Codable, Sendable { - public let url: WebURL + @URLDecoder public var url: URL public let username: String public let acct: String /// The instance-local ID of the user being mentioned. @@ -21,15 +20,10 @@ public struct Mention: Codable, Sendable { self.username = try container.decode(String.self, forKey: .username) self.acct = try container.decode(String.self, forKey: .acct) self.id = try container.decode(String.self, forKey: .id) - do { - self.url = try container.decode(WebURL.self, forKey: .url) - } catch { - let s = try? container.decode(String.self, forKey: .url) - throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "Could not decode URL '\(s ?? "")'") - } + self._url = try container.decode(URLDecoder.self, forKey: .url) } - public init(url: WebURL, username: String, acct: String, id: String) { + public init(url: URL, username: String, acct: String, id: String) { self.url = url self.username = username self.acct = acct diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift index 2053b4a45..303e37bb4 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift @@ -7,7 +7,6 @@ // import Foundation -import WebURL public struct Notification: Decodable, Sendable { public let id: String @@ -18,7 +17,7 @@ public struct Notification: Decodable, Sendable { // Only present for pleroma emoji reactions // Either an emoji or :shortcode: (for akkoma custom emoji reactions) public let emoji: String? - public let emojiURL: WebURL? + @OptionalURLDecoder public var emojiURL: URL? public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -33,7 +32,7 @@ public struct Notification: Decodable, Sendable { self.account = try container.decode(Account.self, forKey: .account) self.status = try container.decodeIfPresent(Status.self, forKey: .status) self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji) - self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL) + self._emojiURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .emojiURL) ?? nil } public static func dismiss(id notificationID: String) -> Request { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/PushNotification.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/PushNotification.swift index 178cab03a..a8456e044 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/PushNotification.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/PushNotification.swift @@ -6,14 +6,13 @@ // import Foundation -import WebURL public struct PushNotification: Decodable { public let accessToken: String public let preferredLocale: String public let notificationID: String public let notificationType: Notification.Kind - public let icon: WebURL + @URLDecoder public var icon: URL public let title: String public let body: String @@ -29,7 +28,7 @@ public struct PushNotification: Decodable { self.notificationID = i.description } self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType) - self.icon = try container.decode(WebURL.self, forKey: .icon) + self._icon = try container.decode(URLDecoder.self, forKey: .icon) self.title = try container.decode(String.self, forKey: .title) self.body = try container.decode(String.self, forKey: .body) } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index 33e26e3df..0f5f1b824 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -7,7 +7,6 @@ // import Foundation -import WebURL public final class Status: StatusProtocol, Decodable, Sendable { /// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts. @@ -15,7 +14,8 @@ public final class Status: StatusProtocol, Decodable, Sendable { public let id: String public let uri: String - public let url: WebURL? + private let _url: OptionalURLDecoder + public var url: URL? { _url.wrappedValue } public let account: Account public let inReplyToID: String? public let inReplyToAccountID: String? @@ -55,13 +55,13 @@ public final class Status: StatusProtocol, Decodable, Sendable { self.id = try container.decode(String.self, forKey: .id) self.uri = try container.decode(String.self, forKey: .uri) do { - self.url = try container.decodeIfPresent(WebURL.self, forKey: .url) + self._url = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .url) ?? nil } catch { let s = try? container.decode(String.self, forKey: .url) if s == "" { - self.url = nil + self._url = OptionalURLDecoder(wrappedValue: nil) } else { - throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "")'", underlyingError: error)) + throw error } } self.account = try container.decode(Account.self, forKey: .account) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift index 47a9e5b15..38a4d34e2 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift @@ -7,7 +7,6 @@ // import Foundation -import WebURL public struct NotificationGroup: Identifiable, Hashable, Sendable { public private(set) var notifications: [Notification] @@ -150,7 +149,7 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable { case poll case update case status - case emojiReaction(String, WebURL?) + case emojiReaction(String, URL?) case unknown var notificationKind: Notification.Kind { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift b/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift new file mode 100644 index 000000000..502595fde --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift @@ -0,0 +1,82 @@ +// +// URLDecoder.swift +// Pachyderm +// +// Created by Shadowfacts on 12/15/24. +// +import Foundation + +private let parseStrategy = URL.ParseStrategy() + .scheme(.required) + .user(.optional) + .password(.optional) + .host(.required) + .port(.optional) + .path(.optional) + .query(.optional) + .fragment(.optional) + +private let formatStyle = URL.FormatStyle() + .scheme(.always) + .user(.omitWhen(.user, matches: [""])) + .password(.omitWhen(.password, matches: [""])) + .host(.always) + .port(.omitIfHTTPFamily) + .path(.always) + .query(.omitWhen(.query, matches: [""])) + .fragment(.omitWhen(.fragment, matches: [""])) + +@propertyWrapper +public struct URLDecoder: Codable, Sendable, Hashable { + public var wrappedValue: URL + + public init(wrappedValue: URL) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: any Decoder) throws { + let s = try decoder.singleValueContainer().decode(String.self) + self.wrappedValue = try parseStrategy.parse(s) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue.formatted(formatStyle)) + } +} + +@propertyWrapper +public struct OptionalURLDecoder: Codable, Sendable, Hashable, ExpressibleByNilLiteral { + public var wrappedValue: URL? + + public init(wrappedValue: URL?) { + self.wrappedValue = wrappedValue + } + + public init(nilLiteral: ()) { + self.wrappedValue = nil + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self.wrappedValue = nil + } else { + let s = try container.decode(String.self) + do { + self.wrappedValue = try parseStrategy.parse(s) + } catch { + throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Could not decode URL '\(s)'", underlyingError: error)) + } + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + if let wrappedValue { + try container.encode(wrappedValue.formatted(formatStyle)) + } else { + try container.encodeNil() + } + } +} diff --git a/Packages/Pachyderm/Tests/PachydermTests/URLTests.swift b/Packages/Pachyderm/Tests/PachydermTests/URLTests.swift index 7564c398b..328060dcb 100644 --- a/Packages/Pachyderm/Tests/PachydermTests/URLTests.swift +++ b/Packages/Pachyderm/Tests/PachydermTests/URLTests.swift @@ -6,20 +6,21 @@ // import XCTest -import WebURL -import WebURLFoundationExtras +@testable import Pachyderm class URLTests: XCTestCase { func testDecodeURL() { - XCTAssertNotNil(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!)) - XCTAssertNotNil(WebURL("https://xn--baw-joa.social/@unituebingen")) - XCTAssertNotNil(URLComponents(string: "https://xn--baw-joa.social/test/é")) - XCTAssertNotNil(WebURL("https://xn--baw-joa.social/test/é")) - if #available(iOS 16.0, *) { - XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é")) - XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭")) - } + XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é")) + XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭")) + } + + func testRoundtripURL() throws { + let orig = URLDecoder(wrappedValue: URL(string: "https://example.com")!) + let encoded = try JSONEncoder().encode(orig) + print(String(data: encoded, encoding: .utf8)!) + let decoded = try JSONDecoder().decode(URLDecoder.self, from: encoded) + XCTAssertEqual(orig.wrappedValue, decoded.wrappedValue) } } diff --git a/ShareExtension/ShareHostingController.swift b/ShareExtension/ShareHostingController.swift index dff8f3d71..4f42c5f69 100644 --- a/ShareExtension/ShareHostingController.swift +++ b/ShareExtension/ShareHostingController.swift @@ -9,7 +9,6 @@ import SwiftUI import ComposeUI import TuskerComponents -import WebURLFoundationExtras import Combine import TuskerPreferences @@ -46,7 +45,7 @@ class ShareHostingController: UIHostingController { currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) }, replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") }, emojiImageView: { - AnyView(AsyncImage(url: URL($0.url)!) { + AnyView(AsyncImage(url: $0.url) { $0 .resizable() .aspectRatio(contentMode: .fit) diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 3e451d7c4..5208f2243 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -103,7 +103,6 @@ D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; }; D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; }; D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; }; - D630C4252BC7845800208903 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4242BC7845800208903 /* WebURL */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; @@ -164,7 +163,6 @@ D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; }; - D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; @@ -818,7 +816,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D630C4252BC7845800208903 /* WebURL in Frameworks */, D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */, D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */, D630C3E52BC6313400208903 /* Pachyderm in Frameworks */, @@ -855,7 +852,6 @@ D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */, D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */, - D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */, ); @@ -1781,7 +1777,6 @@ D630C3E02BC61C6700208903 /* UserAccounts */, D630C3E42BC6313400208903 /* Pachyderm */, D630C4222BC7842C00208903 /* HTMLStreamer */, - D630C4242BC7845800208903 /* WebURL */, D62220462C7EA8DF003E43B7 /* TuskerPreferences */, ); productName = NotificationExtension; @@ -1833,7 +1828,6 @@ ); name = Tusker; packageProductDependencies = ( - D6676CA427A8D0020052936B /* WebURLFoundationExtras */, D674A50827F9128D00BA03AC /* Pachyderm */, D6552366289870790048A653 /* ScreenCorners */, D63CC701290EC0B8000E19DE /* Sentry */, @@ -1960,7 +1954,6 @@ ); mainGroup = D6D4DDC3212518A000E1C4BB; packageReferences = ( - D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */, D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */, D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */, D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */, @@ -3274,14 +3267,6 @@ minimumVersion = 1.0.1; }; }; - D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/karwa/swift-url"; - requirement = { - kind = exactVersion; - version = 0.4.2; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3319,11 +3304,6 @@ package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */; productName = HTMLStreamer; }; - D630C4242BC7845800208903 /* WebURL */ = { - isa = XCSwiftPackageProductDependency; - package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */; - productName = WebURL; - }; D635237029B78A7D009ED5E7 /* TuskerComponents */ = { isa = XCSwiftPackageProductDependency; productName = TuskerComponents; @@ -3342,11 +3322,6 @@ isa = XCSwiftPackageProductDependency; productName = TTTKit; }; - D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = { - isa = XCSwiftPackageProductDependency; - package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */; - productName = WebURLFoundationExtras; - }; D674A50827F9128D00BA03AC /* Pachyderm */ = { isa = XCSwiftPackageProductDependency; productName = Pachyderm; diff --git a/Tusker/CoreData/FollowedHashtag.swift b/Tusker/CoreData/FollowedHashtag.swift index 22156e275..b4fb4b812 100644 --- a/Tusker/CoreData/FollowedHashtag.swift +++ b/Tusker/CoreData/FollowedHashtag.swift @@ -9,7 +9,6 @@ import Foundation import CoreData import Pachyderm -import WebURLFoundationExtras @objc(FollowedHashtag) public final class FollowedHashtag: NSManagedObject { @@ -33,6 +32,6 @@ extension FollowedHashtag { convenience init(hashtag: Hashtag, context: NSManagedObjectContext) { self.init(context: context) self.name = hashtag.name - self.url = URL(hashtag.url)! + self.url = hashtag.url } } diff --git a/Tusker/CoreData/SavedHashtag.swift b/Tusker/CoreData/SavedHashtag.swift index f206d4564..446033cca 100644 --- a/Tusker/CoreData/SavedHashtag.swift +++ b/Tusker/CoreData/SavedHashtag.swift @@ -9,7 +9,6 @@ import Foundation import CoreData import Pachyderm -import WebURLFoundationExtras import UserAccounts @objc(SavedHashtag) @@ -42,6 +41,6 @@ extension SavedHashtag { self.init(context: context) self.accountID = account.id self.name = hashtag.name - self.url = URL(hashtag.url)! + self.url = hashtag.url } } diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index 364480ccc..fd183e409 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -10,7 +10,6 @@ import Foundation import CoreData import Pachyderm -import WebURLFoundationExtras @objc(StatusMO) public final class StatusMO: NSManagedObject, StatusProtocol { @@ -136,7 +135,7 @@ extension StatusMO { self.sensitive = status.sensitive self.spoilerText = status.spoilerText self.uri = status.uri - self.url = status.url != nil ? URL(status.url!) : nil + self.url = status.url self.visibility = status.visibility self.poll = status.poll self.localOnly = status.localOnly ?? false diff --git a/Tusker/HTMLConverter.swift b/Tusker/HTMLConverter.swift index c1975d232..68a995b1f 100644 --- a/Tusker/HTMLConverter.swift +++ b/Tusker/HTMLConverter.swift @@ -8,8 +8,7 @@ import UIKit import HTMLStreamer -import WebURL -import WebURLFoundationExtras +import Pachyderm class HTMLConverter { @@ -45,17 +44,7 @@ extension HTMLConverter { // note: this is duplicated in NotificationExtension 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 let url = try? URL.ParseStrategy().parse(string) { - url - } else if let web = WebURL(string), - let url = URL(web) { - url - } else { - nil - } + try? URL.ParseStrategy().parse(string) } static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { diff --git a/Tusker/Screens/Announcements/AnnouncementContentTextView.swift b/Tusker/Screens/Announcements/AnnouncementContentTextView.swift index cc9d15db4..792b1ae19 100644 --- a/Tusker/Screens/Announcements/AnnouncementContentTextView.swift +++ b/Tusker/Screens/Announcements/AnnouncementContentTextView.swift @@ -8,7 +8,6 @@ import UIKit import Pachyderm -import WebURL class AnnouncementContentTextView: ContentTextView { @@ -30,7 +29,7 @@ class AnnouncementContentTextView: ContentTextView { override func getMention(for url: URL, text: String) -> Mention? { announcement?.mentions.first { - URL($0.url) == url + $0.url == url }.map { Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id) } @@ -38,7 +37,7 @@ class AnnouncementContentTextView: ContentTextView { override func getHashtag(for url: URL, text: String) -> Hashtag? { announcement?.tags.first { - URL($0.url) == url + $0.url == url } } diff --git a/Tusker/Screens/Announcements/AnnouncementListRow.swift b/Tusker/Screens/Announcements/AnnouncementListRow.swift index 13371c20f..e62a786a0 100644 --- a/Tusker/Screens/Announcements/AnnouncementListRow.swift +++ b/Tusker/Screens/Announcements/AnnouncementListRow.swift @@ -9,7 +9,6 @@ import SwiftUI import Pachyderm import TuskerComponents -import WebURLFoundationExtras struct AnnouncementListRow: View { @Binding var announcement: Announcement @@ -116,8 +115,8 @@ struct AnnouncementListRow: View { let url: URL? let staticURL: URL? if case .custom(let emoji) = reaction { - url = URL(emoji.url) - staticURL = URL(emoji.staticURL) + url = emoji.url + staticURL = emoji.staticURL } else { url = nil staticURL = nil diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 53c8b92bc..933d8c9b3 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -8,8 +8,6 @@ import UIKit import Pachyderm -import WebURL -import WebURLFoundationExtras private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}") private func isLikelyMastodonRemoteStatus(url: URL) -> Bool { @@ -229,10 +227,10 @@ class ConversationViewController: UIViewController { let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") { effectiveURL = location } else { - effectiveURL = WebURL(url)!.serialized(excludingFragment: true) + effectiveURL = url.formatted(.url.fragment(.never)) } } else { - effectiveURL = WebURL(url)!.serialized(excludingFragment: true) + effectiveURL = url.formatted(.url.fragment(.never)) } let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true) diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 1534baa7a..dbf0e2be5 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -10,7 +10,6 @@ import UIKit import Combine import Pachyderm import CoreData -import WebURLFoundationExtras class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController { @@ -560,10 +559,7 @@ extension ExploreViewController: UICollectionViewDragDelegate { activity.displaysAuxiliaryScene = true provider = NSItemProvider(object: activity) case let .savedHashtag(hashtag): - guard let url = URL(hashtag.url) else { - return [] - } - provider = NSItemProvider(object: url as NSURL) + provider = NSItemProvider(object: hashtag.url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) diff --git a/Tusker/Screens/Explore/TrendingHashtagsViewController.swift b/Tusker/Screens/Explore/TrendingHashtagsViewController.swift index 28691fa4d..49f039e25 100644 --- a/Tusker/Screens/Explore/TrendingHashtagsViewController.swift +++ b/Tusker/Screens/Explore/TrendingHashtagsViewController.swift @@ -8,7 +8,6 @@ import UIKit import Pachyderm -import WebURLFoundationExtras import Combine class TrendingHashtagsViewController: UIViewController, CollectionViewController { @@ -277,11 +276,10 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate { extension TrendingHashtagsViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let item = dataSource.itemIdentifier(for: indexPath), - case let .tag(hashtag) = item, - let url = URL(hashtag.url) else { + case let .tag(hashtag) = item else { return [] } - let provider = NSItemProvider(object: url as NSURL) + let provider = NSItemProvider(object: hashtag.url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) diff --git a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift index 3d2c22d35..dd06dba80 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift +++ b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift @@ -9,7 +9,6 @@ #if !os(visionOS) import UIKit import Pachyderm -import WebURLFoundationExtras import HTMLStreamer class TrendingLinkCardCollectionViewCell: UICollectionViewCell { @@ -71,7 +70,7 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell { self.card = card self.thumbnailView.image = nil - thumbnailView.update(for: card.image.flatMap { URL($0) }, blurhash: card.blurhash) + thumbnailView.update(for: card.image, blurhash: card.blurhash) let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) titleLabel.text = title diff --git a/Tusker/Screens/Explore/TrendingLinkCardView.swift b/Tusker/Screens/Explore/TrendingLinkCardView.swift index 735f1257f..424256b2d 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardView.swift +++ b/Tusker/Screens/Explore/TrendingLinkCardView.swift @@ -9,7 +9,6 @@ #if os(visionOS) import SwiftUI import Pachyderm -import WebURLFoundationExtras import HTMLStreamer struct TrendingLinkCardView: View { diff --git a/Tusker/Screens/Explore/TrendingLinksViewController.swift b/Tusker/Screens/Explore/TrendingLinksViewController.swift index af73cbdf5..d390c41fb 100644 --- a/Tusker/Screens/Explore/TrendingLinksViewController.swift +++ b/Tusker/Screens/Explore/TrendingLinksViewController.swift @@ -8,7 +8,6 @@ import UIKit import Pachyderm -import WebURLFoundationExtras import SafariServices import Combine #if os(visionOS) @@ -293,21 +292,19 @@ extension TrendingLinksViewController: UICollectionViewDelegate { } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), - let url = URL(card.url) else { + guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else { return } - selected(url: url) + selected(url: card.url) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), - let url = URL(card.url), let cell = collectionView.cellForItem(at: indexPath) else { return nil } return UIContextMenuConfiguration { - let vc = SFSafariViewController(url: url) + let vc = SFSafariViewController(url: card.url) #if !os(visionOS) vc.preferredControlTintColor = Preferences.shared.accentColor.color #endif @@ -324,11 +321,10 @@ extension TrendingLinksViewController: UICollectionViewDelegate { extension TrendingLinksViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), - let url = URL(card.url) else { + guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else { return [] } - return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] + return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))] } } diff --git a/Tusker/Screens/Explore/TrendsViewController.swift b/Tusker/Screens/Explore/TrendsViewController.swift index 511c22b8f..96223bc26 100644 --- a/Tusker/Screens/Explore/TrendsViewController.swift +++ b/Tusker/Screens/Explore/TrendsViewController.swift @@ -513,9 +513,7 @@ extension TrendsViewController: UICollectionViewDelegate { show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) case let .link(card): - if let url = URL(card.url) { - selected(url: url) - } + selected(url: card.url) case let .status(id, state): selected(status: id, state: state.copy()) @@ -544,12 +542,9 @@ extension TrendsViewController: UICollectionViewDelegate { } case let .link(card): - guard let url = URL(card.url) else { - return nil - } let cell = collectionView.cellForItem(at: indexPath)! return UIContextMenuConfiguration { - let vc = SFSafariViewController(url: url) + let vc = SFSafariViewController(url: card.url) #if !os(visionOS) vc.preferredControlTintColor = Preferences.shared.accentColor.color #endif @@ -624,10 +619,7 @@ extension TrendsViewController: UICollectionViewDragDelegate { return [] case let .tag(hashtag): - guard let url = URL(hashtag.url) else { - return [] - } - let provider = NSItemProvider(object: url as NSURL) + let provider = NSItemProvider(object: hashtag.url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) @@ -635,10 +627,7 @@ extension TrendsViewController: UICollectionViewDragDelegate { return [UIDragItem(itemProvider: provider)] case let .link(card): - guard let url = URL(card.url) else { - return [] - } - return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] + return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))] case let .status(id, _): guard let status = mastodonController.persistentContainer.status(for: id), diff --git a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift index a4de09d00..e43787478 100644 --- a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift +++ b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift @@ -8,7 +8,6 @@ import UIKit import UserAccounts -import WebURL class FastSwitchingAccountView: UIView { @@ -131,11 +130,7 @@ class FastSwitchingAccountView: UIView { private func setupAccount(account: UserAccountInfo) { usernameLabel.text = account.username - if let domain = WebURL.Domain(account.instanceURL.host!) { - instanceLabel.text = domain.render(.uncheckedUnicodeString) - } else { - instanceLabel.text = account.instanceURL.host! - } + instanceLabel.text = account.instanceURL.host(percentEncoded: false) let controller = MastodonController.getForAccount(account) avatarTask = Task { guard let account = try? await controller.getOwnAccount(), diff --git a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift index 1494c4fa1..9c4e7bf43 100644 --- a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift @@ -155,7 +155,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { fetchCustomEmojiImage?.1.cancel() case .emojiReaction(let emojiOrShortcode, let url): iconImageView.image = nil - if let url = url.flatMap({ URL($0) }), + if let url, fetchCustomEmojiImage?.0 != url { fetchCustomEmojiImage?.1.cancel() let task = Task { diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index acd5b01a9..9643aa14c 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -740,7 +740,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate { return cell.dragItemsForBeginning(session: session) case .poll, .update: let status = group.notifications.first!.status! - let provider = NSItemProvider(object: URL(status.url!)! as NSURL) + let provider = NSItemProvider(object: status.url! as NSURL) let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) diff --git a/Tusker/Screens/Preferences/Appearance/MockStatusView.swift b/Tusker/Screens/Preferences/Appearance/MockStatusView.swift index 5cbd46e25..30fcc5abf 100644 --- a/Tusker/Screens/Preferences/Appearance/MockStatusView.swift +++ b/Tusker/Screens/Preferences/Appearance/MockStatusView.swift @@ -8,7 +8,6 @@ import SwiftUI import Pachyderm -import WebURL struct MockStatusView: View { @ObservedObject private var preferences = Preferences.shared @@ -136,8 +135,8 @@ private struct MockStatusCardView: UIViewRepresentable { let view = StatusCardView() view.isUserInteractionEnabled = false let card = StatusCardView.CardData( - url: WebURL("https://vaccor.space/tusker")!, - image: WebURL("https://vaccor.space/tusker/img/icon.png")!, + url: URL(string: "https://vaccor.space/tusker")!, + image: URL(string: "https://vaccor.space/tusker/img/icon.png")!, title: "Tusker", description: "Tusker is an iOS app for Mastodon" ) diff --git a/Tusker/Screens/Preferences/PrefsAccountView.swift b/Tusker/Screens/Preferences/PrefsAccountView.swift index 8eda85ff2..595ef095c 100644 --- a/Tusker/Screens/Preferences/PrefsAccountView.swift +++ b/Tusker/Screens/Preferences/PrefsAccountView.swift @@ -8,7 +8,6 @@ import SwiftUI import UserAccounts -import WebURL struct PrefsAccountView: View { let account: UserAccountInfo @@ -19,12 +18,7 @@ struct PrefsAccountView: View { VStack(alignment: .prefsAvatar) { Text(verbatim: account.username) .foregroundColor(.primary) - let instance = if let domain = WebURL.Domain(account.instanceURL.host!) { - domain.render(.uncheckedUnicodeString) - } else { - account.instanceURL.host! - } - Text(verbatim: instance) + Text(verbatim: account.instanceURL.host(percentEncoded: false)!) .font(.caption) .foregroundColor(.primary) } diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index 714769d85..c0f5224de 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -9,7 +9,6 @@ import UIKit import Combine import Pachyderm -import WebURLFoundationExtras fileprivate let accountCell = "accountCell" fileprivate let statusCell = "statusCell" @@ -538,7 +537,7 @@ extension SearchResultsViewController: UICollectionViewDragDelegate { url = account.url activity = UserActivityManager.showProfileActivity(id: id, accountID: accountInfo.id) case .hashtag(let tag): - url = URL(tag.url)! + url = tag.url activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: accountInfo.id)! case .status(let id, _): guard let status = mastodonController.persistentContainer.status(for: id), diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift index b2ea25a3f..afeb92901 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -8,7 +8,6 @@ import UIKit import Pachyderm -import WebURL class StatusActionAccountListViewController: UIViewController { @@ -183,7 +182,7 @@ extension StatusActionAccountListViewController { enum ActionType { case favorite case reblog - case emojiReaction(String, WebURL?) + case emojiReaction(String, URL?) init?(_ groupKind: NotificationGroup.Kind) { switch groupKind { diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 5b9801cb1..927c4799c 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -9,7 +9,6 @@ import UIKit import SafariServices import Pachyderm -import WebURLFoundationExtras import SwiftUI @MainActor @@ -154,12 +153,7 @@ extension MenuActionProvider { } } - let shareSection: [UIMenuElement] - if let url = URL(hashtag.url) { - shareSection = actionsForURL(url, source: source) - } else { - shareSection = [] - } + let shareSection = actionsForURL(hashtag.url, source: source) return [ UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection), @@ -375,14 +369,11 @@ extension MenuActionProvider { } func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] { - guard let url = URL(card.url) else { - return [] - } return [ - openInSafariAction(url: url), + openInSafariAction(url: card.url), createAction(identifier: "share", title: "Share…", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in guard let self else { return } - self.navigationDelegate?.showMoreOptions(forURL: url, source: source) + self.navigationDelegate?.showMoreOptions(forURL: card.url, source: source) }), createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in guard let self = self else { return } @@ -393,7 +384,7 @@ extension MenuActionProvider { text += title text += ":\n" } - text += url.absoluteString + text += card.url.absoluteString let draft = self.mastodonController!.createDraft(text: text) self.navigationDelegate?.compose(editing: draft) diff --git a/Tusker/Views/AccountDisplayNameView.swift b/Tusker/Views/AccountDisplayNameView.swift index 2cda0c0f7..7b58395e3 100644 --- a/Tusker/Views/AccountDisplayNameView.swift +++ b/Tusker/Views/AccountDisplayNameView.swift @@ -8,7 +8,6 @@ import SwiftUI import Pachyderm -import WebURLFoundationExtras import os private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) @@ -54,7 +53,7 @@ struct AccountDisplayNameView: View { } group.enter() - let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in + let request = ImageCache.emojis.get(emoji.url) { (_, image) in defer { group.leave() } guard let image = image else { return } diff --git a/Tusker/Views/BaseEmojiLabel.swift b/Tusker/Views/BaseEmojiLabel.swift index 92b0d6e4b..162679128 100644 --- a/Tusker/Views/BaseEmojiLabel.swift +++ b/Tusker/Views/BaseEmojiLabel.swift @@ -8,7 +8,6 @@ import UIKit import Pachyderm -import WebURLFoundationExtras import os private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) @@ -73,7 +72,7 @@ extension BaseEmojiLabel { foundEmojis = true - if let image = ImageCache.emojis.get(URL(emoji.url)!)?.image { + if let image = ImageCache.emojis.get(emoji.url)?.image { // if the image is cached, add it immediately. // we generate the thumbnail on the main thread, because it's usually fast enough // and the delay caused by doing it asynchronously looks works. @@ -90,7 +89,7 @@ extension BaseEmojiLabel { // otherwise, perform the network request group.enter() - let request = ImageCache.emojis.getFromSource(URL(emoji.url)!) { (_, image) in + let request = ImageCache.emojis.getFromSource(emoji.url) { (_, image) in guard let image else { group.leave() return @@ -98,7 +97,7 @@ extension BaseEmojiLabel { image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in guard let thumbnail = thumbnail?.cgImage, case let rescaled = UIImage(cgImage: thumbnail, scale: screenScale, orientation: .up), - let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: rescaled) else { + let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: rescaled) else { group.leave() return } diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index d2a2e5041..41230698c 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -9,8 +9,6 @@ import UIKit import Pachyderm import SafariServices -import WebURL -import WebURLFoundationExtras import Combine private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) diff --git a/Tusker/Views/CustomEmojiImageView.swift b/Tusker/Views/CustomEmojiImageView.swift index 9155696dc..ae784df93 100644 --- a/Tusker/Views/CustomEmojiImageView.swift +++ b/Tusker/Views/CustomEmojiImageView.swift @@ -8,7 +8,6 @@ import SwiftUI import Pachyderm -import WebURLFoundationExtras struct CustomEmojiImageView: View { let emoji: Emoji @@ -35,7 +34,7 @@ struct CustomEmojiImageView: View { @MainActor private func loadImage() { - request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in + request = ImageCache.emojis.get(emoji.url) { (_, image) in DispatchQueue.main.async { self.request = nil if let image = image { diff --git a/Tusker/Views/Status/StatusCardView.swift b/Tusker/Views/Status/StatusCardView.swift index a8feb6bd7..40f8ee44d 100644 --- a/Tusker/Views/Status/StatusCardView.swift +++ b/Tusker/Views/Status/StatusCardView.swift @@ -9,8 +9,6 @@ import UIKit import Pachyderm import SafariServices -import WebURL -import WebURLFoundationExtras import HTMLStreamer class StatusCardView: UIView { @@ -184,14 +182,14 @@ class StatusCardView: UIView { if sensitive { if let blurhash = card.blurhash { imageView.blurImage = false - imageView.showOnlyBlurHash(blurhash, for: URL(image)!) + imageView.showOnlyBlurHash(blurhash, for: image) } else { // if we don't have a blurhash, load the image and show it behind a blur imageView.blurImage = true - imageView.update(for: URL(image), blurhash: nil) + imageView.update(for: image, blurhash: nil) } } else { - imageView.update(for: URL(image), blurhash: card.blurhash) + imageView.update(for: image, blurhash: card.blurhash) } imageView.isHidden = false leadingSpacer.isHidden = true @@ -210,8 +208,8 @@ class StatusCardView: UIView { descriptionLabel.text = description descriptionLabel.isHidden = description.isEmpty - if let host = card.url.host { - domainLabel.text = host.serialized + if let host = card.url.host(percentEncoded: false) { + domainLabel.text = host domainLabel.isHidden = false } else { domainLabel.isHidden = true @@ -238,7 +236,7 @@ class StatusCardView: UIView { setNeedsDisplay() if let card = card, let delegate = navigationDelegate { - delegate.selected(url: URL(card.url)!) + delegate.selected(url: card.url) } } @@ -248,8 +246,8 @@ class StatusCardView: UIView { } struct CardData: Equatable { - let url: WebURL - let image: WebURL? + let url: URL + let image: URL? let title: String let description: String let blurhash: String? @@ -262,7 +260,7 @@ class StatusCardView: UIView { self.blurhash = card.blurhash } - init(url: WebURL, image: WebURL? = nil, title: String, description: String, blurhash: String? = nil) { + init(url: URL, image: URL? = nil, title: String, description: String, blurhash: String? = nil) { self.url = url self.image = image self.title = title @@ -278,13 +276,13 @@ extension StatusCardView: UIContextMenuInteractionDelegate { guard let card = card else { return nil } return UIContextMenuConfiguration(identifier: nil) { - let vc = SFSafariViewController(url: URL(card.url)!) + let vc = SFSafariViewController(url: card.url) #if !os(visionOS) vc.preferredControlTintColor = Preferences.shared.accentColor.color #endif return vc } actionProvider: { (_) in - let actions = self.actionProvider?.actionsForURL(URL(card.url)!, source: .view(self)) ?? [] + let actions = self.actionProvider?.actionsForURL(card.url, source: .view(self)) ?? [] return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) } } diff --git a/Tusker/Views/StatusContentTextView.swift b/Tusker/Views/StatusContentTextView.swift index 1fdaee45c..d7458c510 100644 --- a/Tusker/Views/StatusContentTextView.swift +++ b/Tusker/Views/StatusContentTextView.swift @@ -8,7 +8,6 @@ import UIKit import Pachyderm -import WebURLFoundationExtras class StatusContentTextView: ContentTextView { @@ -26,7 +25,7 @@ class StatusContentTextView: ContentTextView { let mastodonController = mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) { mention = status.mentions.first { (mention) in - url.host == mention.url.host!.serialized && ( + url.host() == mention.url.host() && ( text.dropFirst() == mention.username // Mastodon and Pleroma include @ in the text || text.dropFirst() == mention.acct // Misskey includes @ and uses the whole acct || text == mention.username // GNU Social does not include the @ in the text, so we don't need to drop it @@ -44,7 +43,7 @@ class StatusContentTextView: ContentTextView { let mastodonController = mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) { hashtag = status.hashtags.first { (hashtag) in - URL(hashtag.url) == url + hashtag.url == url } } else { hashtag = nil From a68d2ce9525ac782b26e0b3348e1711ee536493c Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 15 Dec 2024 17:07:38 -0500 Subject: [PATCH 08/18] Fix compiling for visionOS --- Tusker/Screens/Explore/TrendingLinkCardView.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Tusker/Screens/Explore/TrendingLinkCardView.swift b/Tusker/Screens/Explore/TrendingLinkCardView.swift index 424256b2d..4ffeabd5d 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardView.swift +++ b/Tusker/Screens/Explore/TrendingLinkCardView.swift @@ -15,15 +15,11 @@ struct TrendingLinkCardView: View { let card: Card private var imageURL: URL? { - if let image = card.image { - URL(image) - } else { - nil - } + card.image } private var descriptionText: String { - var converter = TextConverter(configuration: .init(insertNewlines: false)) + let converter = TextConverter(configuration: .init(insertNewlines: false)) return converter.convert(html: card.description) } From 26c483fc9a20061c60cda312d8ff37888bff9c77 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 15 Dec 2024 20:28:08 -0500 Subject: [PATCH 09/18] Bump build number and update changelog --- CHANGELOG.md | 8 ++++++++ Version.xcconfig | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d06b058e..ecbde4919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2024.5 (138) +Bugfixes: +- Fix potential crash when displaying certain attachments +- Fix potential crash due to race condition when opening push notification in app +- Fix misaligned text between profile field values/labels +- Fix rate limited error message not including reset timestamp +- iPadOS/macOS: Fix Cmd+R shortcut not working + ## 2024.5 (137) Features/Improvements: - Improve gallery presentation/dismissal transitions diff --git a/Version.xcconfig b/Version.xcconfig index 7ed3937ec..a2865d0fe 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -10,7 +10,7 @@ // https://help.apple.com/xcode/#/dev745c5c974 MARKETING_VERSION = 2024.5 -CURRENT_PROJECT_VERSION = 137 +CURRENT_PROJECT_VERSION = 138 CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev From 54376ac58595cefc45c889fb6bce89a0cc8c629c Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 16 Dec 2024 19:10:23 -0500 Subject: [PATCH 10/18] Handle empty urls in OptionalURLDecoder Closes #553 --- .../Sources/Pachyderm/Utilities/URLDecoder.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift b/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift index 502595fde..fff687768 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift @@ -63,10 +63,14 @@ public struct OptionalURLDecoder: Codable, Sendable, Hashable, ExpressibleByNilL self.wrappedValue = nil } else { let s = try container.decode(String.self) - do { - self.wrappedValue = try parseStrategy.parse(s) - } catch { - throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Could not decode URL '\(s)'", underlyingError: error)) + if s.isEmpty { + self.wrappedValue = nil + } else { + do { + self.wrappedValue = try parseStrategy.parse(s) + } catch { + throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Could not decode URL '\(s)'", underlyingError: error)) + } } } } From 5ce9892a9ba2b89f60b7d9d58e12d33d7372a7f3 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 16 Dec 2024 20:17:44 -0500 Subject: [PATCH 11/18] Bump build number and update changelog --- CHANGELOG.md | 4 ++++ Version.xcconfig | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecbde4919..81fc1621b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2024.5 (139) +Bugfixes: +- Fix error decoding certain posts + ## 2024.5 (138) Bugfixes: - Fix potential crash when displaying certain attachments diff --git a/Version.xcconfig b/Version.xcconfig index a2865d0fe..a0c8fd202 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -10,7 +10,7 @@ // https://help.apple.com/xcode/#/dev745c5c974 MARKETING_VERSION = 2024.5 -CURRENT_PROJECT_VERSION = 138 +CURRENT_PROJECT_VERSION = 139 CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev From 4ea61542a035c520052d2c49910efaede17adbba Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 26 Dec 2024 17:25:31 -0500 Subject: [PATCH 12/18] Fix gallery buttons changing position during dismiss animation Closes #554 --- .../Sources/GalleryVC/GalleryItemViewController.swift | 4 ++++ .../GalleryVC/Sources/GalleryVC/GalleryViewController.swift | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift index 3c5df9123..ba2c6eec1 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift @@ -11,6 +11,7 @@ import AVFoundation @MainActor protocol GalleryItemViewControllerDelegate: AnyObject { func isGalleryBeingPresented() -> Bool + func isGalleryBeingDismissed() -> Bool func addPresentationAnimationCompletion(_ block: @escaping () -> Void) func galleryItemClose(_ item: GalleryItemViewController) func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]? @@ -376,6 +377,9 @@ class GalleryItemViewController: UIViewController { } private func updateTopControlsInsets() { + guard delegate?.isGalleryBeingDismissed() != true else { + return + } let notchedDeviceTopInsets: [CGFloat] = [ 44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max 48, // iPhone XR, 11 diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift index 8013c3fa7..d9381ebf3 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift @@ -139,6 +139,10 @@ extension GalleryViewController: GalleryItemViewControllerDelegate { isBeingPresented } + func isGalleryBeingDismissed() -> Bool { + isBeingDismissed + } + func addPresentationAnimationCompletion(_ block: @escaping () -> Void) { presentationAnimationCompletionHandlers.append(block) } From 666d2c468a6804f2e4d67ca64d0983bf715f3b03 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 26 Dec 2024 17:25:49 -0500 Subject: [PATCH 13/18] Fix gallery controls being positioned incorrectly in landscape --- .../GalleryVC/GalleryItemViewController.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift index ba2c6eec1..5b93ebe46 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift @@ -386,7 +386,18 @@ class GalleryItemViewController: UIViewController { 47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus 50, // iPhone 12 mini, 13 mini ] - if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) { + let topInset: CGFloat + switch view.window?.windowScene?.interfaceOrientation { + case .portraitUpsideDown: + topInset = view.safeAreaInsets.bottom + case .landscapeLeft: + topInset = view.safeAreaInsets.right + case .landscapeRight: + topInset = view.safeAreaInsets.left + default: + topInset = view.safeAreaInsets.top + } + if notchedDeviceTopInsets.contains(topInset) { // the notch width is not the same for the iPhones 13, // but what we actually want is the same offset from the edges // since the corner radius didn't change @@ -395,7 +406,7 @@ class GalleryItemViewController: UIViewController { let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2 shareButtonLeadingConstraint.constant = offset closeButtonTrailingConstraint.constant = offset - } else if view.safeAreaInsets.top == 0 { + } else if topInset == 0 { // square corner devices shareButtonLeadingConstraint.constant = 8 shareButtonTopConstraint.constant = 8 From a35b72d256d59eede6e6e115d6953418ed633c13 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 26 Dec 2024 17:31:18 -0500 Subject: [PATCH 14/18] Revert "Replace WebURL with URL.ParseStrategy" This reverts commit adaf8dc217f0857bef9b8dcc7fae0efc3c08bc3d. --- .../NotificationService.swift | 16 +++- Packages/Pachyderm/Package.swift | 4 +- .../Pachyderm/Sources/Pachyderm/Client.swift | 5 +- .../Pachyderm/Model/Announcement.swift | 5 +- .../Sources/Pachyderm/Model/Card.swift | 25 +++--- .../Sources/Pachyderm/Model/Emoji.swift | 16 +++- .../Sources/Pachyderm/Model/Hashtag.swift | 8 +- .../Sources/Pachyderm/Model/Mention.swift | 12 ++- .../Pachyderm/Model/Notification.swift | 5 +- .../Pachyderm/Model/PushNotification.swift | 5 +- .../Sources/Pachyderm/Model/Status.swift | 10 +-- .../Utilities/NotificationGroup.swift | 3 +- .../Pachyderm/Utilities/URLDecoder.swift | 86 ------------------- .../Tests/PachydermTests/URLTests.swift | 21 +++-- ShareExtension/ShareHostingController.swift | 3 +- Tusker.xcodeproj/project.pbxproj | 25 ++++++ Tusker/CoreData/FollowedHashtag.swift | 3 +- Tusker/CoreData/SavedHashtag.swift | 3 +- Tusker/CoreData/StatusMO.swift | 3 +- Tusker/HTMLConverter.swift | 15 +++- .../AnnouncementContentTextView.swift | 5 +- .../Announcements/AnnouncementListRow.swift | 5 +- .../ConversationViewController.swift | 6 +- .../Explore/ExploreViewController.swift | 6 +- .../TrendingHashtagsViewController.swift | 6 +- .../TrendingLinkCardCollectionViewCell.swift | 3 +- .../Explore/TrendingLinkCardView.swift | 1 + .../Explore/TrendingLinksViewController.swift | 14 +-- .../Explore/TrendsViewController.swift | 19 +++- .../FastSwitchingAccountView.swift | 7 +- ...nNotificationGroupCollectionViewCell.swift | 2 +- ...otificationsCollectionViewController.swift | 2 +- .../Appearance/MockStatusView.swift | 5 +- .../Preferences/PrefsAccountView.swift | 8 +- .../Search/SearchResultsViewController.swift | 3 +- ...tatusActionAccountListViewController.swift | 3 +- Tusker/Screens/Utilities/Previewing.swift | 17 +++- Tusker/Views/AccountDisplayNameView.swift | 3 +- Tusker/Views/BaseEmojiLabel.swift | 7 +- Tusker/Views/ContentTextView.swift | 2 + Tusker/Views/CustomEmojiImageView.swift | 3 +- Tusker/Views/Status/StatusCardView.swift | 24 +++--- Tusker/Views/StatusContentTextView.swift | 5 +- 43 files changed, 238 insertions(+), 191 deletions(-) delete mode 100644 Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift diff --git a/NotificationExtension/NotificationService.swift b/NotificationExtension/NotificationService.swift index 9e00a67de..a2754ee4b 100644 --- a/NotificationExtension/NotificationService.swift +++ b/NotificationExtension/NotificationService.swift @@ -14,6 +14,7 @@ import OSLog import Pachyderm import Intents import HTMLStreamer +import WebURL import UIKit import TuskerPreferences @@ -237,7 +238,8 @@ class NotificationService: UNNotificationServiceExtension { for match in emojiRegex.matches(in: content.body, range: NSRange(location: 0, length: content.body.utf16.count)).reversed() { let emojiName = (content.body as NSString).substring(with: match.range(at: 1)) guard let emoji = notification.status?.emojis.first(where: { $0.shortcode == emojiName }), - let (data, _) = try? await URLSession.shared.data(from: emoji.url), + let url = URL(emoji.url), + let (data, _) = try? await URLSession.shared.data(from: url), let image = UIImage(data: data) else { continue } @@ -366,7 +368,17 @@ private func decodeBase64URL(_ s: String) -> Data? { // copied from HTMLConverter.Callbacks, blergh private struct HTMLCallbacks: HTMLConversionCallbacks { static func makeURL(string: String) -> URL? { - try? URL.ParseStrategy().parse(string) + // 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 let url = try? URL.ParseStrategy().parse(string) { + url + } else if let web = WebURL(string), + let url = URL(web) { + url + } else { + nil + } } static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { diff --git a/Packages/Pachyderm/Package.swift b/Packages/Pachyderm/Package.swift index 959bd7b1a..a1be7f1d9 100644 --- a/Packages/Pachyderm/Package.swift +++ b/Packages/Pachyderm/Package.swift @@ -7,7 +7,6 @@ let package = Package( name: "Pachyderm", platforms: [ .iOS(.v16), - .macOS(.v13), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. @@ -17,6 +16,7 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. + .package(url: "https://github.com/karwa/swift-url.git", exact: "0.4.2"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -24,6 +24,8 @@ let package = Package( .target( name: "Pachyderm", dependencies: [ + .product(name: "WebURL", package: "swift-url"), + .product(name: "WebURLFoundationExtras", package: "swift-url"), ], swiftSettings: [ .swiftLanguageMode(.v5) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index f5f157e58..6065e8419 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -7,6 +7,7 @@ // import Foundation +import WebURL /** The base Mastodon API client. @@ -201,8 +202,8 @@ public struct Client: Sendable { let wellKnown = Request(method: .get, path: "/.well-known/nodeinfo") let wellKnownResults = try await run(wellKnown).0 if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }), - let href = try? URL.ParseStrategy().parse(url.href), - href.host == self.baseURL.host() { + let href = WebURL(url.href), + href.host == WebURL(self.baseURL)?.host { let nodeInfo = Request(method: .get, path: Endpoint(stringLiteral: href.path)) return try await run(nodeInfo).0 } else { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift index 1d7c61f13..b4735994d 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Announcement.swift @@ -6,6 +6,7 @@ // import Foundation +import WebURL public struct Announcement: Decodable, Sendable, Hashable, Identifiable { public let id: String @@ -59,7 +60,7 @@ extension Announcement { public struct Account: Decodable, Sendable, Hashable { public let id: String public let username: String - @URLDecoder public var url: URL + public let url: WebURL public let acct: String } } @@ -67,7 +68,7 @@ extension Announcement { extension Announcement { public struct Status: Decodable, Sendable, Hashable { public let id: String - @URLDecoder public var url: URL + public let url: WebURL } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift index f60756a2f..f3dbd14e6 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift @@ -7,17 +7,18 @@ // import Foundation +import WebURL public struct Card: Codable, Sendable { - @URLDecoder public var url: URL + public let url: WebURL public let title: String public let description: String - @OptionalURLDecoder public var image: URL? + public let image: WebURL? public let kind: Kind public let authorName: String? - @OptionalURLDecoder public var authorURL: URL? + public let authorURL: WebURL? public let providerName: String? - @OptionalURLDecoder public var providerURL: URL? + public let providerURL: WebURL? public let html: String? public let width: Int? public let height: Int? @@ -26,15 +27,15 @@ public struct Card: Codable, Sendable { public let history: [History]? public init( - url: URL, + url: WebURL, title: String, description: String, - image: URL? = nil, + image: WebURL? = nil, kind: Card.Kind, authorName: String? = nil, - authorURL: URL? = nil, + authorURL: WebURL? = nil, providerName: String? = nil, - providerURL: URL? = nil, + providerURL: WebURL? = nil, html: String? = nil, width: Int? = nil, height: Int? = nil, @@ -60,15 +61,15 @@ public struct Card: Codable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self._url = try container.decode(URLDecoder.self, forKey: .url) + self.url = try container.decode(WebURL.self, forKey: .url) self.title = try container.decode(String.self, forKey: .title) self.description = try container.decode(String.self, forKey: .description) self.kind = try container.decode(Kind.self, forKey: .kind) - self._image = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .image) ?? nil + self.image = try? container.decodeIfPresent(WebURL.self, forKey: .image) self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName) - self._authorURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .authorURL) ?? nil + self.authorURL = try? container.decodeIfPresent(WebURL.self, forKey: .authorURL) self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName) - self._providerURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .providerURL) ?? nil + self.providerURL = try? container.decodeIfPresent(WebURL.self, forKey: .providerURL) self.html = try? container.decodeIfPresent(String.self, forKey: .html) self.width = try? container.decodeIfPresent(Int.self, forKey: .width) self.height = try? container.decodeIfPresent(Int.self, forKey: .height) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift index 1d08e99c9..8a11f139d 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Emoji.swift @@ -7,11 +7,14 @@ // import Foundation +import WebURL public struct Emoji: Codable, Sendable { public let shortcode: String - @URLDecoder public var url: URL - @URLDecoder public var staticURL: URL + // these shouldn't need to be WebURLs as they're not external resources, + // but some instances (pleroma?) has emoji urls that Foundation considers malformed so we use WebURL to be more lenient + public let url: WebURL + public let staticURL: WebURL public let visibleInPicker: Bool public let category: String? @@ -19,8 +22,13 @@ public struct Emoji: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) self.shortcode = try container.decode(String.self, forKey: .shortcode) - self._url = try container.decode(URLDecoder.self, forKey: .url) - self._staticURL = try container.decode(URLDecoder.self, forKey: .staticURL) + do { + self.url = try container.decode(WebURL.self, forKey: .url) + } catch { + let s = try? container.decode(String.self, forKey: .url) + throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "")'", underlyingError: error)) + } + self.staticURL = try container.decode(WebURL.self, forKey: .staticURL) self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker) self.category = try container.decodeIfPresent(String.self, forKey: .category) } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift index 3d86209af..85b2c8775 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Hashtag.swift @@ -7,10 +7,12 @@ // import Foundation +import WebURL +import WebURLFoundationExtras public struct Hashtag: Codable, Sendable { public let name: String - @URLDecoder public var url: URL + public let url: WebURL /// Only present when returned from the trending hashtags endpoint public let history: [History]? /// Only present on Mastodon >= 4 and when logged in @@ -18,7 +20,7 @@ public struct Hashtag: Codable, Sendable { public init(name: String, url: URL) { self.name = name - self.url = url + self.url = WebURL(url)! self.history = nil self.following = nil } @@ -27,7 +29,7 @@ public struct Hashtag: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decode(String.self, forKey: .name) // pixelfed (possibly others) don't fully escape special characters in the hashtag url - self._url = try container.decode(URLDecoder.self, forKey: .url) + self.url = try container.decode(WebURL.self, forKey: .url) self.history = try container.decodeIfPresent([History].self, forKey: .history) self.following = try container.decodeIfPresent(Bool.self, forKey: .following) } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift index d48e13c73..f92d3c462 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Mention.swift @@ -7,9 +7,10 @@ // import Foundation +import WebURL public struct Mention: Codable, Sendable { - @URLDecoder public var url: URL + public let url: WebURL public let username: String public let acct: String /// The instance-local ID of the user being mentioned. @@ -20,10 +21,15 @@ public struct Mention: Codable, Sendable { self.username = try container.decode(String.self, forKey: .username) self.acct = try container.decode(String.self, forKey: .acct) self.id = try container.decode(String.self, forKey: .id) - self._url = try container.decode(URLDecoder.self, forKey: .url) + do { + self.url = try container.decode(WebURL.self, forKey: .url) + } catch { + let s = try? container.decode(String.self, forKey: .url) + throw DecodingError.dataCorruptedError(forKey: .url, in: container, debugDescription: "Could not decode URL '\(s ?? "")'") + } } - public init(url: URL, username: String, acct: String, id: String) { + public init(url: WebURL, username: String, acct: String, id: String) { self.url = url self.username = username self.acct = acct diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift index 303e37bb4..2053b4a45 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Notification.swift @@ -7,6 +7,7 @@ // import Foundation +import WebURL public struct Notification: Decodable, Sendable { public let id: String @@ -17,7 +18,7 @@ public struct Notification: Decodable, Sendable { // Only present for pleroma emoji reactions // Either an emoji or :shortcode: (for akkoma custom emoji reactions) public let emoji: String? - @OptionalURLDecoder public var emojiURL: URL? + public let emojiURL: WebURL? public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -32,7 +33,7 @@ public struct Notification: Decodable, Sendable { self.account = try container.decode(Account.self, forKey: .account) self.status = try container.decodeIfPresent(Status.self, forKey: .status) self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji) - self._emojiURL = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .emojiURL) ?? nil + self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL) } public static func dismiss(id notificationID: String) -> Request { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/PushNotification.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/PushNotification.swift index a8456e044..178cab03a 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/PushNotification.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/PushNotification.swift @@ -6,13 +6,14 @@ // import Foundation +import WebURL public struct PushNotification: Decodable { public let accessToken: String public let preferredLocale: String public let notificationID: String public let notificationType: Notification.Kind - @URLDecoder public var icon: URL + public let icon: WebURL public let title: String public let body: String @@ -28,7 +29,7 @@ public struct PushNotification: Decodable { self.notificationID = i.description } self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType) - self._icon = try container.decode(URLDecoder.self, forKey: .icon) + self.icon = try container.decode(WebURL.self, forKey: .icon) self.title = try container.decode(String.self, forKey: .title) self.body = try container.decode(String.self, forKey: .body) } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index 0f5f1b824..33e26e3df 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -7,6 +7,7 @@ // import Foundation +import WebURL public final class Status: StatusProtocol, Decodable, Sendable { /// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts. @@ -14,8 +15,7 @@ public final class Status: StatusProtocol, Decodable, Sendable { public let id: String public let uri: String - private let _url: OptionalURLDecoder - public var url: URL? { _url.wrappedValue } + public let url: WebURL? public let account: Account public let inReplyToID: String? public let inReplyToAccountID: String? @@ -55,13 +55,13 @@ public final class Status: StatusProtocol, Decodable, Sendable { self.id = try container.decode(String.self, forKey: .id) self.uri = try container.decode(String.self, forKey: .uri) do { - self._url = try container.decodeIfPresent(OptionalURLDecoder.self, forKey: .url) ?? nil + self.url = try container.decodeIfPresent(WebURL.self, forKey: .url) } catch { let s = try? container.decode(String.self, forKey: .url) if s == "" { - self._url = OptionalURLDecoder(wrappedValue: nil) + self.url = nil } else { - throw error + throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "")'", underlyingError: error)) } } self.account = try container.decode(Account.self, forKey: .account) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift index 38a4d34e2..47a9e5b15 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift @@ -7,6 +7,7 @@ // import Foundation +import WebURL public struct NotificationGroup: Identifiable, Hashable, Sendable { public private(set) var notifications: [Notification] @@ -149,7 +150,7 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable { case poll case update case status - case emojiReaction(String, URL?) + case emojiReaction(String, WebURL?) case unknown var notificationKind: Notification.Kind { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift b/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift deleted file mode 100644 index fff687768..000000000 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/URLDecoder.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// URLDecoder.swift -// Pachyderm -// -// Created by Shadowfacts on 12/15/24. -// -import Foundation - -private let parseStrategy = URL.ParseStrategy() - .scheme(.required) - .user(.optional) - .password(.optional) - .host(.required) - .port(.optional) - .path(.optional) - .query(.optional) - .fragment(.optional) - -private let formatStyle = URL.FormatStyle() - .scheme(.always) - .user(.omitWhen(.user, matches: [""])) - .password(.omitWhen(.password, matches: [""])) - .host(.always) - .port(.omitIfHTTPFamily) - .path(.always) - .query(.omitWhen(.query, matches: [""])) - .fragment(.omitWhen(.fragment, matches: [""])) - -@propertyWrapper -public struct URLDecoder: Codable, Sendable, Hashable { - public var wrappedValue: URL - - public init(wrappedValue: URL) { - self.wrappedValue = wrappedValue - } - - public init(from decoder: any Decoder) throws { - let s = try decoder.singleValueContainer().decode(String.self) - self.wrappedValue = try parseStrategy.parse(s) - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(wrappedValue.formatted(formatStyle)) - } -} - -@propertyWrapper -public struct OptionalURLDecoder: Codable, Sendable, Hashable, ExpressibleByNilLiteral { - public var wrappedValue: URL? - - public init(wrappedValue: URL?) { - self.wrappedValue = wrappedValue - } - - public init(nilLiteral: ()) { - self.wrappedValue = nil - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self.wrappedValue = nil - } else { - let s = try container.decode(String.self) - if s.isEmpty { - self.wrappedValue = nil - } else { - do { - self.wrappedValue = try parseStrategy.parse(s) - } catch { - throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Could not decode URL '\(s)'", underlyingError: error)) - } - } - } - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - if let wrappedValue { - try container.encode(wrappedValue.formatted(formatStyle)) - } else { - try container.encodeNil() - } - } -} diff --git a/Packages/Pachyderm/Tests/PachydermTests/URLTests.swift b/Packages/Pachyderm/Tests/PachydermTests/URLTests.swift index 328060dcb..7564c398b 100644 --- a/Packages/Pachyderm/Tests/PachydermTests/URLTests.swift +++ b/Packages/Pachyderm/Tests/PachydermTests/URLTests.swift @@ -6,21 +6,20 @@ // import XCTest -@testable import Pachyderm +import WebURL +import WebURLFoundationExtras class URLTests: XCTestCase { func testDecodeURL() { - XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é")) - XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭")) - } - - func testRoundtripURL() throws { - let orig = URLDecoder(wrappedValue: URL(string: "https://example.com")!) - let encoded = try JSONEncoder().encode(orig) - print(String(data: encoded, encoding: .utf8)!) - let decoded = try JSONDecoder().decode(URLDecoder.self, from: encoded) - XCTAssertEqual(orig.wrappedValue, decoded.wrappedValue) + XCTAssertNotNil(WebURL(URL(string: "https://xn--baw-joa.social/@unituebingen")!)) + XCTAssertNotNil(WebURL("https://xn--baw-joa.social/@unituebingen")) + XCTAssertNotNil(URLComponents(string: "https://xn--baw-joa.social/test/é")) + XCTAssertNotNil(WebURL("https://xn--baw-joa.social/test/é")) + if #available(iOS 16.0, *) { + XCTAssertNotNil(try? URL.ParseStrategy().parse("https://xn--baw-joa.social/test/é")) + XCTAssertNotNil(try? URL.ParseStrategy().parse("http://見.香港/热狗/🌭")) + } } } diff --git a/ShareExtension/ShareHostingController.swift b/ShareExtension/ShareHostingController.swift index 4f42c5f69..dff8f3d71 100644 --- a/ShareExtension/ShareHostingController.swift +++ b/ShareExtension/ShareHostingController.swift @@ -9,6 +9,7 @@ import SwiftUI import ComposeUI import TuskerComponents +import WebURLFoundationExtras import Combine import TuskerPreferences @@ -45,7 +46,7 @@ class ShareHostingController: UIHostingController { currentAccountContainerView: { AnyView(SwitchAccountContainerView(content: $0, mastodonContextPublisher: mastodonContextPublisher)) }, replyContentView: { _, _ in fatalError("replies aren't allowed in share sheet") }, emojiImageView: { - AnyView(AsyncImage(url: $0.url) { + AnyView(AsyncImage(url: URL($0.url)!) { $0 .resizable() .aspectRatio(contentMode: .fit) diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 5208f2243..3e451d7c4 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -103,6 +103,7 @@ D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; }; D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; }; D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; }; + D630C4252BC7845800208903 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4242BC7845800208903 /* WebURL */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; @@ -163,6 +164,7 @@ D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D663626B21361C6700C9CBA2 /* Account+Preferences.swift */; }; D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */; }; + D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D6676CA427A8D0020052936B /* WebURLFoundationExtras */; }; D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */; }; D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */; }; D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D674A50827F9128D00BA03AC /* Pachyderm */; }; @@ -816,6 +818,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D630C4252BC7845800208903 /* WebURL in Frameworks */, D62220472C7EA8DF003E43B7 /* TuskerPreferences in Frameworks */, D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */, D630C3E52BC6313400208903 /* Pachyderm in Frameworks */, @@ -852,6 +855,7 @@ D6934F2C2BA7AD32002B1C8D /* GalleryVC in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */, D60BB3942B30076F00DAEA65 /* HTMLStreamer in Frameworks */, + D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */, ); @@ -1777,6 +1781,7 @@ D630C3E02BC61C6700208903 /* UserAccounts */, D630C3E42BC6313400208903 /* Pachyderm */, D630C4222BC7842C00208903 /* HTMLStreamer */, + D630C4242BC7845800208903 /* WebURL */, D62220462C7EA8DF003E43B7 /* TuskerPreferences */, ); productName = NotificationExtension; @@ -1828,6 +1833,7 @@ ); name = Tusker; packageProductDependencies = ( + D6676CA427A8D0020052936B /* WebURLFoundationExtras */, D674A50827F9128D00BA03AC /* Pachyderm */, D6552366289870790048A653 /* ScreenCorners */, D63CC701290EC0B8000E19DE /* Sentry */, @@ -1954,6 +1960,7 @@ ); mainGroup = D6D4DDC3212518A000E1C4BB; packageReferences = ( + D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */, D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */, D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */, D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */, @@ -3267,6 +3274,14 @@ minimumVersion = 1.0.1; }; }; + D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/karwa/swift-url"; + requirement = { + kind = exactVersion; + version = 0.4.2; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3304,6 +3319,11 @@ package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */; productName = HTMLStreamer; }; + D630C4242BC7845800208903 /* WebURL */ = { + isa = XCSwiftPackageProductDependency; + package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */; + productName = WebURL; + }; D635237029B78A7D009ED5E7 /* TuskerComponents */ = { isa = XCSwiftPackageProductDependency; productName = TuskerComponents; @@ -3322,6 +3342,11 @@ isa = XCSwiftPackageProductDependency; productName = TTTKit; }; + D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = { + isa = XCSwiftPackageProductDependency; + package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */; + productName = WebURLFoundationExtras; + }; D674A50827F9128D00BA03AC /* Pachyderm */ = { isa = XCSwiftPackageProductDependency; productName = Pachyderm; diff --git a/Tusker/CoreData/FollowedHashtag.swift b/Tusker/CoreData/FollowedHashtag.swift index b4fb4b812..22156e275 100644 --- a/Tusker/CoreData/FollowedHashtag.swift +++ b/Tusker/CoreData/FollowedHashtag.swift @@ -9,6 +9,7 @@ import Foundation import CoreData import Pachyderm +import WebURLFoundationExtras @objc(FollowedHashtag) public final class FollowedHashtag: NSManagedObject { @@ -32,6 +33,6 @@ extension FollowedHashtag { convenience init(hashtag: Hashtag, context: NSManagedObjectContext) { self.init(context: context) self.name = hashtag.name - self.url = hashtag.url + self.url = URL(hashtag.url)! } } diff --git a/Tusker/CoreData/SavedHashtag.swift b/Tusker/CoreData/SavedHashtag.swift index 446033cca..f206d4564 100644 --- a/Tusker/CoreData/SavedHashtag.swift +++ b/Tusker/CoreData/SavedHashtag.swift @@ -9,6 +9,7 @@ import Foundation import CoreData import Pachyderm +import WebURLFoundationExtras import UserAccounts @objc(SavedHashtag) @@ -41,6 +42,6 @@ extension SavedHashtag { self.init(context: context) self.accountID = account.id self.name = hashtag.name - self.url = hashtag.url + self.url = URL(hashtag.url)! } } diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index fd183e409..364480ccc 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -10,6 +10,7 @@ import Foundation import CoreData import Pachyderm +import WebURLFoundationExtras @objc(StatusMO) public final class StatusMO: NSManagedObject, StatusProtocol { @@ -135,7 +136,7 @@ extension StatusMO { self.sensitive = status.sensitive self.spoilerText = status.spoilerText self.uri = status.uri - self.url = status.url + self.url = status.url != nil ? URL(status.url!) : nil self.visibility = status.visibility self.poll = status.poll self.localOnly = status.localOnly ?? false diff --git a/Tusker/HTMLConverter.swift b/Tusker/HTMLConverter.swift index 68a995b1f..c1975d232 100644 --- a/Tusker/HTMLConverter.swift +++ b/Tusker/HTMLConverter.swift @@ -8,7 +8,8 @@ import UIKit import HTMLStreamer -import Pachyderm +import WebURL +import WebURLFoundationExtras class HTMLConverter { @@ -44,7 +45,17 @@ extension HTMLConverter { // note: this is duplicated in NotificationExtension struct Callbacks: HTMLConversionCallbacks { static func makeURL(string: String) -> URL? { - try? URL.ParseStrategy().parse(string) + // 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 let url = try? URL.ParseStrategy().parse(string) { + url + } else if let web = WebURL(string), + let url = URL(web) { + url + } else { + nil + } } static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { diff --git a/Tusker/Screens/Announcements/AnnouncementContentTextView.swift b/Tusker/Screens/Announcements/AnnouncementContentTextView.swift index 792b1ae19..cc9d15db4 100644 --- a/Tusker/Screens/Announcements/AnnouncementContentTextView.swift +++ b/Tusker/Screens/Announcements/AnnouncementContentTextView.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import WebURL class AnnouncementContentTextView: ContentTextView { @@ -29,7 +30,7 @@ class AnnouncementContentTextView: ContentTextView { override func getMention(for url: URL, text: String) -> Mention? { announcement?.mentions.first { - $0.url == url + URL($0.url) == url }.map { Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id) } @@ -37,7 +38,7 @@ class AnnouncementContentTextView: ContentTextView { override func getHashtag(for url: URL, text: String) -> Hashtag? { announcement?.tags.first { - $0.url == url + URL($0.url) == url } } diff --git a/Tusker/Screens/Announcements/AnnouncementListRow.swift b/Tusker/Screens/Announcements/AnnouncementListRow.swift index e62a786a0..13371c20f 100644 --- a/Tusker/Screens/Announcements/AnnouncementListRow.swift +++ b/Tusker/Screens/Announcements/AnnouncementListRow.swift @@ -9,6 +9,7 @@ import SwiftUI import Pachyderm import TuskerComponents +import WebURLFoundationExtras struct AnnouncementListRow: View { @Binding var announcement: Announcement @@ -115,8 +116,8 @@ struct AnnouncementListRow: View { let url: URL? let staticURL: URL? if case .custom(let emoji) = reaction { - url = emoji.url - staticURL = emoji.staticURL + url = URL(emoji.url) + staticURL = URL(emoji.staticURL) } else { url = nil staticURL = nil diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 933d8c9b3..53c8b92bc 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -8,6 +8,8 @@ import UIKit import Pachyderm +import WebURL +import WebURLFoundationExtras private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}") private func isLikelyMastodonRemoteStatus(url: URL) -> Bool { @@ -227,10 +229,10 @@ class ConversationViewController: UIViewController { let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") { effectiveURL = location } else { - effectiveURL = url.formatted(.url.fragment(.never)) + effectiveURL = WebURL(url)!.serialized(excludingFragment: true) } } else { - effectiveURL = url.formatted(.url.fragment(.never)) + effectiveURL = WebURL(url)!.serialized(excludingFragment: true) } let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true) diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index dbf0e2be5..1534baa7a 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -10,6 +10,7 @@ import UIKit import Combine import Pachyderm import CoreData +import WebURLFoundationExtras class ExploreViewController: UIViewController, UICollectionViewDelegate, CollectionViewController { @@ -559,7 +560,10 @@ extension ExploreViewController: UICollectionViewDragDelegate { activity.displaysAuxiliaryScene = true provider = NSItemProvider(object: activity) case let .savedHashtag(hashtag): - provider = NSItemProvider(object: hashtag.url as NSURL) + guard let url = URL(hashtag.url) else { + return [] + } + provider = NSItemProvider(object: url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) diff --git a/Tusker/Screens/Explore/TrendingHashtagsViewController.swift b/Tusker/Screens/Explore/TrendingHashtagsViewController.swift index 49f039e25..28691fa4d 100644 --- a/Tusker/Screens/Explore/TrendingHashtagsViewController.swift +++ b/Tusker/Screens/Explore/TrendingHashtagsViewController.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import WebURLFoundationExtras import Combine class TrendingHashtagsViewController: UIViewController, CollectionViewController { @@ -276,10 +277,11 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate { extension TrendingHashtagsViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let item = dataSource.itemIdentifier(for: indexPath), - case let .tag(hashtag) = item else { + case let .tag(hashtag) = item, + let url = URL(hashtag.url) else { return [] } - let provider = NSItemProvider(object: hashtag.url as NSURL) + let provider = NSItemProvider(object: url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) diff --git a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift index dd06dba80..3d2c22d35 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift +++ b/Tusker/Screens/Explore/TrendingLinkCardCollectionViewCell.swift @@ -9,6 +9,7 @@ #if !os(visionOS) import UIKit import Pachyderm +import WebURLFoundationExtras import HTMLStreamer class TrendingLinkCardCollectionViewCell: UICollectionViewCell { @@ -70,7 +71,7 @@ class TrendingLinkCardCollectionViewCell: UICollectionViewCell { self.card = card self.thumbnailView.image = nil - thumbnailView.update(for: card.image, blurhash: card.blurhash) + thumbnailView.update(for: card.image.flatMap { URL($0) }, blurhash: card.blurhash) let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) titleLabel.text = title diff --git a/Tusker/Screens/Explore/TrendingLinkCardView.swift b/Tusker/Screens/Explore/TrendingLinkCardView.swift index 4ffeabd5d..906f21b1c 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardView.swift +++ b/Tusker/Screens/Explore/TrendingLinkCardView.swift @@ -9,6 +9,7 @@ #if os(visionOS) import SwiftUI import Pachyderm +import WebURLFoundationExtras import HTMLStreamer struct TrendingLinkCardView: View { diff --git a/Tusker/Screens/Explore/TrendingLinksViewController.swift b/Tusker/Screens/Explore/TrendingLinksViewController.swift index d390c41fb..af73cbdf5 100644 --- a/Tusker/Screens/Explore/TrendingLinksViewController.swift +++ b/Tusker/Screens/Explore/TrendingLinksViewController.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import WebURLFoundationExtras import SafariServices import Combine #if os(visionOS) @@ -292,19 +293,21 @@ extension TrendingLinksViewController: UICollectionViewDelegate { } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else { + guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), + let url = URL(card.url) else { return } - selected(url: card.url) + selected(url: url) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), + let url = URL(card.url), let cell = collectionView.cellForItem(at: indexPath) else { return nil } return UIContextMenuConfiguration { - let vc = SFSafariViewController(url: card.url) + let vc = SFSafariViewController(url: url) #if !os(visionOS) vc.preferredControlTintColor = Preferences.shared.accentColor.color #endif @@ -321,10 +324,11 @@ extension TrendingLinksViewController: UICollectionViewDelegate { extension TrendingLinksViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard case .link(let card) = dataSource.itemIdentifier(for: indexPath) else { + guard case .link(let card) = dataSource.itemIdentifier(for: indexPath), + let url = URL(card.url) else { return [] } - return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))] + return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] } } diff --git a/Tusker/Screens/Explore/TrendsViewController.swift b/Tusker/Screens/Explore/TrendsViewController.swift index 96223bc26..511c22b8f 100644 --- a/Tusker/Screens/Explore/TrendsViewController.swift +++ b/Tusker/Screens/Explore/TrendsViewController.swift @@ -513,7 +513,9 @@ extension TrendsViewController: UICollectionViewDelegate { show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) case let .link(card): - selected(url: card.url) + if let url = URL(card.url) { + selected(url: url) + } case let .status(id, state): selected(status: id, state: state.copy()) @@ -542,9 +544,12 @@ extension TrendsViewController: UICollectionViewDelegate { } case let .link(card): + guard let url = URL(card.url) else { + return nil + } let cell = collectionView.cellForItem(at: indexPath)! return UIContextMenuConfiguration { - let vc = SFSafariViewController(url: card.url) + let vc = SFSafariViewController(url: url) #if !os(visionOS) vc.preferredControlTintColor = Preferences.shared.accentColor.color #endif @@ -619,7 +624,10 @@ extension TrendsViewController: UICollectionViewDragDelegate { return [] case let .tag(hashtag): - let provider = NSItemProvider(object: hashtag.url as NSURL) + guard let url = URL(hashtag.url) else { + return [] + } + let provider = NSItemProvider(object: url as NSURL) if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) @@ -627,7 +635,10 @@ extension TrendsViewController: UICollectionViewDragDelegate { return [UIDragItem(itemProvider: provider)] case let .link(card): - return [UIDragItem(itemProvider: NSItemProvider(object: card.url as NSURL))] + guard let url = URL(card.url) else { + return [] + } + return [UIDragItem(itemProvider: NSItemProvider(object: url as NSURL))] case let .status(id, _): guard let status = mastodonController.persistentContainer.status(for: id), diff --git a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift index e43787478..a4de09d00 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 { @@ -130,7 +131,11 @@ class FastSwitchingAccountView: UIView { private func setupAccount(account: UserAccountInfo) { usernameLabel.text = account.username - instanceLabel.text = account.instanceURL.host(percentEncoded: false) + if let domain = WebURL.Domain(account.instanceURL.host!) { + instanceLabel.text = domain.render(.uncheckedUnicodeString) + } else { + instanceLabel.text = account.instanceURL.host! + } let controller = MastodonController.getForAccount(account) avatarTask = Task { guard let account = try? await controller.getOwnAccount(), diff --git a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift index 9c4e7bf43..1494c4fa1 100644 --- a/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/ActionNotificationGroupCollectionViewCell.swift @@ -155,7 +155,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell { fetchCustomEmojiImage?.1.cancel() case .emojiReaction(let emojiOrShortcode, let url): iconImageView.image = nil - if let url, + if let url = url.flatMap({ URL($0) }), fetchCustomEmojiImage?.0 != url { fetchCustomEmojiImage?.1.cancel() let task = Task { diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index 9643aa14c..acd5b01a9 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -740,7 +740,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate { return cell.dragItemsForBeginning(session: session) case .poll, .update: let status = group.notifications.first!.status! - let provider = NSItemProvider(object: status.url! as NSURL) + let provider = NSItemProvider(object: URL(status.url!)! as NSURL) let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) diff --git a/Tusker/Screens/Preferences/Appearance/MockStatusView.swift b/Tusker/Screens/Preferences/Appearance/MockStatusView.swift index 30fcc5abf..5cbd46e25 100644 --- a/Tusker/Screens/Preferences/Appearance/MockStatusView.swift +++ b/Tusker/Screens/Preferences/Appearance/MockStatusView.swift @@ -8,6 +8,7 @@ import SwiftUI import Pachyderm +import WebURL struct MockStatusView: View { @ObservedObject private var preferences = Preferences.shared @@ -135,8 +136,8 @@ private struct MockStatusCardView: UIViewRepresentable { let view = StatusCardView() view.isUserInteractionEnabled = false let card = StatusCardView.CardData( - url: URL(string: "https://vaccor.space/tusker")!, - image: URL(string: "https://vaccor.space/tusker/img/icon.png")!, + url: WebURL("https://vaccor.space/tusker")!, + image: WebURL("https://vaccor.space/tusker/img/icon.png")!, title: "Tusker", description: "Tusker is an iOS app for Mastodon" ) diff --git a/Tusker/Screens/Preferences/PrefsAccountView.swift b/Tusker/Screens/Preferences/PrefsAccountView.swift index 595ef095c..8eda85ff2 100644 --- a/Tusker/Screens/Preferences/PrefsAccountView.swift +++ b/Tusker/Screens/Preferences/PrefsAccountView.swift @@ -8,6 +8,7 @@ import SwiftUI import UserAccounts +import WebURL struct PrefsAccountView: View { let account: UserAccountInfo @@ -18,7 +19,12 @@ struct PrefsAccountView: View { VStack(alignment: .prefsAvatar) { Text(verbatim: account.username) .foregroundColor(.primary) - Text(verbatim: account.instanceURL.host(percentEncoded: false)!) + 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/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index c0f5224de..714769d85 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -9,6 +9,7 @@ import UIKit import Combine import Pachyderm +import WebURLFoundationExtras fileprivate let accountCell = "accountCell" fileprivate let statusCell = "statusCell" @@ -537,7 +538,7 @@ extension SearchResultsViewController: UICollectionViewDragDelegate { url = account.url activity = UserActivityManager.showProfileActivity(id: id, accountID: accountInfo.id) case .hashtag(let tag): - url = tag.url + url = URL(tag.url)! activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: tag.name), accountID: accountInfo.id)! case .status(let id, _): guard let status = mastodonController.persistentContainer.status(for: id), diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift index afeb92901..b2ea25a3f 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import WebURL class StatusActionAccountListViewController: UIViewController { @@ -182,7 +183,7 @@ extension StatusActionAccountListViewController { enum ActionType { case favorite case reblog - case emojiReaction(String, URL?) + case emojiReaction(String, WebURL?) init?(_ groupKind: NotificationGroup.Kind) { switch groupKind { diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 927c4799c..5b9801cb1 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -9,6 +9,7 @@ import UIKit import SafariServices import Pachyderm +import WebURLFoundationExtras import SwiftUI @MainActor @@ -153,7 +154,12 @@ extension MenuActionProvider { } } - let shareSection = actionsForURL(hashtag.url, source: source) + let shareSection: [UIMenuElement] + if let url = URL(hashtag.url) { + shareSection = actionsForURL(url, source: source) + } else { + shareSection = [] + } return [ UIMenu(title: "", image: nil, identifier: nil, options: [.displayInline], children: shareSection), @@ -369,11 +375,14 @@ extension MenuActionProvider { } func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] { + guard let url = URL(card.url) else { + return [] + } return [ - openInSafariAction(url: card.url), + openInSafariAction(url: url), createAction(identifier: "share", title: "Share…", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in guard let self else { return } - self.navigationDelegate?.showMoreOptions(forURL: card.url, source: source) + self.navigationDelegate?.showMoreOptions(forURL: url, source: source) }), createAction(identifier: "postlink", title: "Post this Link", systemImageName: "square.and.pencil", handler: { [weak self] _ in guard let self = self else { return } @@ -384,7 +393,7 @@ extension MenuActionProvider { text += title text += ":\n" } - text += card.url.absoluteString + text += url.absoluteString let draft = self.mastodonController!.createDraft(text: text) self.navigationDelegate?.compose(editing: draft) diff --git a/Tusker/Views/AccountDisplayNameView.swift b/Tusker/Views/AccountDisplayNameView.swift index 7b58395e3..2cda0c0f7 100644 --- a/Tusker/Views/AccountDisplayNameView.swift +++ b/Tusker/Views/AccountDisplayNameView.swift @@ -8,6 +8,7 @@ import SwiftUI import Pachyderm +import WebURLFoundationExtras import os private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) @@ -53,7 +54,7 @@ struct AccountDisplayNameView: View { } group.enter() - let request = ImageCache.emojis.get(emoji.url) { (_, image) in + let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in defer { group.leave() } guard let image = image else { return } diff --git a/Tusker/Views/BaseEmojiLabel.swift b/Tusker/Views/BaseEmojiLabel.swift index 162679128..92b0d6e4b 100644 --- a/Tusker/Views/BaseEmojiLabel.swift +++ b/Tusker/Views/BaseEmojiLabel.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import WebURLFoundationExtras import os private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) @@ -72,7 +73,7 @@ extension BaseEmojiLabel { foundEmojis = true - if let image = ImageCache.emojis.get(emoji.url)?.image { + if let image = ImageCache.emojis.get(URL(emoji.url)!)?.image { // if the image is cached, add it immediately. // we generate the thumbnail on the main thread, because it's usually fast enough // and the delay caused by doing it asynchronously looks works. @@ -89,7 +90,7 @@ extension BaseEmojiLabel { // otherwise, perform the network request group.enter() - let request = ImageCache.emojis.getFromSource(emoji.url) { (_, image) in + let request = ImageCache.emojis.getFromSource(URL(emoji.url)!) { (_, image) in guard let image else { group.leave() return @@ -97,7 +98,7 @@ extension BaseEmojiLabel { image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in guard let thumbnail = thumbnail?.cgImage, case let rescaled = UIImage(cgImage: thumbnail, scale: screenScale, orientation: .up), - let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: rescaled) else { + let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: rescaled) else { group.leave() return } diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 41230698c..d2a2e5041 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -9,6 +9,8 @@ import UIKit import Pachyderm import SafariServices +import WebURL +import WebURLFoundationExtras import Combine private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) diff --git a/Tusker/Views/CustomEmojiImageView.swift b/Tusker/Views/CustomEmojiImageView.swift index ae784df93..9155696dc 100644 --- a/Tusker/Views/CustomEmojiImageView.swift +++ b/Tusker/Views/CustomEmojiImageView.swift @@ -8,6 +8,7 @@ import SwiftUI import Pachyderm +import WebURLFoundationExtras struct CustomEmojiImageView: View { let emoji: Emoji @@ -34,7 +35,7 @@ struct CustomEmojiImageView: View { @MainActor private func loadImage() { - request = ImageCache.emojis.get(emoji.url) { (_, image) in + request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in DispatchQueue.main.async { self.request = nil if let image = image { diff --git a/Tusker/Views/Status/StatusCardView.swift b/Tusker/Views/Status/StatusCardView.swift index 40f8ee44d..a8feb6bd7 100644 --- a/Tusker/Views/Status/StatusCardView.swift +++ b/Tusker/Views/Status/StatusCardView.swift @@ -9,6 +9,8 @@ import UIKit import Pachyderm import SafariServices +import WebURL +import WebURLFoundationExtras import HTMLStreamer class StatusCardView: UIView { @@ -182,14 +184,14 @@ class StatusCardView: UIView { if sensitive { if let blurhash = card.blurhash { imageView.blurImage = false - imageView.showOnlyBlurHash(blurhash, for: image) + imageView.showOnlyBlurHash(blurhash, for: URL(image)!) } else { // if we don't have a blurhash, load the image and show it behind a blur imageView.blurImage = true - imageView.update(for: image, blurhash: nil) + imageView.update(for: URL(image), blurhash: nil) } } else { - imageView.update(for: image, blurhash: card.blurhash) + imageView.update(for: URL(image), blurhash: card.blurhash) } imageView.isHidden = false leadingSpacer.isHidden = true @@ -208,8 +210,8 @@ class StatusCardView: UIView { descriptionLabel.text = description descriptionLabel.isHidden = description.isEmpty - if let host = card.url.host(percentEncoded: false) { - domainLabel.text = host + if let host = card.url.host { + domainLabel.text = host.serialized domainLabel.isHidden = false } else { domainLabel.isHidden = true @@ -236,7 +238,7 @@ class StatusCardView: UIView { setNeedsDisplay() if let card = card, let delegate = navigationDelegate { - delegate.selected(url: card.url) + delegate.selected(url: URL(card.url)!) } } @@ -246,8 +248,8 @@ class StatusCardView: UIView { } struct CardData: Equatable { - let url: URL - let image: URL? + let url: WebURL + let image: WebURL? let title: String let description: String let blurhash: String? @@ -260,7 +262,7 @@ class StatusCardView: UIView { self.blurhash = card.blurhash } - init(url: URL, image: URL? = nil, title: String, description: String, blurhash: String? = nil) { + init(url: WebURL, image: WebURL? = nil, title: String, description: String, blurhash: String? = nil) { self.url = url self.image = image self.title = title @@ -276,13 +278,13 @@ extension StatusCardView: UIContextMenuInteractionDelegate { guard let card = card else { return nil } return UIContextMenuConfiguration(identifier: nil) { - let vc = SFSafariViewController(url: card.url) + let vc = SFSafariViewController(url: URL(card.url)!) #if !os(visionOS) vc.preferredControlTintColor = Preferences.shared.accentColor.color #endif return vc } actionProvider: { (_) in - let actions = self.actionProvider?.actionsForURL(card.url, source: .view(self)) ?? [] + let actions = self.actionProvider?.actionsForURL(URL(card.url)!, source: .view(self)) ?? [] return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) } } diff --git a/Tusker/Views/StatusContentTextView.swift b/Tusker/Views/StatusContentTextView.swift index d7458c510..1fdaee45c 100644 --- a/Tusker/Views/StatusContentTextView.swift +++ b/Tusker/Views/StatusContentTextView.swift @@ -8,6 +8,7 @@ import UIKit import Pachyderm +import WebURLFoundationExtras class StatusContentTextView: ContentTextView { @@ -25,7 +26,7 @@ class StatusContentTextView: ContentTextView { let mastodonController = mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) { mention = status.mentions.first { (mention) in - url.host() == mention.url.host() && ( + url.host == mention.url.host!.serialized && ( text.dropFirst() == mention.username // Mastodon and Pleroma include @ in the text || text.dropFirst() == mention.acct // Misskey includes @ and uses the whole acct || text == mention.username // GNU Social does not include the @ in the text, so we don't need to drop it @@ -43,7 +44,7 @@ class StatusContentTextView: ContentTextView { let mastodonController = mastodonController, let status = mastodonController.persistentContainer.status(for: statusID) { hashtag = status.hashtags.first { (hashtag) in - hashtag.url == url + URL(hashtag.url) == url } } else { hashtag = nil From 7c43261f9ce292c0aa1b6a4c22f7af1d99c0f0d6 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 15 Dec 2024 20:46:05 -0500 Subject: [PATCH 15/18] Revert "Raise min deployment target to iOS 16" This reverts commit f4b51c06c1107cd8149a8e07a0f652ac6816ee3a. --- .../NotificationService.swift | 5 +- Packages/ComposeUI/Package.swift | 2 +- .../Controllers/AttachmentRowController.swift | 15 ++- .../AttachmentsListController.swift | 38 +++++++ .../Controllers/ComposeController.swift | 35 +++++- .../FocusedAttachmentController.swift | 5 +- .../Controllers/PollController.swift | 2 +- .../Controllers/ToolbarController.swift | 5 +- .../Sources/ComposeUI/KeyboardReader.swift | 1 + .../ComposeUI/View+ForwardsCompat.swift | 26 +++++ Packages/Duckable/Package.swift | 2 +- Packages/GalleryVC/Package.swift | 2 +- .../Content/VideoControlsViewController.swift | 38 ++++++- .../VideoGalleryContentViewController.swift | 13 +++ .../Content/VideoOverlayViewController.swift | 15 +++ Packages/InstanceFeatures/Package.swift | 2 +- .../MatchedGeometryPresentation/Package.swift | 2 +- Packages/Pachyderm/Package.swift | 2 +- Packages/PushNotifications/Package.swift | 2 +- Packages/TTTKit/Package.swift | 2 +- Packages/TuskerComponents/Package.swift | 2 +- .../TuskerComponents/AsyncPicker.swift | 25 ++++- .../TuskerComponents/AsyncToggle.swift | 25 ++++- .../Sources/TuskerComponents/MenuPicker.swift | 4 +- Packages/TuskerPreferences/Package.swift | 2 +- Packages/UserAccounts/Package.swift | 2 +- Tusker.xcodeproj/project.pbxproj | 30 ++--- Tusker/Caching/DiskCache.swift | 19 +--- Tusker/CoreData/TimelinePosition.swift | 11 +- Tusker/Extensions/View+AppListStyle.swift | 15 ++- Tusker/HTMLConverter.swift | 5 +- Tusker/MultiThreadDictionary.swift | 104 ++++++++++++++++++ Tusker/Scenes/MainSceneDelegate.swift | 3 +- .../Announcements/AddReactionView.swift | 13 ++- .../Announcements/AnnouncementListRow.swift | 18 ++- .../AddHashtagPinnedTimelineView.swift | 17 +++ .../CustomizeTimelinesView.swift | 11 +- .../Customize Timelines/EditFilterView.swift | 14 ++- .../PinnedTimelinesView.swift | 10 ++ .../Explore/InlineTrendsViewController.swift | 4 +- .../Explore/TrendsViewController.swift | 17 ++- .../EditListAccountsViewController.swift | 29 +++-- Tusker/Screens/Mute/MuteAccountView.swift | 11 +- ...lowRequestNotificationViewController.swift | 8 +- ...otificationsCollectionViewController.swift | 8 +- .../InstanceSelectorTableViewController.swift | 4 +- .../Screens/Preferences/About/AboutView.swift | 10 +- .../Appearance/AppearancePrefsView.swift | 9 +- .../NotificationsPrefsView.swift | 7 +- .../PushInstanceSettingsView.swift | 2 +- .../OppositeCollapseKeywordsView.swift | 12 ++ .../Screens/Report/ReportAddStatusView.swift | 48 ++++---- .../Report/ReportSelectRulesView.swift | 18 ++- Tusker/Screens/Report/ReportView.swift | 17 ++- .../Search/MastodonSearchController.swift | 12 +- .../EnhancedNavigationViewController.swift | 30 +++-- Tusker/Screens/Utilities/Previewing.swift | 21 +++- .../UserActivityHandlingContext.swift | 6 +- Tusker/TuskerNavigationDelegate.swift | 3 +- Tusker/Views/AccountDisplayNameView.swift | 9 +- Tusker/Views/Attachments/AttachmentView.swift | 12 +- Tusker/Views/BaseEmojiLabel.swift | 15 +-- Tusker/Views/ContentTextView.swift | 6 +- ...opyableLabel.swift => CopyableLable.swift} | 14 ++- .../Profile Header/ProfileHeaderView.xib | 19 ++-- ...ersationMainStatusCollectionViewCell.swift | 5 +- 66 files changed, 717 insertions(+), 183 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift create mode 100644 Tusker/MultiThreadDictionary.swift rename Tusker/Views/{CopyableLabel.swift => CopyableLable.swift} (73%) diff --git a/NotificationExtension/NotificationService.swift b/NotificationExtension/NotificationService.swift index a2754ee4b..6270a3358 100644 --- a/NotificationExtension/NotificationService.swift +++ b/NotificationExtension/NotificationService.swift @@ -371,13 +371,14 @@ private struct HTMLCallbacks: HTMLConversionCallbacks { // 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 let url = try? URL.ParseStrategy().parse(string) { + 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 { - nil + URL(string: string) } } diff --git a/Packages/ComposeUI/Package.swift b/Packages/ComposeUI/Package.swift index 434658b20..752a018ca 100644 --- a/Packages/ComposeUI/Package.swift +++ b/Packages/ComposeUI/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "ComposeUI", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift index c729b26e4..ff11ef043 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentRowController.swift @@ -156,7 +156,7 @@ class AttachmentRowController: ViewController { Button(role: .destructive, action: controller.removeAttachment) { Label("Delete", systemImage: "trash") } - } preview: { + } previewIfAvailable: { ControllerView(controller: { controller.thumbnailController }) } @@ -221,3 +221,16 @@ extension AttachmentRowController { case allowEntry, recognizingText } } + +private extension View { + @available(iOS, obsoleted: 16.0) + @available(visionOS 1.0, *) + @ViewBuilder + func contextMenu(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View { + if #available(iOS 16.0, *) { + self.contextMenu(menuItems: menuItems, preview: preview) + } else { + self.contextMenu(menuItems: menuItems) + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift index 98af19262..56bae119e 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/AttachmentsListController.swift @@ -214,6 +214,44 @@ fileprivate extension View { self } } + + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func sheetOrPopover(isPresented: Binding, @ViewBuilder content: @escaping () -> some View) -> some View { + if #available(iOS 16.0, *) { + self.modifier(SheetOrPopover(isPresented: isPresented, view: content)) + } else { + self.popover(isPresented: isPresented, content: content) + } + } + + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func withSheetDetentsIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } else { + self + } + } +} + +@available(iOS 16.0, *) +fileprivate struct SheetOrPopover: ViewModifier { + @Binding var isPresented: Bool + @ViewBuilder let view: () -> V + + @Environment(\.horizontalSizeClass) var sizeClass + + func body(content: Content) -> some View { + if sizeClass == .compact { + content.sheet(isPresented: $isPresented, content: view) + } else { + content.popover(isPresented: $isPresented, content: view) + } + } } @available(visionOS 1.0, *) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index d63cd7303..da478b5bb 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -125,7 +125,9 @@ public final class ComposeController: ViewController { self.toolbarController = ToolbarController(parent: self) self.attachmentsListController = AttachmentsListController(parent: self) - NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil) + if #available(iOS 16.0, *) { + NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil) + } NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext) } @@ -322,6 +324,10 @@ public final class ComposeController: ViewController { ControllerView(controller: { controller.toolbarController }) #endif } + #if !os(visionOS) + // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this + .padding(.bottom, keyboardInset) + #endif .transition(.move(edge: .bottom)) } } @@ -430,7 +436,7 @@ public final class ComposeController: ViewController { } .listStyle(.plain) #if !os(visionOS) - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboardInteractivelyIfAvailable() #endif .disabled(controller.isPosting) } @@ -481,6 +487,31 @@ public final class ComposeController: ViewController { .keyboardShortcut(.return, modifiers: .command) .disabled(!controller.postButtonEnabled) } + + #if !os(visionOS) + @available(iOS, obsoleted: 16.0) + private var keyboardInset: CGFloat { + if #unavailable(iOS 16.0), + UIDevice.current.userInterfaceIdiom == .pad, + keyboardReader.isVisible { + return ToolbarController.height + } else { + return 0 + } + } + #endif + } +} + +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.scrollDismissesKeyboard(.interactively) + } else { + self + } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift index 9369df647..436724d65 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/FocusedAttachmentController.swift @@ -51,11 +51,14 @@ class FocusedAttachmentController: ViewController { .onAppear { player.play() } - } else { + } else if #available(iOS 16.0, *) { ZoomableScrollView { attachmentView .matchedGeometryDestination(id: attachment.id) } + } else { + attachmentView + .matchedGeometryDestination(id: attachment.id) } Spacer(minLength: 0) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift index 18f6b1015..7a1e2cb9e 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/PollController.swift @@ -96,7 +96,7 @@ class PollController: ViewController { .onMove(perform: controller.moveOptions) } .listStyle(.plain) - .scrollDisabled(true) + .scrollDisabledIfAvailable(true) .frame(height: 44 * CGFloat(poll.options.count)) Button(action: controller.addOption) { diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift index 5528c4a6e..bcef0cf0a 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ToolbarController.swift @@ -66,7 +66,7 @@ class ToolbarController: ViewController { } }) } - .scrollDisabled(realWidth ?? 0 <= minWidth ?? 0) + .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0) .frame(height: ToolbarController.height) .frame(maxWidth: .infinity) .background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing]) @@ -122,7 +122,8 @@ class ToolbarController: ViewController { Spacer() - if composeController.mastodonController.instanceFeatures.createStatusWithLanguage { + if #available(iOS 16.0, *), + composeController.mastodonController.instanceFeatures.createStatusWithLanguage { LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection) } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift index 2c049b2aa..11aa3771f 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/KeyboardReader.swift @@ -10,6 +10,7 @@ import UIKit import Combine +@available(iOS, obsoleted: 16.0) class KeyboardReader: ObservableObject { // @Published var isVisible = false @Published var keyboardHeight: CGFloat = 0 diff --git a/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift b/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift new file mode 100644 index 000000000..7b1dc3c39 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/View+ForwardsCompat.swift @@ -0,0 +1,26 @@ +// +// View+ForwardsCompat.swift +// ComposeUI +// +// Created by Shadowfacts on 3/25/23. +// + +import SwiftUI + +extension View { + #if os(visionOS) + func scrollDisabledIfAvailable(_ disabled: Bool) -> some View { + self.scrollDisabled(disabled) + } + #else + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDisabledIfAvailable(_ disabled: Bool) -> some View { + if #available(iOS 16.0, *) { + self.scrollDisabled(disabled) + } else { + self + } + } + #endif +} diff --git a/Packages/Duckable/Package.swift b/Packages/Duckable/Package.swift index 830960d67..bdff53ed0 100644 --- a/Packages/Duckable/Package.swift +++ b/Packages/Duckable/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Duckable", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Packages/GalleryVC/Package.swift b/Packages/GalleryVC/Package.swift index 158fd4c8d..14fd97fff 100644 --- a/Packages/GalleryVC/Package.swift +++ b/Packages/GalleryVC/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "GalleryVC", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. diff --git a/Packages/GalleryVC/Sources/GalleryVC/Content/VideoControlsViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/Content/VideoControlsViewController.swift index f19f13601..6f5046f9d 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/Content/VideoControlsViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/Content/VideoControlsViewController.swift @@ -9,6 +9,15 @@ import UIKit import AVFoundation +@propertyWrapper +final class Box { + var wrappedValue: T + + init(wrappedValue: T) { + self.wrappedValue = wrappedValue + } +} + class VideoControlsViewController: UIViewController { private static let formatter: DateComponentsFormatter = { let f = DateComponentsFormatter() @@ -18,6 +27,9 @@ class VideoControlsViewController: UIViewController { }() private let player: AVPlayer + #if !os(visionOS) + @Box private var playbackSpeed: Float + #endif private lazy var muteButton: MuteButton = { let button = MuteButton() @@ -51,8 +63,13 @@ class VideoControlsViewController: UIViewController { private lazy var optionsButton = MenuButton { [unowned self] in let imageName: String + #if os(visionOS) + let playbackSpeed = player.defaultRate + #else + let playbackSpeed = self.playbackSpeed + #endif if #available(iOS 17.0, *) { - switch player.defaultRate { + switch playbackSpeed { case 0.5: imageName = "gauge.with.dots.needle.0percent" case 1: @@ -68,8 +85,12 @@ class VideoControlsViewController: UIViewController { imageName = "speedometer" } let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in - UIAction(title: speed.displayName, state: self.player.defaultRate == speed.rate ? .on : .off) { [unowned self] _ in + UIAction(title: speed.displayName, state: playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in + #if os(visionOS) self.player.defaultRate = speed.rate + #else + self.playbackSpeed = speed.rate + #endif if self.player.rate > 0 { self.player.rate = speed.rate } @@ -99,11 +120,20 @@ class VideoControlsViewController: UIViewController { private var scrubbingTargetTime: CMTime? private var isSeeking = false + #if os(visionOS) init(player: AVPlayer) { self.player = player super.init(nibName: nil, bundle: nil) } + #else + init(player: AVPlayer, playbackSpeed: Box) { + self.player = player + self._playbackSpeed = playbackSpeed + + super.init(nibName: nil, bundle: nil) + } + #endif required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -187,7 +217,11 @@ class VideoControlsViewController: UIViewController { @objc private func scrubbingEnded() { scrubbingChanged() if wasPlayingWhenScrubbingStarted { + #if os(visionOS) player.play() + #else + player.rate = playbackSpeed + #endif } } diff --git a/Packages/GalleryVC/Sources/GalleryVC/Content/VideoGalleryContentViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/Content/VideoGalleryContentViewController.swift index 8d5488ee6..a668bb39d 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/Content/VideoGalleryContentViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/Content/VideoGalleryContentViewController.swift @@ -16,6 +16,11 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi public private(set) var item: AVPlayerItem public let player: AVPlayer + #if !os(visionOS) + @available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate") + @Box private var playbackSpeed: Float = 1 + #endif + private var presentationSizeObservation: NSKeyValueObservation? private var statusObservation: NSKeyValueObservation? private var rateObservation: NSKeyValueObservation? @@ -165,12 +170,20 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi isShowingError ? .fade : .fromSourceViewWithoutSnapshot } + #if os(visionOS) private lazy var overlayVC = VideoOverlayViewController(player: player) + #else + private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed) + #endif public var contentOverlayAccessoryViewController: UIViewController? { overlayVC } + #if os(visionOS) public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player) + #else + public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed) + #endif public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) { if !isShowingError { diff --git a/Packages/GalleryVC/Sources/GalleryVC/Content/VideoOverlayViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/Content/VideoOverlayViewController.swift index 2877621d8..b86a9af31 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/Content/VideoOverlayViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/Content/VideoOverlayViewController.swift @@ -15,6 +15,9 @@ class VideoOverlayViewController: UIViewController { private static let pauseImage = UIImage(systemName: "pause.fill")! private let player: AVPlayer + #if !os(visionOS) + @Box private var playbackSpeed: Float + #endif private var dimmingView: UIView! private var controlsStack: UIStackView! @@ -23,10 +26,18 @@ class VideoOverlayViewController: UIViewController { private var rateObservation: NSKeyValueObservation? + #if os(visionOS) init(player: AVPlayer) { self.player = player super.init(nibName: nil, bundle: nil) } + #else + init(player: AVPlayer, playbackSpeed: Box) { + self.player = player + self._playbackSpeed = playbackSpeed + super.init(nibName: nil, bundle: nil) + } + #endif required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -98,7 +109,11 @@ class VideoOverlayViewController: UIViewController { if player.currentTime() >= player.currentItem!.duration { player.seek(to: .zero) } + #if os(visionOS) player.play() + #else + player.rate = playbackSpeed + #endif } } diff --git a/Packages/InstanceFeatures/Package.swift b/Packages/InstanceFeatures/Package.swift index 3da3f9a84..04f8493c5 100644 --- a/Packages/InstanceFeatures/Package.swift +++ b/Packages/InstanceFeatures/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "InstanceFeatures", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Packages/MatchedGeometryPresentation/Package.swift b/Packages/MatchedGeometryPresentation/Package.swift index a5d53636d..ca4be041d 100644 --- a/Packages/MatchedGeometryPresentation/Package.swift +++ b/Packages/MatchedGeometryPresentation/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "MatchedGeometryPresentation", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. diff --git a/Packages/Pachyderm/Package.swift b/Packages/Pachyderm/Package.swift index a1be7f1d9..aaa51975b 100644 --- a/Packages/Pachyderm/Package.swift +++ b/Packages/Pachyderm/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "Pachyderm", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Packages/PushNotifications/Package.swift b/Packages/PushNotifications/Package.swift index e4a2c5c92..4fbfe4fa3 100644 --- a/Packages/PushNotifications/Package.swift +++ b/Packages/PushNotifications/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "PushNotifications", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. diff --git a/Packages/TTTKit/Package.swift b/Packages/TTTKit/Package.swift index 64ab3e039..4b8f753ee 100644 --- a/Packages/TTTKit/Package.swift +++ b/Packages/TTTKit/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "TTTKit", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Packages/TuskerComponents/Package.swift b/Packages/TuskerComponents/Package.swift index 350c14613..077ead8b2 100644 --- a/Packages/TuskerComponents/Package.swift +++ b/Packages/TuskerComponents/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "TuskerComponents", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AsyncPicker.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AsyncPicker.swift index 5777b6949..d1b78a2ca 100644 --- a/Packages/TuskerComponents/Sources/TuskerComponents/AsyncPicker.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AsyncPicker.swift @@ -9,14 +9,21 @@ import SwiftUI public struct AsyncPicker: View { let titleKey: LocalizedStringKey + #if !os(visionOS) + @available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent") + let labelHidden: Bool + #endif let alignment: Alignment @Binding var value: V let onChange: (V) async -> Bool let content: Content @State private var isLoading = false - public init(_ titleKey: LocalizedStringKey, alignment: Alignment = .center, value: Binding, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) { + public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, alignment: Alignment = .center, value: Binding, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) { self.titleKey = titleKey + #if !os(visionOS) + self.labelHidden = labelHidden + #endif self.alignment = alignment self._value = value self.onChange = onChange @@ -24,9 +31,25 @@ public struct AsyncPicker: View { } public var body: some View { + #if os(visionOS) LabeledContent(titleKey) { picker } + #else + if #available(iOS 16.0, *) { + LabeledContent(titleKey) { + picker + } + } else if labelHidden { + picker + } else { + HStack { + Text(titleKey) + Spacer() + picker + } + } + #endif } private var picker: some View { diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/AsyncToggle.swift b/Packages/TuskerComponents/Sources/TuskerComponents/AsyncToggle.swift index 9352daf68..4e0b00ace 100644 --- a/Packages/TuskerComponents/Sources/TuskerComponents/AsyncToggle.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/AsyncToggle.swift @@ -10,19 +10,42 @@ import SwiftUI public struct AsyncToggle: View { let titleKey: LocalizedStringKey + #if !os(visionOS) + @available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent") + let labelHidden: Bool + #endif @Binding var mode: Mode let onChange: (Bool) async -> Bool - public init(_ titleKey: LocalizedStringKey, mode: Binding, onChange: @escaping (Bool) async -> Bool) { + public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding, onChange: @escaping (Bool) async -> Bool) { self.titleKey = titleKey + #if !os(visionOS) + self.labelHidden = labelHidden + #endif self._mode = mode self.onChange = onChange } public var body: some View { + #if os(visionOS) LabeledContent(titleKey) { toggleOrSpinner } + #else + if #available(iOS 16.0, *) { + LabeledContent(titleKey) { + toggleOrSpinner + } + } else if labelHidden { + toggleOrSpinner + } else { + HStack { + Text(titleKey) + Spacer() + toggleOrSpinner + } + } + #endif } @ViewBuilder diff --git a/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift index 80bc60723..08f67c1ed 100644 --- a/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift +++ b/Packages/TuskerComponents/Sources/TuskerComponents/MenuPicker.swift @@ -47,7 +47,9 @@ public struct MenuPicker: UIViewRepresentable { private func makeConfiguration() -> UIButton.Configuration { var config = UIButton.Configuration.borderless() - config.indicator = .popup + if #available(iOS 16.0, *) { + config.indicator = .popup + } if buttonStyle.hasIcon { config.image = selectedOption.image } diff --git a/Packages/TuskerPreferences/Package.swift b/Packages/TuskerPreferences/Package.swift index 58aa2cf84..6624c3eca 100644 --- a/Packages/TuskerPreferences/Package.swift +++ b/Packages/TuskerPreferences/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "TuskerPreferences", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. diff --git a/Packages/UserAccounts/Package.swift b/Packages/UserAccounts/Package.swift index fc1b8c5b4..30fb63f2f 100644 --- a/Packages/UserAccounts/Package.swift +++ b/Packages/UserAccounts/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "UserAccounts", platforms: [ - .iOS(.v16), + .iOS(.v15), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 3e451d7c4..d25760d59 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -141,6 +141,7 @@ D64B96812BC3279D002C8990 /* PrefsAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96802BC3279D002C8990 /* PrefsAccountView.swift */; }; D64B96842BC3893C002C8990 /* PushSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; + D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; }; D651C5B42915B00400236EF6 /* ProfileFieldsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */; }; D6531DEE246B81C9000F9538 /* GifvPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvPlayerView.swift */; }; D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; }; @@ -332,7 +333,7 @@ D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; - D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLabel.swift */; }; + D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; D6DD8FFD298495A8002AD3FD /* LogoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFC298495A8002AD3FD /* LogoutService.swift */; }; D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */; }; D6DD996B2998611A0015C962 /* SuggestedProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */; }; @@ -567,6 +568,7 @@ D64B96802BC3279D002C8990 /* PrefsAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsAccountView.swift; sourceTree = ""; }; D64B96832BC3893C002C8990 /* PushSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionView.swift; sourceTree = ""; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; + D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = ""; }; D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldsView.swift; sourceTree = ""; }; D6531DED246B81C9000F9538 /* GifvPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvPlayerView.swift; sourceTree = ""; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = ""; }; @@ -768,7 +770,7 @@ D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = ""; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = ""; }; - D6D9498E298EB79400C59229 /* CopyableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLabel.swift; sourceTree = ""; }; + D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = ""; }; D6DD8FFC298495A8002AD3FD /* LogoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutService.swift; sourceTree = ""; }; D6DD8FFE2984D327002AD3FD /* BookmarksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewController.swift; sourceTree = ""; }; D6DD996A2998611A0015C962 /* SuggestedProfilesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfilesViewController.swift; sourceTree = ""; }; @@ -1476,7 +1478,7 @@ D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */, D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */, D620483523D38075008A63EF /* ContentTextView.swift */, - D6D9498E298EB79400C59229 /* CopyableLabel.swift */, + D6D9498E298EB79400C59229 /* CopyableLable.swift */, D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */, D6969E9F240C8384002843CE /* EmojiLabel.swift */, D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */, @@ -1613,6 +1615,7 @@ D6DEBA8C2B6579830008629A /* MainThreadBox.swift */, D6B81F432560390300F6E31D /* MenuController.swift */, D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */, + D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */, D6895DE828D962C2006341DA /* TimelineLikeController.swift */, @@ -2287,7 +2290,7 @@ D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */, D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */, D65B4B5A29720AB000DABDFB /* ReportStatusView.swift in Sources */, - D6D9498F298EB79400C59229 /* CopyableLabel.swift in Sources */, + D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, @@ -2371,6 +2374,7 @@ D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, + D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */, D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */, D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */, D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, @@ -2527,7 +2531,6 @@ INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2560,7 +2563,6 @@ INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2592,7 +2594,6 @@ INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2683,7 +2684,6 @@ CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2727,7 +2727,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TuskerUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2750,7 +2750,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; INFOPLIST_FILE = OpenInTusker/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2778,7 +2777,6 @@ INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2807,7 +2805,6 @@ INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2836,7 +2833,6 @@ INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2992,7 +2988,6 @@ CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3025,7 +3020,6 @@ CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3090,7 +3084,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TuskerUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3110,7 +3104,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; INFOPLIST_FILE = TuskerUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3133,7 +3127,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; INFOPLIST_FILE = OpenInTusker/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3158,7 +3151,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; INFOPLIST_FILE = OpenInTusker/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Tusker/Caching/DiskCache.swift b/Tusker/Caching/DiskCache.swift index d13b68b4e..aaf8b86f0 100644 --- a/Tusker/Caching/DiskCache.swift +++ b/Tusker/Caching/DiskCache.swift @@ -8,7 +8,6 @@ import Foundation import CryptoKit -import os struct DiskCacheTransformer { let toData: (T) throws -> Data @@ -22,7 +21,7 @@ class DiskCache { let defaultExpiry: CacheExpiry let transformer: DiskCacheTransformer - private var fileStates = OSAllocatedUnfairLock(initialState: [String: FileState]()) + private var fileStates = MultiThreadDictionary() init(name: String, defaultExpiry: CacheExpiry, transformer: DiskCacheTransformer, fileManager: FileManager = .default) throws { self.defaultExpiry = defaultExpiry @@ -60,9 +59,7 @@ class DiskCache { } private func fileState(forKey key: String) -> FileState { - return fileStates.withLock { - $0[key] ?? .unknown - } + return fileStates[key] ?? .unknown } func setObject(_ object: T, forKey key: String) throws { @@ -71,17 +68,13 @@ class DiskCache { guard fileManager.createFile(atPath: path, contents: data, attributes: [.modificationDate: defaultExpiry.date]) else { throw Error.couldNotCreateFile } - fileStates.withLock { - $0[key] = .exists - } + fileStates[key] = .exists } func removeObject(forKey key: String) throws { let path = makeFilePath(for: key) try fileManager.removeItem(atPath: path) - fileStates.withLock { - $0[key] = .doesNotExist - } + fileStates[key] = .doesNotExist } func existsObject(forKey key: String) throws -> Bool { @@ -112,9 +105,7 @@ class DiskCache { } guard date.timeIntervalSinceNow >= 0 else { try fileManager.removeItem(atPath: path) - fileStates.withLock { - $0[key] = .doesNotExist - } + fileStates[key] = .doesNotExist throw Error.expired } diff --git a/Tusker/CoreData/TimelinePosition.swift b/Tusker/CoreData/TimelinePosition.swift index d340d6fcb..ac29440bc 100644 --- a/Tusker/CoreData/TimelinePosition.swift +++ b/Tusker/CoreData/TimelinePosition.swift @@ -76,10 +76,17 @@ func fromTimelineKind(_ kind: String) -> Timeline { } else if kind == "direct" { return .direct } else if kind.starts(with: "hashtag:") { - return .tag(hashtag: String(kind.trimmingPrefix("hashtag:"))) + return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind))) } else if kind.starts(with: "list:") { - return .list(id: String(kind.trimmingPrefix("list:"))) + return .list(id: String(trimmingPrefix("list:", of: kind))) } else { fatalError("invalid timeline kind \(kind)") } } + +// replace with Collection.trimmingPrefix +@available(iOS, obsoleted: 16.0) +@available(visionOS 1.0, *) +private func trimmingPrefix(_ prefix: String, of str: String) -> Substring { + return str[str.index(str.startIndex, offsetBy: prefix.count)...] +} diff --git a/Tusker/Extensions/View+AppListStyle.swift b/Tusker/Extensions/View+AppListStyle.swift index 2d0e79651..472bfc3c1 100644 --- a/Tusker/Extensions/View+AppListStyle.swift +++ b/Tusker/Extensions/View+AppListStyle.swift @@ -36,12 +36,19 @@ private struct AppGroupedListBackground: ViewModifier { } func body(content: Content) -> some View { - if colorScheme == .dark, !pureBlackDarkMode { - content - .scrollContentBackground(.hidden) - .background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all)) + if #available(iOS 16.0, *) { + if colorScheme == .dark, !pureBlackDarkMode { + content + .scrollContentBackground(.hidden) + .background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all)) + } else { + content + } } else { content + .onAppear { + UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground + } } } } diff --git a/Tusker/HTMLConverter.swift b/Tusker/HTMLConverter.swift index c1975d232..26dd6ccf8 100644 --- a/Tusker/HTMLConverter.swift +++ b/Tusker/HTMLConverter.swift @@ -48,13 +48,14 @@ extension HTMLConverter { // 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 let url = try? URL.ParseStrategy().parse(string) { + 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 { - nil + URL(string: string) } } diff --git a/Tusker/MultiThreadDictionary.swift b/Tusker/MultiThreadDictionary.swift new file mode 100644 index 000000000..8f015d777 --- /dev/null +++ b/Tusker/MultiThreadDictionary.swift @@ -0,0 +1,104 @@ +// +// MultiThreadDictionary.swift +// Tusker +// +// Created by Shadowfacts on 5/6/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import Foundation +import os + +// once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]> +// to make the lock semantics more clear +@available(iOS, obsoleted: 16.0) +@available(visionOS 1.0, *) +final class MultiThreadDictionary: @unchecked Sendable { + #if os(visionOS) + private let lock = OSAllocatedUnfairLock(initialState: [Key: Value]()) + #else + private let lock: any Lock<[Key: Value]> + #endif + + init() { + #if !os(visionOS) + if #available(iOS 16.0, *) { + self.lock = OSAllocatedUnfairLock(initialState: [:]) + } else { + self.lock = UnfairLock(initialState: [:]) + } + #endif + } + + subscript(key: Key) -> Value? { + get { + return lock.withLock { dict in + dict[key] + } + } + set(value) { + #if os(visionOS) + lock.withLock { dict in + dict[key] = value + } + #else + _ = lock.withLock { dict in + dict[key] = value + } + #endif + } + } + + /// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread. + func removeValue(forKey key: Key) -> Value? { + return lock.withLock { dict in + dict.removeValue(forKey: key) + } + } + + func contains(key: Key) -> Bool { + return lock.withLock { dict in + dict.keys.contains(key) + } + } + + // TODO: this should really be throws/rethrows but the stupid type-erased lock makes that not possible + func withLock(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable { + return try lock.withLock { dict in + return try body(&dict) + } + } +} + +#if !os(visionOS) +// TODO: replace this only with OSAllocatedUnfairLock +@available(iOS, obsoleted: 16.0) +fileprivate protocol Lock { + associatedtype State + func withLock(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R: Sendable +} + +@available(iOS 16.0, *) +extension OSAllocatedUnfairLock: Lock { +} + +// from http://www.russbishop.net/the-law +fileprivate class UnfairLock: Lock { + private var lock: UnsafeMutablePointer + private var state: State + init(initialState: State) { + self.state = initialState + self.lock = .allocate(capacity: 1) + self.lock.initialize(to: os_unfair_lock()) + } + deinit { + self.lock.deinitialize(count: 1) + self.lock.deallocate() + } + func withLock(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable { + os_unfair_lock_lock(lock) + defer { os_unfair_lock_unlock(lock) } + return try body(&state) + } +} +#endif diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index ffb6486c0..31e5b6678 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -274,7 +274,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate } else { mainVC = MainSplitViewController(mastodonController: mastodonController) } - if UIDevice.current.userInterfaceIdiom == .phone { + if UIDevice.current.userInterfaceIdiom == .phone, + #available(iOS 16.0, *) { // TODO: maybe the duckable container should be outside the account switching container return DuckableContainerViewController(child: mainVC) } else { diff --git a/Tusker/Screens/Announcements/AddReactionView.swift b/Tusker/Screens/Announcements/AddReactionView.swift index c5ca7269f..f365ef1be 100644 --- a/Tusker/Screens/Announcements/AddReactionView.swift +++ b/Tusker/Screens/Announcements/AddReactionView.swift @@ -86,7 +86,7 @@ struct AddReactionView: View { } } .navigationViewStyle(.stack) - .presentationDetents([.medium, .large]) + .mediumPresentationDetentIfAvailable() .alertWithData("Error Adding Reaction", data: $error, actions: { _ in Button("OK") {} }, message: { error in @@ -171,6 +171,17 @@ private struct AddReactionButton: View { } private extension View { + @available(iOS, obsoleted: 16.0) + @available(visionOS 1.0, *) + @ViewBuilder + func mediumPresentationDetentIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.presentationDetents([.medium, .large]) + } else { + self + } + } + @available(iOS, obsoleted: 17.1) @available(visionOS 1.0, *) @ViewBuilder diff --git a/Tusker/Screens/Announcements/AnnouncementListRow.swift b/Tusker/Screens/Announcements/AnnouncementListRow.swift index 13371c20f..412338779 100644 --- a/Tusker/Screens/Announcements/AnnouncementListRow.swift +++ b/Tusker/Screens/Announcements/AnnouncementListRow.swift @@ -20,10 +20,14 @@ struct AnnouncementListRow: View { @State private var isShowingAddReactionSheet = false var body: some View { - mostOfTheBody - .alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in - dimension[.leading] - }) + if #available(iOS 16.0, *) { + mostOfTheBody + .alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in + dimension[.leading] + }) + } else { + mostOfTheBody + } } private var mostOfTheBody: some View { @@ -50,7 +54,11 @@ struct AnnouncementListRow: View { Label { Text("Add Reaction") } icon: { - Image("face.smiling.badge.plus") + if #available(iOS 16.0, *) { + Image("face.smiling.badge.plus") + } else { + Image(systemName: "face.smiling") + } } } .labelStyle(.iconOnly) diff --git a/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift b/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift index 1c66346d4..30e4d53cd 100644 --- a/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift +++ b/Tusker/Screens/Customize Timelines/AddHashtagPinnedTimelineView.swift @@ -9,6 +9,20 @@ import SwiftUI import Pachyderm +@available(iOS, obsoleted: 16.0) +struct AddHashtagPinnedTimelineRepresentable: UIViewControllerRepresentable { + typealias UIViewControllerType = UIHostingController + + @Binding var pinnedTimelines: [PinnedTimeline] + + func makeUIViewController(context: Context) -> UIHostingController { + return UIHostingController(rootView: AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)) + } + + func updateUIViewController(_ uiViewController: UIHostingController, context: Context) { + } +} + struct AddHashtagPinnedTimelineView: View { @EnvironmentObject private var mastodonController: MastodonController @Environment(\.dismiss) private var dismiss @@ -35,6 +49,9 @@ struct AddHashtagPinnedTimelineView: View { var body: some View { NavigationView { list + #if !os(visionOS) + .appGroupedListBackground(container: AddHashtagPinnedTimelineRepresentable.UIViewControllerType.self) + #endif .listStyle(.grouped) .navigationTitle("Add Hashtag") .navigationBarTitleDisplayMode(.inline) diff --git a/Tusker/Screens/Customize Timelines/CustomizeTimelinesView.swift b/Tusker/Screens/Customize Timelines/CustomizeTimelinesView.swift index 8eabf79b5..e7c32a642 100644 --- a/Tusker/Screens/Customize Timelines/CustomizeTimelinesView.swift +++ b/Tusker/Screens/Customize Timelines/CustomizeTimelinesView.swift @@ -36,8 +36,15 @@ struct CustomizeTimelinesList: View { } var body: some View { - NavigationStack { - navigationBody + if #available(iOS 16.0, *) { + NavigationStack { + navigationBody + } + } else { + NavigationView { + navigationBody + } + .navigationViewStyle(.stack) } } diff --git a/Tusker/Screens/Customize Timelines/EditFilterView.swift b/Tusker/Screens/Customize Timelines/EditFilterView.swift index c6452dffb..acfa70f02 100644 --- a/Tusker/Screens/Customize Timelines/EditFilterView.swift +++ b/Tusker/Screens/Customize Timelines/EditFilterView.swift @@ -149,7 +149,7 @@ struct EditFilterView: View { } .appGroupedListBackground(container: UIHostingController.self) #if !os(visionOS) - .scrollDismissesKeyboard(.interactively) + .scrollDismissesKeyboardInteractivelyIfAvailable() #endif .navigationTitle(create ? "Add Filter" : "Edit Filter") .navigationBarTitleDisplayMode(.inline) @@ -226,6 +226,18 @@ private struct FilterContextToggleStyle: ToggleStyle { } } +private extension View { + @available(iOS, obsoleted: 16.0) + @ViewBuilder + func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self.scrollDismissesKeyboard(.interactively) + } else { + self + } + } +} + //struct EditFilterView_Previews: PreviewProvider { // static var previews: some View { // EditFilterView() diff --git a/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift b/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift index 381cd4f36..c355f285b 100644 --- a/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift +++ b/Tusker/Screens/Customize Timelines/PinnedTimelinesView.swift @@ -115,8 +115,18 @@ struct PinnedTimelinesModifier: ViewModifier { func body(content: Content) -> some View { content .sheet(isPresented: $isShowingAddHashtagSheet, content: { + #if os(visionOS) AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines) .edgesIgnoringSafeArea(.bottom) + #else + if #available(iOS 16.0, *) { + AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines) + .edgesIgnoringSafeArea(.bottom) + } else { + AddHashtagPinnedTimelineRepresentable(pinnedTimelines: $pinnedTimelines) + .edgesIgnoringSafeArea(.bottom) + } + #endif }) .sheet(isPresented: $isShowingAddInstanceSheet, content: { AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines) diff --git a/Tusker/Screens/Explore/InlineTrendsViewController.swift b/Tusker/Screens/Explore/InlineTrendsViewController.swift index 8a4cd6fad..74ab5e36b 100644 --- a/Tusker/Screens/Explore/InlineTrendsViewController.swift +++ b/Tusker/Screens/Explore/InlineTrendsViewController.swift @@ -41,7 +41,9 @@ class InlineTrendsViewController: UIViewController { navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false - navigationItem.preferredSearchBarPlacement = .stacked + if #available(iOS 16.0, *) { + navigationItem.preferredSearchBarPlacement = .stacked + } let trends = TrendsViewController(mastodonController: mastodonController) trends.view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Tusker/Screens/Explore/TrendsViewController.swift b/Tusker/Screens/Explore/TrendsViewController.swift index 511c22b8f..4b09e2e79 100644 --- a/Tusker/Screens/Explore/TrendsViewController.swift +++ b/Tusker/Screens/Explore/TrendsViewController.swift @@ -525,12 +525,12 @@ extension TrendsViewController: UICollectionViewDelegate { } } - func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { - guard indexPaths.count == 1, - let item = dataSource.itemIdentifier(for: indexPaths[0]) else { + @available(iOS, obsoleted: 16.0) + @available(visionOS 1.0, *) + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return nil } - let indexPath = indexPaths[0] switch item { case .loadingIndicator, .confirmLoadMoreStatuses(_): @@ -584,6 +584,15 @@ extension TrendsViewController: UICollectionViewDelegate { } } + // implementing the highlightPreviewForItemAt method seems to prevent the old, single IndexPath variant of this method from being called on iOS 16 + @available(iOS 16.0, visionOS 1.0, *) + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { + guard indexPaths.count == 1 else { + return nil + } + return self.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPaths[0], point: point) + } + func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } diff --git a/Tusker/Screens/Lists/EditListAccountsViewController.swift b/Tusker/Screens/Lists/EditListAccountsViewController.swift index 31b1e249d..b3a8ed0c6 100644 --- a/Tusker/Screens/Lists/EditListAccountsViewController.swift +++ b/Tusker/Screens/Lists/EditListAccountsViewController.swift @@ -100,13 +100,28 @@ class EditListAccountsViewController: UIViewController, CollectionViewController navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false - navigationItem.preferredSearchBarPlacement = .stacked - - navigationItem.renameDelegate = self - navigationItem.titleMenuProvider = { [unowned self] suggested in - var children = suggested - children.append(contentsOf: self.listSettingsMenuElements()) - return UIMenu(children: children) + if #available(iOS 16.0, *) { + navigationItem.preferredSearchBarPlacement = .stacked + + navigationItem.renameDelegate = self + navigationItem.titleMenuProvider = { [unowned self] suggested in + var children = suggested + children.append(contentsOf: self.listSettingsMenuElements()) + return UIMenu(children: children) + } + } else { + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [ + // uncached so that menu always reflects the current state of the list + UIDeferredMenuElement.uncached({ [unowned self] elementHandler in + var elements = self.listSettingsMenuElements() + elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in + RenameListService(list: self.list, mastodonController: self.mastodonController, present: { + self.present($0, animated: true) + }).run() + }), at: 0) + elementHandler(elements) + }) + ])) } } diff --git a/Tusker/Screens/Mute/MuteAccountView.swift b/Tusker/Screens/Mute/MuteAccountView.swift index 8f1168c13..5cdc72a4c 100644 --- a/Tusker/Screens/Mute/MuteAccountView.swift +++ b/Tusker/Screens/Mute/MuteAccountView.swift @@ -41,8 +41,15 @@ struct MuteAccountView: View { @State private var error: Error? var body: some View { - NavigationStack { - navigationViewContent + if #available(iOS 16.0, *) { + NavigationStack { + navigationViewContent + } + } else { + NavigationView { + navigationViewContent + } + .navigationViewStyle(.stack) } } diff --git a/Tusker/Screens/Notifications/FollowRequestNotificationViewController.swift b/Tusker/Screens/Notifications/FollowRequestNotificationViewController.swift index 3d53b7738..522947027 100644 --- a/Tusker/Screens/Notifications/FollowRequestNotificationViewController.swift +++ b/Tusker/Screens/Notifications/FollowRequestNotificationViewController.swift @@ -101,8 +101,14 @@ extension FollowRequestNotificationViewController: UICollectionViewDelegate { UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }), UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }), ] + let acceptRejectMenu: UIMenu + if #available(iOS 16.0, *) { + acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren) + } else { + acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren) + } return UIMenu(children: [ - UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren), + acceptRejectMenu, UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))), ]) } diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index acd5b01a9..eb042b724 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -700,8 +700,14 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate { UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }), UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }), ] + let acceptRejectMenu: UIMenu + if #available(iOS 16.0, *) { + acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren) + } else { + acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren) + } return UIMenu(children: [ - UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren), + acceptRejectMenu, UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))), ]) } diff --git a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift index 32d2d018d..17f34f94c 100644 --- a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift +++ b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift @@ -96,7 +96,9 @@ class InstanceSelectorTableViewController: UITableViewController { searchController.searchBar.placeholder = "Search or enter a URL" navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false - navigationItem.preferredSearchBarPlacement = .stacked + if #available(iOS 16.0, *) { + navigationItem.preferredSearchBarPlacement = .stacked + } definesPresentationContext = true urlHandler = urlCheckerSubject diff --git a/Tusker/Screens/Preferences/About/AboutView.swift b/Tusker/Screens/Preferences/About/AboutView.swift index c3d0dcc52..8f87b236b 100644 --- a/Tusker/Screens/Preferences/About/AboutView.swift +++ b/Tusker/Screens/Preferences/About/AboutView.swift @@ -91,10 +91,14 @@ struct AboutView: View { @ViewBuilder private var iconOrGame: some View { - FlipView { + if #available(iOS 16.0, *) { + FlipView { + appIcon + } back: { + TTTView() + } + } else { appIcon - } back: { - TTTView() } } diff --git a/Tusker/Screens/Preferences/Appearance/AppearancePrefsView.swift b/Tusker/Screens/Preferences/Appearance/AppearancePrefsView.swift index 88e2fd8a5..35e836189 100644 --- a/Tusker/Screens/Preferences/Appearance/AppearancePrefsView.swift +++ b/Tusker/Screens/Preferences/Appearance/AppearancePrefsView.swift @@ -27,7 +27,14 @@ struct AppearancePrefsView: View { private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in var image: UIImage? if let color = color.color { - image = UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal) + if #available(iOS 16.0, *) { + image = UIImage(systemName: "circle.fill")!.withTintColor(color, renderingMode: .alwaysTemplate).withRenderingMode(.alwaysOriginal) + } else { + image = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).image { context in + color.setFill() + context.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: 20, height: 20)) + } + } } return (color, image) } diff --git a/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift b/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift index 903395ce3..c8437e261 100644 --- a/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift +++ b/Tusker/Screens/Preferences/Notifications/NotificationsPrefsView.swift @@ -36,7 +36,12 @@ struct NotificationsPrefsView: View { if #available(iOS 15.4, *) { Section { Button { - if let url = URL(string: UIApplication.openNotificationSettingsURLString) { + let str = if #available(iOS 16.0, *) { + UIApplication.openNotificationSettingsURLString + } else { + UIApplicationOpenNotificationSettingsURLString + } + if let url = URL(string: str) { UIApplication.shared.open(url) } } label: { diff --git a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift index 9d6a7cb06..98c68d6d8 100644 --- a/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift +++ b/Tusker/Screens/Preferences/Notifications/PushInstanceSettingsView.swift @@ -34,7 +34,7 @@ struct PushInstanceSettingsView: View { HStack { PrefsAccountView(account: account) Spacer() - AsyncToggle("\(account.instanceURL.host!) notifications enabled", mode: $mode, onChange: updateNotificationsEnabled(enabled:)) + AsyncToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:)) .labelsHidden() } PushSubscriptionView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription) diff --git a/Tusker/Screens/Preferences/OppositeCollapseKeywordsView.swift b/Tusker/Screens/Preferences/OppositeCollapseKeywordsView.swift index 60a92547a..27e737461 100644 --- a/Tusker/Screens/Preferences/OppositeCollapseKeywordsView.swift +++ b/Tusker/Screens/Preferences/OppositeCollapseKeywordsView.swift @@ -43,9 +43,21 @@ struct OppositeCollapseKeywordsView: View { .listStyle(.grouped) .appGroupedListBackground(container: PreferencesNavigationController.self) } + #if !os(visionOS) + .onAppear(perform: updateAppearance) + #endif .navigationBarTitle(preferences.expandAllContentWarnings ? "Collapse Post CW Keywords" : "Expand Post CW Keywords") } + @available(iOS, obsoleted: 16.0) + private func updateAppearance() { + if #available(iOS 16.0, *) { + // no longer necessary + } else { + UIScrollView.appearance(whenContainedInInstancesOf: [PreferencesNavigationController.self]).keyboardDismissMode = .interactive + } + } + private func commitExisting(at index: Int) -> () -> Void { return { if keywords[index].value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/Tusker/Screens/Report/ReportAddStatusView.swift b/Tusker/Screens/Report/ReportAddStatusView.swift index a148828eb..e56cc76ba 100644 --- a/Tusker/Screens/Report/ReportAddStatusView.swift +++ b/Tusker/Screens/Report/ReportAddStatusView.swift @@ -69,30 +69,34 @@ private struct ScrollBackgroundModifier: ViewModifier { @Environment(\.colorScheme) private var colorScheme func body(content: Content) -> some View { - content - .scrollContentBackground(.hidden) - .background { - // otherwise the pureBlackDarkMode isn't propagated, for some reason? - // even though it is for ReportSelectRulesView?? - let traits: UITraitCollection = { - var t = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light) - #if os(visionOS) - t = t.modifyingTraits({ mutableTraits in - mutableTraits.pureBlackDarkMode = true - }) - #else - if #available(iOS 17.0, *) { + if #available(iOS 16.0, *) { + content + .scrollContentBackground(.hidden) + .background { + // otherwise the pureBlackDarkMode isn't propagated, for some reason? + // even though it is for ReportSelectRulesView?? + let traits: UITraitCollection = { + var t = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light) + #if os(visionOS) t = t.modifyingTraits({ mutableTraits in mutableTraits.pureBlackDarkMode = true }) - } else { - t.obsoletePureBlackDarkMode = true - } - #endif - return t - }() - Color(uiColor: .appGroupedBackground.resolvedColor(with: traits)) - .edgesIgnoringSafeArea(.all) - } + #else + if #available(iOS 17.0, *) { + t = t.modifyingTraits({ mutableTraits in + mutableTraits.pureBlackDarkMode = true + }) + } else { + t.obsoletePureBlackDarkMode = true + } + #endif + return t + }() + Color(uiColor: .appGroupedBackground.resolvedColor(with: traits)) + .edgesIgnoringSafeArea(.all) + } + } else { + content + } } } diff --git a/Tusker/Screens/Report/ReportSelectRulesView.swift b/Tusker/Screens/Report/ReportSelectRulesView.swift index 4d7d06267..39e6510a8 100644 --- a/Tusker/Screens/Report/ReportSelectRulesView.swift +++ b/Tusker/Screens/Report/ReportSelectRulesView.swift @@ -49,12 +49,26 @@ struct ReportSelectRulesView: View { } .appGroupedListRowBackground() } - .scrollContentBackground(.hidden) - .background(Color.appGroupedBackground) + .withAppBackgroundIfAvailable() .navigationTitle("Rules") } } +private extension View { + @available(iOS, obsoleted: 16.0) + @available(visionOS 1.0, *) + @ViewBuilder + func withAppBackgroundIfAvailable() -> some View { + if #available(iOS 16.0, *) { + self + .scrollContentBackground(.hidden) + .background(Color.appGroupedBackground) + } else { + self + } + } +} + //struct ReportSelectRulesView_Previews: PreviewProvider { // static var previews: some View { // ReportSelectRulesView() diff --git a/Tusker/Screens/Report/ReportView.swift b/Tusker/Screens/Report/ReportView.swift index ec6d3fb79..d51fae177 100644 --- a/Tusker/Screens/Report/ReportView.swift +++ b/Tusker/Screens/Report/ReportView.swift @@ -27,11 +27,18 @@ struct ReportView: View { } var body: some View { - NavigationStack { - navigationViewContent - #if !os(visionOS) - .scrollDismissesKeyboard(.interactively) - #endif + if #available(iOS 16.0, *) { + NavigationStack { + navigationViewContent + #if !os(visionOS) + .scrollDismissesKeyboard(.interactively) + #endif + } + } else { + NavigationView { + navigationViewContent + } + .navigationViewStyle(.stack) } } diff --git a/Tusker/Screens/Search/MastodonSearchController.swift b/Tusker/Screens/Search/MastodonSearchController.swift index 62efb009d..bc6276455 100644 --- a/Tusker/Screens/Search/MastodonSearchController.swift +++ b/Tusker/Screens/Search/MastodonSearchController.swift @@ -39,7 +39,9 @@ class MastodonSearchController: UISearchController { searchResultsUpdater = searchResultsController automaticallyShowsSearchResultsController = false showsSearchResultsController = true - scopeBarActivation = .onSearchActivation + if #available(iOS 16.0, *) { + scopeBarActivation = .onSearchActivation + } searchBar.autocapitalizationType = .none searchBar.delegate = self @@ -76,8 +78,12 @@ class MastodonSearchController: UISearchController { 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 Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) { - langSuggestions.append("language:\(identifier)") + if #available(iOS 16.0, *) { + if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) { + langSuggestions.append("language:\(identifier)") + } + } else if searchText != "en" { + langSuggestions.append("language:\(searchText)") } } suggestions.append((.language, langSuggestions)) diff --git a/Tusker/Screens/Utilities/EnhancedNavigationViewController.swift b/Tusker/Screens/Utilities/EnhancedNavigationViewController.swift index a2af712e3..ee74cb135 100644 --- a/Tusker/Screens/Utilities/EnhancedNavigationViewController.swift +++ b/Tusker/Screens/Utilities/EnhancedNavigationViewController.swift @@ -22,7 +22,8 @@ class EnhancedNavigationViewController: UINavigationController { override var viewControllers: [UIViewController] { didSet { poppedViewControllers = [] - if useBrowserStyleNavigation { + if #available(iOS 16.0, *), + useBrowserStyleNavigation { // TODO: this for loop might not be necessary for vc in viewControllers { configureNavItem(vc.navigationItem) @@ -39,7 +40,8 @@ class EnhancedNavigationViewController: UINavigationController { self.interactivePushTransition = InteractivePushTransition(navigationController: self) #endif - if useBrowserStyleNavigation, + if #available(iOS 16.0, *), + useBrowserStyleNavigation, let topViewController { configureNavItem(topViewController.navigationItem) updateTopNavItemState() @@ -50,7 +52,9 @@ class EnhancedNavigationViewController: UINavigationController { let popped = performAfterAnimating(block: { super.popViewController(animated: animated) }, after: { - self.updateTopNavItemState() + if #available(iOS 16.0, *) { + self.updateTopNavItemState() + } }, animated: animated) if let popped { poppedViewControllers.insert(popped, at: 0) @@ -62,7 +66,9 @@ class EnhancedNavigationViewController: UINavigationController { let popped = performAfterAnimating(block: { super.popToRootViewController(animated: animated) }, after: { - self.updateTopNavItemState() + if #available(iOS 16.0, *) { + self.updateTopNavItemState() + } }, animated: animated) if let popped { poppedViewControllers = popped @@ -74,7 +80,9 @@ class EnhancedNavigationViewController: UINavigationController { let popped = performAfterAnimating(block: { super.popToViewController(viewController, animated: animated) }, after: { - self.updateTopNavItemState() + if #available(iOS 16.0, *) { + self.updateTopNavItemState() + } }, animated: animated) if let popped { poppedViewControllers.insert(contentsOf: popped, at: 0) @@ -89,11 +97,15 @@ class EnhancedNavigationViewController: UINavigationController { self.poppedViewControllers = [] } - configureNavItem(viewController.navigationItem) + if #available(iOS 16.0, *) { + configureNavItem(viewController.navigationItem) + } super.pushViewController(viewController, animated: animated) - updateTopNavItemState() + if #available(iOS 16.0, *) { + updateTopNavItemState() + } } func pushPoppedViewController() { @@ -123,7 +135,9 @@ class EnhancedNavigationViewController: UINavigationController { pushViewController(target, animated: true) }, after: { self.viewControllers.insert(contentsOf: toInsert, at: self.viewControllers.count - 1) - self.updateTopNavItemState() + if #available(iOS 16.0, *) { + self.updateTopNavItemState() + } }, animated: true) } diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index 5b9801cb1..32d1cadd1 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -204,7 +204,8 @@ extension MenuActionProvider { }), ] - if includeStatusButtonActions { + if #available(iOS 16.0, *), + includeStatusButtonActions { let favorited = status.favourited // TODO: move this color into an asset catalog or something var favImage = UIImage(systemName: favorited ? "star.fill" : "star")! @@ -367,11 +368,19 @@ extension MenuActionProvider { addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID)) - let toggleableAndActions = toggleableSection + actionsSection - return [ - UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions), - UIMenu(options: .displayInline, children: shareSection), - ] + if #available(iOS 16.0, *) { + let toggleableAndActions = toggleableSection + actionsSection + return [ + UIMenu(options: .displayInline, preferredElementSize: toggleableAndActions.count == 1 ? .large : .medium, children: toggleableAndActions), + UIMenu(options: .displayInline, children: shareSection), + ] + } else { + return [ + UIMenu(options: .displayInline, children: shareSection), + UIMenu(options: .displayInline, children: toggleableSection), + UIMenu(options: .displayInline, children: actionsSection), + ] + } } func actionsForTrendingLink(card: Card, source: PopoverSource) -> [UIMenuElement] { diff --git a/Tusker/Shortcuts/UserActivityHandlingContext.swift b/Tusker/Shortcuts/UserActivityHandlingContext.swift index bfb627b38..5903bb8ef 100644 --- a/Tusker/Shortcuts/UserActivityHandlingContext.swift +++ b/Tusker/Shortcuts/UserActivityHandlingContext.swift @@ -108,7 +108,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext { } func compose(editing draft: Draft) { - if UIDevice.current.userInterfaceIdiom == .phone { + if #available(iOS 16.0, *), + UIDevice.current.userInterfaceIdiom == .phone { self.root.compose(editing: draft, animated: false, isDucked: true, completion: nil) } else { DispatchQueue.main.async { @@ -122,7 +123,8 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext { precondition(state > .initial) navigation.run() #if !os(visionOS) - if let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) { + if #available(iOS 16.0, *), + let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) { self.root.compose(editing: duckedDraft, animated: false, isDucked: true, completion: nil) } #endif diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 18d903e57..ca1e462d5 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -114,7 +114,8 @@ extension TuskerNavigationDelegate { #if os(visionOS) fatalError("unreachable") #else - if presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) { + if #available(iOS 16.0, *), + presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) { return } else { present(compose, animated: animated, completion: completion) diff --git a/Tusker/Views/AccountDisplayNameView.swift b/Tusker/Views/AccountDisplayNameView.swift index 2cda0c0f7..b089900ba 100644 --- a/Tusker/Views/AccountDisplayNameView.swift +++ b/Tusker/Views/AccountDisplayNameView.swift @@ -9,7 +9,6 @@ import SwiftUI import Pachyderm import WebURLFoundationExtras -import os private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) @@ -41,7 +40,7 @@ struct AccountDisplayNameView: View { guard !matches.isEmpty else { return } let emojiSize = self.emojiSize - let emojiImages = OSAllocatedUnfairLock(initialState: [String: Image]()) + let emojiImages = MultiThreadDictionary() let group = DispatchGroup() @@ -64,9 +63,7 @@ struct AccountDisplayNameView: View { image.draw(in: CGRect(origin: .zero, size: size)) } - emojiImages.withLock { - $0[emoji.shortcode] = Image(uiImage: resized) - } + emojiImages[emoji.shortcode] = Image(uiImage: resized) } if let request = request { emojiRequests.append(request) @@ -81,7 +78,7 @@ struct AccountDisplayNameView: View { // iterate backwards as to not alter the indices of earlier matches for match in matches.reversed() { let shortcode = (account.displayName as NSString).substring(with: match.range(at: 1)) - guard let image = emojiImages.withLock({ $0[shortcode] }) else { continue } + guard let image = emojiImages[shortcode] else { continue } let afterCurrentMatch = (account.displayName as NSString).substring(with: NSRange(location: match.range.upperBound, length: endIndex - match.range.upperBound)) diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index 0db1800dc..2c40283da 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -263,7 +263,17 @@ class AttachmentView: GIFImageView { let asset = AVURLAsset(url: attachment.url) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true - guard let image = try? await generator.image(at: .zero).image, + let image: CGImage? + #if os(visionOS) + image = try? await generator.image(at: .zero).image + #else + if #available(iOS 16.0, *) { + image = try? await generator.image(at: .zero).image + } else { + image = try? generator.copyCGImage(at: .zero, actualTime: nil) + } + #endif + guard let image, let prepared = await UIImage(cgImage: image).byPreparingForDisplay(), !Task.isCancelled else { return diff --git a/Tusker/Views/BaseEmojiLabel.swift b/Tusker/Views/BaseEmojiLabel.swift index 92b0d6e4b..e32aab9a4 100644 --- a/Tusker/Views/BaseEmojiLabel.swift +++ b/Tusker/Views/BaseEmojiLabel.swift @@ -9,7 +9,6 @@ import UIKit import Pachyderm import WebURLFoundationExtras -import os private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) @@ -57,7 +56,7 @@ extension BaseEmojiLabel { return imageSizeMatchingFontSize } - let emojiImages = OSAllocatedUnfairLock(initialState: [String: UIImage]()) + let emojiImages = MultiThreadDictionary() var foundEmojis = false let group = DispatchGroup() @@ -80,11 +79,9 @@ extension BaseEmojiLabel { // todo: consider caching these somewhere? the cache needs to take into account both the url and the font size, which may vary across labels, so can't just use ImageCache if let thumbnail = image.preparingThumbnail(of: emojiImageSize(image)), let cgImage = thumbnail.cgImage { - emojiImages.withLock { - // the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert - // see FB12187798 - $0[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up) - } + // the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert + // see FB12187798 + emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up) } } else { // otherwise, perform the network request @@ -102,9 +99,7 @@ extension BaseEmojiLabel { group.leave() return } - emojiImages.withLock { - $0[emoji.shortcode] = transformedImage - } + emojiImages[emoji.shortcode] = transformedImage group.leave() } } diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index d2a2e5041..f6fc07fc3 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -146,7 +146,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? { let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top) - if let textLayoutManager { + if #available(iOS 16.0, *), + let textLayoutManager { guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else { return nil } @@ -304,7 +305,8 @@ extension ContentTextView: UIContextMenuInteractionDelegate { // Determine the line rects that the link takes up in the coordinate space of this view. var rects = [CGRect]() - if let textLayoutManager, + if #available(iOS 16.0, *), + let textLayoutManager, let contentManager = textLayoutManager.textContentManager { // convert from NSRange to NSTextRange // i have no idea under what circumstances any of these calls could fail diff --git a/Tusker/Views/CopyableLabel.swift b/Tusker/Views/CopyableLable.swift similarity index 73% rename from Tusker/Views/CopyableLabel.swift rename to Tusker/Views/CopyableLable.swift index 4c1a2eb4b..23d070e15 100644 --- a/Tusker/Views/CopyableLabel.swift +++ b/Tusker/Views/CopyableLable.swift @@ -1,5 +1,5 @@ // -// CopyableLabel.swift +// CopyableLable.swift // Tusker // // Created by Shadowfacts on 2/4/23. @@ -8,7 +8,7 @@ import UIKit -class CopyableLabel: UILabel { +class CopyableLable: UILabel { private var _editMenuInteraction: Any! @available(iOS 16.0, *) @@ -28,10 +28,12 @@ class CopyableLabel: UILabel { } private func commonInit() { - editMenuInteraction = UIEditMenuInteraction(delegate: nil) - addInteraction(editMenuInteraction) - isUserInteractionEnabled = true - addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed))) + if #available(iOS 16.0, *) { + editMenuInteraction = UIEditMenuInteraction(delegate: nil) + addInteraction(editMenuInteraction) + isUserInteractionEnabled = true + addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(longPressed))) + } } override func copy(_ sender: Any?) { diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.xib b/Tusker/Views/Profile Header/ProfileHeaderView.xib index d0663427d..1a5f1cfbe 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.xib +++ b/Tusker/Views/Profile Header/ProfileHeaderView.xib @@ -1,8 +1,9 @@ - + - + + @@ -124,16 +125,16 @@ - + -