diff --git a/CHANGELOG-release.md b/CHANGELOG-release.md index e5a02a67..02ee3b9e 100644 --- a/CHANGELOG-release.md +++ b/CHANGELOG-release.md @@ -1,3 +1,13 @@ +## 2024.5 +Features/Improvements: +- Improve gallery animations + +Bugfixes: +- Handle right-to-left text in display names +- Fix crash during gifv playback +- iPadOS: Fix app becoming unresponsive when switching accounts +- iPadOS/macOS: Fix Cmd+R shortcuts not working + ## 2024.4 This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d06b058..944b1271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2024.5 (141) +Bugfixes: +- Fix gallery controls being positioned incorrectly during dismiss animation on certain devices +- Fix gallery controls being positioned incorrectly in landscape orientations + +## 2024.5 (139) +Bugfixes: +- Fix error decoding certain posts + +## 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/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index 88cf7845..91e3a856 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -75,11 +75,11 @@ public final class ComposeController: ViewController { var postButtonEnabled: Bool { draft.editedStatusID != nil || - (draft.hasContent - && charactersRemaining >= 0 - && !isPosting - && attachmentsListController.isValid - && isPollValid) + (draft.hasContent + && charactersRemaining >= 0 + && !isPosting + && attachmentsListController.isValid + && isPollValid) } private var isPollValid: Bool { @@ -419,9 +419,9 @@ public final class ComposeController: ViewController { becomeFirstResponder: $controller.contentWarningBecomeFirstResponder, focusNextView: $controller.mainComposeTextViewBecomeFirstResponder ) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(config.backgroundColor) + .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .listRowSeparator(.hidden) + .listRowBackground(config.backgroundColor) } MainTextView() diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryItemViewController.swift index 0b24222c..546b9413 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]? @@ -397,13 +398,27 @@ 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 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 @@ -412,7 +427,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 diff --git a/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift b/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift index 69131aa8..5683f790 100644 --- a/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift +++ b/Packages/GalleryVC/Sources/GalleryVC/GalleryViewController.swift @@ -149,6 +149,10 @@ extension GalleryViewController: GalleryItemViewControllerDelegate { isBeingPresented } + func isGalleryBeingDismissed() -> Bool { + isBeingDismissed + } + func addPresentationAnimationCompletion(_ block: @escaping () -> Void) { presentationAnimationCompletionHandlers.append(block) } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index 906cf2ee..6065e841 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 { diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/NodeInfo.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/NodeInfo.swift index 1c3ab39d..f839d835 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/NodeInfo.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/NodeInfo.swift @@ -11,9 +11,15 @@ import Foundation public struct NodeInfo: Decodable, Sendable, Equatable { public let version: String public let software: Software + public let metadata: Metadata public struct Software: Decodable, Sendable, Equatable { public let name: String public let version: String } + + public struct Metadata: Decodable, Sendable, Equatable { + public let nodeName: String + public let nodeDescription: String + } } diff --git a/Packages/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift b/Packages/Pachyderm/Tests/PachydermTests/NotificationGroupTests.swift index aa079875..0c83834f 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)!, ]) } diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 5b00a9d5..53c8b92b 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/Explore/TrendingLinkCardView.swift b/Tusker/Screens/Explore/TrendingLinkCardView.swift index 735f1257..33150227 100644 --- a/Tusker/Screens/Explore/TrendingLinkCardView.swift +++ b/Tusker/Screens/Explore/TrendingLinkCardView.swift @@ -16,15 +16,11 @@ struct TrendingLinkCardView: View { let card: Card private var imageURL: URL? { - if let image = card.image { - URL(image) - } else { - nil - } + card.image.flatMap { URL($0) } } private var descriptionText: String { - var converter = TextConverter(configuration: .init(insertNewlines: false)) + let converter = TextConverter(configuration: .init(insertNewlines: false)) return converter.convert(html: card.description) } diff --git a/Tusker/Screens/Main/BaseMainTabBarViewController.swift b/Tusker/Screens/Main/BaseMainTabBarViewController.swift index 3019d6a4..f3eb8313 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 687be122..e1c37957 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 d40cf709..500ebdc1 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/Onboarding/InstanceSelectorTableViewController.swift b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift index 17f34f94..df867120 100644 --- a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift +++ b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift @@ -75,13 +75,14 @@ class InstanceSelectorTableViewController: UITableViewController { dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in switch item { - case let .selected(_, instance): + case let .selected(_, info): let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell - cell.updateUI(instance: instance) + cell.updateUI(info: info) return cell case let .recommended(instance): let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell - cell.updateUI(instance: instance) + let info = Info(host: instance.domain, description: instance.description, thumbnail: instance.proxiedThumbnailURL, adult: instance.category == "adult") + cell.updateUI(info: info) return cell } }) @@ -164,22 +165,20 @@ class InstanceSelectorTableViewController: UITableViewController { return } - let client = Client(baseURL: url, session: .appDefault) - let request = Client.getInstanceV1() - client.run(request) { (response) in + checkSpecificInstance(url: url) { (info) in var snapshot = self.dataSource.snapshot() if snapshot.indexOfSection(.selected) != nil { snapshot.deleteSections([.selected]) } - if case let .success(instance, _) = response { + if let info { if snapshot.indexOfSection(.recommendedInstances) != nil { snapshot.insertSections([.selected], beforeSection: .recommendedInstances) } else { snapshot.appendSections([.selected]) } - snapshot.appendItems([.selected(url, instance)], toSection: .selected) + snapshot.appendItems([.selected(url, info)], toSection: .selected) DispatchQueue.main.async { self.dataSource.apply(snapshot) { @@ -194,6 +193,29 @@ class InstanceSelectorTableViewController: UITableViewController { } } + private func checkSpecificInstance(url: URL, completionHandler: @escaping (Info?) -> Void) { + let client = Client(baseURL: url, session: .appDefault) + let request = Client.getInstanceV1() + client.run(request) { response in + switch response { + case .success(let instance, _): + let host = url.host ?? URLComponents(string: instance.uri)?.host ?? instance.uri + let info = Info(host: host, description: instance.shortDescription ?? instance.description, thumbnail: instance.thumbnail, adult: false) + completionHandler(info) + case .failure(_): + Task { + do { + let nodeInfo = try await client.nodeInfo() + let info = Info(host: url.host!, description: nodeInfo.metadata.nodeDescription, thumbnail: nil, adult: false) + completionHandler(info) + } catch { + completionHandler(nil) + } + } + } + } + } + private func loadRecommendedInstances() { InstanceSelector.getInstances(category: nil) { (response) in DispatchQueue.main.async { @@ -312,13 +334,13 @@ extension InstanceSelectorTableViewController { case recommendedInstances } enum Item: Equatable, Hashable, Sendable { - case selected(URL, InstanceV1) + case selected(URL, Info) case recommended(InstanceSelector.Instance) static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { - case let (.selected(urlA, instanceA), .selected(urlB, instanceB)): - return urlA == urlB && instanceA.uri == instanceB.uri + case let (.selected(urlA, _), .selected(urlB, _)): + return urlA == urlB case let (.recommended(a), .recommended(b)): return a.domain == b.domain default: @@ -328,16 +350,21 @@ extension InstanceSelectorTableViewController { func hash(into hasher: inout Hasher) { switch self { - case let .selected(url, instance): + case let .selected(url, _): hasher.combine(0) hasher.combine(url) - hasher.combine(instance.uri) case let .recommended(instance): hasher.combine(1) hasher.combine(instance.domain) } } } + struct Info: Hashable { + let host: String + let description: String + let thumbnail: URL? + let adult: Bool + } } extension InstanceSelectorTableViewController: UISearchResultsUpdating { diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift index 70633e57..b6617dc1 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 7ebe5f9c..81123ba7 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() + } +} diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index 79724b35..2c40283d 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -412,7 +412,7 @@ class AttachmentView: GIFImageView { makeBadgeView(text: "ALT") } if badges.contains(.noAlt) { - makeBadgeView(text: "No ALT") + makeBadgeView(text: "NO ALT") } let first = stack.arrangedSubviews.first! diff --git a/Tusker/Views/Instance Cell/InstanceTableViewCell.swift b/Tusker/Views/Instance Cell/InstanceTableViewCell.swift index a89a9d03..5b277617 100644 --- a/Tusker/Views/Instance Cell/InstanceTableViewCell.swift +++ b/Tusker/Views/Instance Cell/InstanceTableViewCell.swift @@ -16,8 +16,7 @@ class InstanceTableViewCell: UITableViewCell { @IBOutlet weak var adultLabel: UILabel! @IBOutlet weak var descriptionTextView: ContentTextView! - var instance: InstanceV1? - var selectorInstance: InstanceSelector.Instance? + private var instance: InstanceSelectorTableViewController.Info? private var thumbnailTask: Task? @@ -44,25 +43,14 @@ class InstanceTableViewCell: UITableViewCell { backgroundConfiguration = .appListGroupedCell(for: state) } - func updateUI(instance: InstanceSelector.Instance) { - self.selectorInstance = instance - self.instance = nil - - domainLabel.text = instance.domain - adultLabel.isHidden = instance.category != "adult" - descriptionTextView.setBodyTextFromHTML(instance.description) - updateThumbnail(url: instance.proxiedThumbnailURL) - } - - func updateUI(instance: InstanceV1) { - self.instance = instance - self.selectorInstance = nil + func updateUI(info: InstanceSelectorTableViewController.Info) { + self.instance = info - domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri - adultLabel.isHidden = true - descriptionTextView.setBodyTextFromHTML(instance.shortDescription ?? instance.description) + domainLabel.text = info.host + adultLabel.isHidden = !info.adult + descriptionTextView.setBodyTextFromHTML(info.description) - if let thumbnail = instance.thumbnail { + if let thumbnail = info.thumbnail { updateThumbnail(url: thumbnail) } else { thumbnailImageView.image = nil @@ -85,7 +73,6 @@ class InstanceTableViewCell: UITableViewCell { thumbnailTask?.cancel() instance = nil - selectorInstance = nil } } diff --git a/Tusker/Views/Profile Header/ProfileFieldValueView.swift b/Tusker/Views/Profile Header/ProfileFieldValueView.swift index cfac804b..0d379b60 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) + 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 + } + + 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!) + } +} diff --git a/Version.xcconfig b/Version.xcconfig index 7ed3937e..18440179 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 = 141 CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev