From 676e603ffc9b9fb03e754edda4685ecee888e66e Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 10 Nov 2023 14:04:11 -0500 Subject: [PATCH 01/41] Fix crash when showing trending hashtag with less than two days of history --- .../Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tusker/Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift b/Tusker/Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift index 9bde0085..a0de3bf8 100644 --- a/Tusker/Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift +++ b/Tusker/Views/Hashtag Cell/TrendingHashtagCollectionViewCell.swift @@ -67,7 +67,8 @@ class TrendingHashtagCollectionViewCell: UICollectionViewCell { historyView.setHistory(hashtag.history) historyView.isHidden = hashtag.history == nil || hashtag.history!.count < 2 - if let history = hashtag.history { + if let history = hashtag.history, + history.count >= 2 { let sorted = history.sorted(by: { $0.day < $1.day }) let lastTwo = sorted[(sorted.count - 2)...] let accounts = lastTwo.map(\.accounts).reduce(0, +) From bc7500bde97cdbbe7e6df98700622e1e47468528 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 10 Nov 2023 14:08:11 -0500 Subject: [PATCH 02/41] Fix crash when uploading attachment without known MIME type or extension --- .../Sources/ComposeUI/API/PostService.swift | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index 6733c17d..c7de164d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -114,13 +114,9 @@ class PostService: ObservableObject { } catch let error as DraftAttachment.ExportError { throw Error.attachmentData(index: index, cause: error) } - do { - let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription) - attachments.append(uploaded.id) - currentStep += 1 - } catch let error as Client.Error { - throw Error.attachmentUpload(index: index, cause: error) - } + let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription) + attachments.append(uploaded.id) + currentStep += 1 } return attachments } @@ -138,10 +134,21 @@ class PostService: ObservableObject { } } - private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment { - let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)") + private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment { + guard let mimeType = utType.preferredMIMEType else { + throw Error.attachmentMissingMimeType(index: index, type: utType) + } + var filename = "file" + if let ext = utType.preferredFilenameExtension { + filename.append(".\(ext)") + } + let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename) let req = Client.upload(attachment: formAttachment, description: description) - return try await mastodonController.run(req).0 + do { + return try await mastodonController.run(req).0 + } catch let error as Client.Error { + throw Error.attachmentUpload(index: index, cause: error) + } } private func textForPosting() -> String { @@ -170,6 +177,7 @@ class PostService: ObservableObject { enum Error: Swift.Error, LocalizedError { case attachmentData(index: Int, cause: DraftAttachment.ExportError) + case attachmentMissingMimeType(index: Int, type: UTType) case attachmentUpload(index: Int, cause: Client.Error) case posting(Client.Error) @@ -177,6 +185,8 @@ class PostService: ObservableObject { switch self { case let .attachmentData(index: index, cause: cause): return "Attachment \(index + 1): \(cause.localizedDescription)" + case let .attachmentMissingMimeType(index: index, type: type): + return "Attachment \(index + 1): unknown MIME type for \(type.identifier)" case let .attachmentUpload(index: index, cause: cause): return "Attachment \(index + 1): \(cause.localizedDescription)" case let .posting(error): From b40d8152740918cc36e219431086a8c0d345149f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 10 Nov 2023 14:16:16 -0500 Subject: [PATCH 03/41] Ensure LazilyDecoding runs on the managed object context's thread Maybe fix the crash in KeyPath machinery? --- Tusker/LazilyDecoding.swift | 69 ++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/Tusker/LazilyDecoding.swift b/Tusker/LazilyDecoding.swift index 7d402eab..26fec37a 100644 --- a/Tusker/LazilyDecoding.swift +++ b/Tusker/LazilyDecoding.swift @@ -7,12 +7,13 @@ // import Foundation +import CoreData private let decoder = PropertyListDecoder() private let encoder = PropertyListEncoder() @propertyWrapper -public struct LazilyDecoding { +public struct LazilyDecoding { private let keyPath: ReferenceWritableKeyPath private let fallback: Value @@ -32,37 +33,41 @@ public struct LazilyDecoding { public static subscript(_enclosingInstance instance: Enclosing, wrapped wrappedKeyPath: ReferenceWritableKeyPath, storage storageKeyPath: ReferenceWritableKeyPath) -> Value { get { - var wrapper = instance[keyPath: storageKeyPath] - if let value = wrapper.value { - return value - } else { - guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback } - do { - let value = try decoder.decode(Box.self, from: data) - wrapper.value = value.value - wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in - var wrapper = instance[keyPath: storageKeyPath] - if wrapper.skipClearingOnNextUpdate { - wrapper.skipClearingOnNextUpdate = false - } else { - wrapper.removeCachedValue() - } + instance.performOnContext { + var wrapper = instance[keyPath: storageKeyPath] + if let value = wrapper.value { + return value + } else { + guard let data = instance[keyPath: wrapper.keyPath] else { return wrapper.fallback } + do { + let value = try decoder.decode(Box.self, from: data) + wrapper.value = value.value + wrapper.observation = instance.observe(wrapper.keyPath, changeHandler: { instance, _ in + var wrapper = instance[keyPath: storageKeyPath] + if wrapper.skipClearingOnNextUpdate { + wrapper.skipClearingOnNextUpdate = false + } else { + wrapper.removeCachedValue() + } + instance[keyPath: storageKeyPath] = wrapper + }) instance[keyPath: storageKeyPath] = wrapper - }) - instance[keyPath: storageKeyPath] = wrapper - return value.value - } catch { - return wrapper.fallback + return value.value + } catch { + return wrapper.fallback + } } } } set { - var wrapper = instance[keyPath: storageKeyPath] - wrapper.value = newValue - wrapper.skipClearingOnNextUpdate = true - instance[keyPath: storageKeyPath] = wrapper - let newData = try! encoder.encode(Box(value: newValue)) - instance[keyPath: wrapper.keyPath] = newData + instance.performOnContext { + var wrapper = instance[keyPath: storageKeyPath] + wrapper.value = newValue + wrapper.skipClearingOnNextUpdate = true + instance[keyPath: storageKeyPath] = wrapper + let newData = try! encoder.encode(Box(value: newValue)) + instance[keyPath: wrapper.keyPath] = newData + } } } @@ -73,6 +78,16 @@ public struct LazilyDecoding { } +extension NSManagedObject { + fileprivate func performOnContext(_ f: () -> V) -> V { + if let managedObjectContext { + managedObjectContext.performAndWait(f) + } else { + f() + } + } +} + extension LazilyDecoding { init(arrayFrom keyPath: ReferenceWritableKeyPath) where Value == [T] { self.init(from: keyPath, fallback: []) From de946be0082620774b884bbd108102c2106e277f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 10 Nov 2023 14:20:33 -0500 Subject: [PATCH 04/41] Fix crash if ContentTextView asked for context menu config w/o mastodon controller --- Tusker/Views/ContentTextView.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 75b93de9..c736f739 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -207,10 +207,12 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? { let text = (self.text as NSString).substring(with: range) - if let mention = getMention(for: url, text: text) { - return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!) - } else if let tag = getHashtag(for: url, text: text) { - return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!) + if let mention = getMention(for: url, text: text), + let mastodonController { + return ProfileViewController(accountID: mention.id, mastodonController: mastodonController) + } else if let tag = getHashtag(for: url, text: text), + let mastodonController { + return HashtagTimelineViewController(for: tag, mastodonController: mastodonController) } else if url.scheme == "https" || url.scheme == "http" { let vc = SFSafariViewController(url: url) vc.preferredControlTintColor = Preferences.shared.accentColor.color From 1c3631285076c7a7bfcb9bb19d4e7bf8f2d1bcbd Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 10 Nov 2023 14:35:36 -0500 Subject: [PATCH 05/41] Fix status deletions not being handled properly in logged-out views --- Tusker/API/DeleteStatusService.swift | 3 +-- Tusker/API/FetchStatusService.swift | 8 +------- .../Screens/Conversation/ConversationViewController.swift | 4 +--- .../Screens/Explore/TrendingStatusesViewController.swift | 4 +--- .../LocalPredicateStatusesViewController.swift | 4 +--- .../NotificationsCollectionViewController.swift | 4 +--- .../Screens/Profile/ProfileStatusesViewController.swift | 4 +--- Tusker/Screens/Search/SearchResultsViewController.swift | 4 +--- .../StatusActionAccountListViewController.swift | 4 +--- Tusker/Screens/Timeline/TimelineViewController.swift | 4 +--- 10 files changed, 10 insertions(+), 33 deletions(-) diff --git a/Tusker/API/DeleteStatusService.swift b/Tusker/API/DeleteStatusService.swift index 881b9b4d..8525a769 100644 --- a/Tusker/API/DeleteStatusService.swift +++ b/Tusker/API/DeleteStatusService.swift @@ -35,8 +35,7 @@ class DeleteStatusService { reblogIDs = reblogs.map(\.id) } - NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [ - "accountID": mastodonController.accountInfo!.id, + NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [ "statusIDs": [status.id] + reblogIDs, ]) } catch { diff --git a/Tusker/API/FetchStatusService.swift b/Tusker/API/FetchStatusService.swift index bf944b6d..07a1366d 100644 --- a/Tusker/API/FetchStatusService.swift +++ b/Tusker/API/FetchStatusService.swift @@ -36,11 +36,6 @@ class FetchStatusService { } private func handleStatusNotFound() { - // todo: what about when browsing on another instance? - guard let accountID = mastodonController.accountInfo?.id else { - return - } - var reblogIDs = [String]() if let cached = mastodonController.persistentContainer.status(for: statusID) { let reblogsReq = StatusMO.fetchRequest() @@ -50,8 +45,7 @@ class FetchStatusService { } } - NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [ - "accountID": accountID, + NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [ "statusIDs": [statusID] + reblogIDs ]) } diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index 3e3a5ddc..764c9f13 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -112,7 +112,7 @@ class ConversationViewController: UIViewController { appearance.configureWithDefaultBackground() navigationItem.scrollEdgeAppearance = appearance - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) } private func updateVisibilityBarButtonItem() { @@ -145,8 +145,6 @@ class ConversationViewController: UIViewController { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String], case .localID(let mainStatusID) = mode else { return diff --git a/Tusker/Screens/Explore/TrendingStatusesViewController.swift b/Tusker/Screens/Explore/TrendingStatusesViewController.swift index 447054ae..7a391927 100644 --- a/Tusker/Screens/Explore/TrendingStatusesViewController.swift +++ b/Tusker/Screens/Explore/TrendingStatusesViewController.swift @@ -102,7 +102,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController override func viewDidLoad() { super.viewDidLoad() - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) } override func viewWillAppear(_ animated: Bool) { @@ -146,8 +146,6 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift b/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift index a28b4059..92939bf9 100644 --- a/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift +++ b/Tusker/Screens/Local Predicate Statuses List/LocalPredicateStatusesViewController.swift @@ -107,7 +107,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)")) - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext) } @@ -205,8 +205,6 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index 02e2ebb2..69e3a9c2 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -121,7 +121,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle self.reapplyFilters(actionsChanged: actionsChanged) } - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -257,8 +257,6 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index cf85c9fa..3b0de882 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -148,7 +148,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie self.reapplyFilters(actionsChanged: actionsChanged) } - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -376,8 +376,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index e4165f08..d587b006 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -120,7 +120,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController { userActivity = UserActivityManager.searchActivity(query: nil, accountID: mastodonController.accountInfo!.id) - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -309,8 +309,6 @@ class SearchResultsViewController: UIViewController, CollectionViewController { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift index ea1e4523..d1e4a501 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -84,7 +84,7 @@ class StatusActionAccountListViewController: UIViewController { view.backgroundColor = .appBackground - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) } override func viewWillAppear(_ animated: Bool) { @@ -99,8 +99,6 @@ class StatusActionAccountListViewController: UIViewController { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index b822b49a..16d12fe7 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -162,7 +162,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } } .store(in: &cancellables) - NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) if userActivity != nil { userActivityNeedsUpdate @@ -943,8 +943,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, - let accountID = mastodonController.accountInfo?.id, - userInfo["accountID"] as? String == accountID, let statusIDs = userInfo["statusIDs"] as? [String] else { return } From 380f878d8193accdc294874213e318ca25c0dfbb Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 10 Nov 2023 14:42:48 -0500 Subject: [PATCH 06/41] Use server language preference for default search token suggestion --- Tusker/Screens/Search/MastodonSearchController.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Tusker/Screens/Search/MastodonSearchController.swift b/Tusker/Screens/Search/MastodonSearchController.swift index d227d0dc..d766647b 100644 --- a/Tusker/Screens/Search/MastodonSearchController.swift +++ b/Tusker/Screens/Search/MastodonSearchController.swift @@ -65,12 +65,13 @@ class MastodonSearchController: UISearchController { searchText.isEmpty || $0.contains(searchText) })) - // TODO: use default language from preferences var langSuggestions = [String]() - if searchText.isEmpty || "language:en".contains(searchText) { - langSuggestions.append("language:en") + let defaultLanguage = searchResultsController.mastodonController.accountPreferences.serverDefaultLanguage ?? "en" + let languageToken = "language:\(defaultLanguage)" + if searchText.isEmpty || languageToken.contains(searchText) { + langSuggestions.append(languageToken) } - if searchText != "en", + if searchText != defaultLanguage, let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) { let identifier = (searchText as NSString).substring(with: match.range(at: 1)) if #available(iOS 16.0, *) { From ca7fe74a90d2ec586dcabc60bed2aace664e45a9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 10 Nov 2023 14:48:48 -0500 Subject: [PATCH 07/41] Add accessibility description/action to status edit history entry --- .../StatusEditCollectionViewCell.swift | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift index 536928d1..bbaa1382 100644 --- a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift +++ b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift @@ -91,8 +91,76 @@ class StatusEditCollectionViewCell: UICollectionViewListCell { fatalError("init(coder:) has not been implemented") } - // todo: accessibility + // MARK: Accessibility + override var isAccessibilityElement: Bool { + get { true } + set {} + } + + override var accessibilityAttributedLabel: NSAttributedString? { + get { + var str: AttributedString = "" + if statusState.collapsed ?? false { + if !edit.spoilerText.isEmpty { + str += AttributedString(edit.spoilerText) + str += ", " + } + str += "collapsed" + } else { + str += AttributedString(contentContainer.contentTextView.attributedText) + + if edit.attachments.count > 0 { + let includeDescriptions: Bool + switch Preferences.shared.attachmentBlurMode { + case .useStatusSetting: + includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || edit.spoilerText.isEmpty + case .always: + includeDescriptions = true + case .never: + includeDescriptions = false + } + if includeDescriptions { + if edit.attachments.count == 1 { + let attachment = edit.attachments[0] + let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description" + str += AttributedString(", attachment: \(desc)") + } else { + for (index, attachment) in edit.attachments.enumerated() { + let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description" + str += AttributedString(", attachment \(index + 1): \(desc)") + } + } + } else { + str += AttributedString(", \(edit.attachments.count) attachment\(edit.attachments.count == 1 ? "" : "s")") + } + } + if edit.poll != nil { + str += ", poll" + } + } + return NSAttributedString(str) + } + set {} + } + + override var accessibilityHint: String? { + get { + if statusState.collapsed ?? false { + return "Double tap to expand the post." + } else { + return nil + } + } + set {} + } + + override func accessibilityActivate() -> Bool { + if statusState.collapsed ?? false { + collapseButtonPressed() + } + return true + } // MARK: Configure UI From 6d3ffd7dd3d5c85a967a3d5f3396cf75590d2ef1 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 18 Nov 2023 10:56:05 -0500 Subject: [PATCH 08/41] Style blockquote appropriately Closes #22 --- Tusker/HTMLConverter.swift | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Tusker/HTMLConverter.swift b/Tusker/HTMLConverter.swift index 22504752..8f747811 100644 --- a/Tusker/HTMLConverter.swift +++ b/Tusker/HTMLConverter.swift @@ -86,6 +86,12 @@ struct HTMLConverter { } } + lazy var currentFont = if attributed.length == 0 { + font + } else { + attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font + } + switch node.tagName() { case "br": // need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which @@ -102,20 +108,8 @@ struct HTMLConverter { case "p": attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: font])) case "em", "i": - let currentFont: UIFont - if attributed.length == 0 { - currentFont = font - } else { - currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font - } attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange) case "strong", "b": - let currentFont: UIFont - if attributed.length == 0 { - currentFont = font - } else { - currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font - } attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange) case "del": attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) @@ -124,6 +118,14 @@ struct HTMLConverter { case "pre": attributed.append(NSAttributedString(string: "\n\n")) attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange) + case "blockquote": + let paragraphStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle + paragraphStyle.headIndent = 32 + paragraphStyle.firstLineHeadIndent = 32 + attributed.addAttributes([ + .font: currentFont.withTraits(.traitItalic)!, + .paragraphStyle: paragraphStyle, + ], range: attributed.fullRange) default: break } From 4e98e569eb392eb0a5024147b8c10b5674906fa8 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 18 Nov 2023 11:00:19 -0500 Subject: [PATCH 09/41] Fix avatars in follow request notification not being rounded Closes #448 --- .../FollowRequestNotificationCollectionViewCell.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift b/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift index 1dc17197..09165ddf 100644 --- a/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift @@ -23,6 +23,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell { $0.contentMode = .scaleAspectFill $0.layer.masksToBounds = true $0.layer.cornerCurve = .continuous + $0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 NSLayoutConstraint.activate([ $0.widthAnchor.constraint(equalTo: $0.heightAnchor), ]) From cdfb06f4a7c16a2fe7a06d41507b7d001609cc7f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 18 Nov 2023 11:08:35 -0500 Subject: [PATCH 10/41] Render IDN domains in for logged-in accounts --- .../Fast Account Switcher/FastSwitchingAccountView.swift | 9 +++++++-- Tusker/Screens/Preferences/PreferencesView.swift | 8 +++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift index e800ecbc..61b5b47a 100644 --- a/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift +++ b/Tusker/Screens/Fast Account Switcher/FastSwitchingAccountView.swift @@ -8,6 +8,7 @@ import UIKit import UserAccounts +import WebURL class FastSwitchingAccountView: UIView { @@ -126,7 +127,11 @@ class FastSwitchingAccountView: UIView { private func setupAccount(account: UserAccountInfo) { usernameLabel.text = account.username - instanceLabel.text = account.instanceURL.host! + if let domain = WebURL.Domain(account.instanceURL.host!) { + instanceLabel.text = domain.render(.uncheckedUnicodeString) + } else { + instanceLabel.text = account.instanceURL.host! + } let controller = MastodonController.getForAccount(account) controller.getOwnAccount { [weak self] (result) in guard let self = self, @@ -140,7 +145,7 @@ class FastSwitchingAccountView: UIView { } } - accessibilityLabel = "\(account.username!)@\(account.instanceURL.host!)" + accessibilityLabel = "\(account.username!)@\(instanceLabel.text!)" } private func setupPlaceholder() { diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index 6ee2f61a..6739d89e 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -7,6 +7,7 @@ import SwiftUI import UserAccounts +import WebURL struct PreferencesView: View { let mastodonController: MastodonController @@ -41,7 +42,12 @@ struct PreferencesView: View { VStack(alignment: .leading) { Text(verbatim: account.username) .foregroundColor(.primary) - Text(verbatim: account.instanceURL.host!) + let instance = if let domain = WebURL.Domain(account.instanceURL.host!) { + domain.render(.uncheckedUnicodeString) + } else { + account.instanceURL.host! + } + Text(verbatim: instance) .font(.caption) .foregroundColor(.primary) } From 16f6dc84c9a64f4259ae57402cc0a1d369245d62 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 18 Nov 2023 11:15:47 -0500 Subject: [PATCH 11/41] Update Sentry package --- Tusker.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index b3e129fc..b85af33a 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -2969,7 +2969,7 @@ repositoryURL = "https://github.com/getsentry/sentry-cocoa.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 8.0.0; + minimumVersion = 8.15.0; }; }; D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = { From 13809b91d17ff2557003ae5f4957bc9948307403 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 18 Nov 2023 11:36:59 -0500 Subject: [PATCH 12/41] Fix crash if window removed while fast account switcher is hiding --- .../FastAccountSwitcherViewController.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift index 0104cfab..f8b81106 100644 --- a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift +++ b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift @@ -166,7 +166,9 @@ class FastAccountSwitcherViewController: UIViewController { selectionChangedFeedbackGenerator = nil hide() { - (self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount() + if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate { + sceneDelegate.showAddAccount() + } } } else { let account = UserAccountsManager.shared.accounts[newIndex - 1] @@ -178,7 +180,9 @@ class FastAccountSwitcherViewController: UIViewController { selectionChangedFeedbackGenerator = nil hide() { - (self.view.window!.windowScene!.delegate as! MainSceneDelegate).activateAccount(account, animated: true) + if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate { + sceneDelegate.activateAccount(account, animated: true) + } } } else { hide() From 6d7074e71d7e6863afd769e6bf924cf0de3542ee Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 19 Nov 2023 21:22:00 -0500 Subject: [PATCH 13/41] Tweak profile header separator --- .../ProfileStatusesViewController.swift | 12 +++++++-- .../Profile Header/ProfileHeaderView.xib | 27 +++++-------------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index 3b0de882..06054869 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -67,18 +67,25 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in - guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + guard let item = self.dataSource.itemIdentifier(for: indexPath), + let section = self.dataSource.sectionIdentifier(for: indexPath.section) else { return sectionSeparatorConfiguration } var config = sectionSeparatorConfiguration if item.hideSeparators { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden + } else if section == .header { + config.topSeparatorVisibility = .hidden + config.bottomSeparatorInsets = .zero + } else if indexPath.row == 0 && (section == .pinned || section == .entries) { + // TODO: row == 0 isn't technically right, the top post could be filtered out + config.topSeparatorInsets = .zero } else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item, filterer.isKnownHide(state: filterState) { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden - } else if case .status(_, _, _, _) = item { + } else { config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } @@ -88,6 +95,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie if case .header = dataSource.sectionIdentifier(for: sectionIndex) { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground + config.separatorConfiguration.bottomSeparatorInsets = .zero return .list(using: config, layoutEnvironment: environment) } else { let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.xib b/Tusker/Views/Profile Header/ProfileHeaderView.xib index 2d375314..92df2514 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.xib +++ b/Tusker/Views/Profile Header/ProfileHeaderView.xib @@ -1,9 +1,9 @@ - + - + @@ -46,7 +46,7 @@