diff --git a/CHANGELOG.md b/CHANGELOG.md index a20eebdb..9efdee82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 2023.8 (106) +Bugfixes: +- Fix being able to set post language to multiple/undefined +- iPadOS: Fix language picker button not having a pointer effect +- macOS: Fix Cmd+W sometimes closing the non-foreground window + +## 2023.8 (105) +Features/Improvements: +- Use server-set preference for default post visibility, language, and (on Hometown) local-only +- Add preference to underline links +- Allow changing list reply policy and exclusivity from menu on Edit List screen +- Attribute network requests to user, rather than developer, when appropriate + +Bugfixes: +- Fix older notifications not loading if all initially-loaded are grouped together +- Fix list timelines failing to refresh if there were no statuses initially +- Fix timeline jump button having a background when Button Shapes accessibility setting is on +- Fix crash when relaunching app after not being launched in more than a week +- Fix potential crash on instance selector screen +- Fix crash when showing display names with custom emojis in certain places + ## 2023.8 (104) Features/Improvements: - Show search operators on Mastodon 4.2 diff --git a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift index 2097647e..6733c17d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/API/PostService.swift @@ -111,7 +111,7 @@ class PostService: ObservableObject { do { (data, utType) = try await getData(for: attachment) currentStep += 1 - } catch let error as AttachmentData.Error { + } catch let error as DraftAttachment.ExportError { throw Error.attachmentData(index: index, cause: error) } do { @@ -169,7 +169,7 @@ class PostService: ObservableObject { } enum Error: Swift.Error, LocalizedError { - case attachmentData(index: Int, cause: AttachmentData.Error) + case attachmentData(index: Int, cause: DraftAttachment.ExportError) case attachmentUpload(index: Int, cause: Client.Error) case posting(Client.Error) diff --git a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift index dcdafe5e..fdf4104d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Controllers/ComposeController.swift @@ -20,7 +20,11 @@ public final class ComposeController: ViewController { public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView public typealias EmojiImageView = (Emoji) -> AnyView - @Published public private(set) var draft: Draft + @Published public private(set) var draft: Draft { + didSet { + assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext) + } + } @Published public var config: ComposeUIConfig @Published public var mastodonController: ComposeMastodonContext let fetchAvatar: AvatarImageView.FetchAvatar @@ -106,6 +110,7 @@ public final class ComposeController: ViewController { emojiImageView: @escaping EmojiImageView ) { self.draft = draft + assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext) self.config = config self.mastodonController = mastodonController self.fetchAvatar = fetchAvatar diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift index 16f2443d..0def2175 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftAttachment.swift @@ -137,6 +137,8 @@ extension DraftAttachment { //private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment" private let imageType = UTType.image.identifier +private let heifType = UTType.heif.identifier +private let heicType = UTType.heic.identifier private let jpegType = UTType.jpeg.identifier private let pngType = UTType.png.identifier private let mp4Type = UTType.mpeg4Movie.identifier @@ -148,7 +150,7 @@ extension DraftAttachment: NSItemProviderReading { // todo: is there a better way of handling movies than manually adding all possible UTI types? // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension // without the file extension, getting the thumbnail and exporting the video for attachment upload fails - [/*typeIdentifier, */ gifType, jpegType, pngType, imageType, mp4Type, quickTimeType] + [/*typeIdentifier, */ gifType, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType] } public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { @@ -273,20 +275,13 @@ extension DraftAttachment { var data = data var type = type - if type != .png && type != .jpeg, - let image = UIImage(data: data) { - // The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. - data = image.jpegData(compressionQuality: 0.8)! - type = .jpeg - } - let image = CIImage(data: data)! let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion - if needsColorSpaceConversion || type == .heic { + if needsColorSpaceConversion || type == .heic || type == .heif { let context = CIContext() let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! if type == .png { diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift index c87ad5bf..a6c1342d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift @@ -81,6 +81,7 @@ public class DraftsPersistentContainer: NSPersistentContainer { contentWarning: String, inReplyToID: String?, visibility: Visibility, + language: String?, localOnly: Bool ) -> Draft { let draft = Draft(context: viewContext) @@ -92,6 +93,7 @@ public class DraftsPersistentContainer: NSPersistentContainer { draft.contentWarningEnabled = !contentWarning.isEmpty draft.inReplyToID = inReplyToID draft.visibility = visibility + draft.language = language draft.localOnly = localOnly save() return draft diff --git a/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift b/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift deleted file mode 100644 index 7bd31cd2..00000000 --- a/Packages/ComposeUI/Sources/ComposeUI/Model/AttachmentData.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// AttachmentData.swift -// ComposeUI -// -// Created by Shadowfacts on 1/1/20. -// Copyright © 2020 Shadowfacts. All rights reserved. -// - -import UIKit -import Photos -import UniformTypeIdentifiers -import PencilKit -import InstanceFeatures - -enum AttachmentData { - case asset(PHAsset) - case image(Data, originalType: UTType) - case video(URL) - case drawing(PKDrawing) - case gif(Data) - - var type: AttachmentType { - switch self { - case let .asset(asset): - return asset.attachmentType! - case .image(_, originalType: _): - return .image - case .video(_): - return .video - case .drawing(_): - return .image - case .gif(_): - return .image - } - } - - var isAsset: Bool { - switch self { - case .asset(_): - return true - default: - return false - } - } - - var canSaveToDraft: Bool { - switch self { - case .video(_): - return false - default: - return true - } - } - - func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { - switch self { - case let .image(originalData, originalType): - let data: Data - let type: UTType - switch originalType { - case .png, .jpeg: - data = originalData - type = originalType - default: - let image = UIImage(data: originalData)! - // The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. - data = image.jpegData(compressionQuality: 0.8)! - type = .jpeg - } - let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion) - completion(.success(processed)) - case let .asset(asset): - if asset.mediaType == .image { - let options = PHImageRequestOptions() - options.version = .current - options.deliveryMode = .highQualityFormat - options.resizeMode = .none - options.isNetworkAccessAllowed = true - PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in - guard let data = data, let dataUTI = dataUTI else { - completion(.failure(.missingData)) - return - } - let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion) - completion(.success(processed)) - } - } else if asset.mediaType == .video { - let options = PHVideoRequestOptions() - options.deliveryMode = .automatic - options.isNetworkAccessAllowed = true - options.version = .current - PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in - if let exportSession = exportSession { - AttachmentData.exportVideoData(session: exportSession, completion: completion) - } else if let error = info?[PHImageErrorKey] as? Error { - completion(.failure(.videoExport(error))) - } else { - completion(.failure(.noVideoExportSession)) - } - } - } else { - fatalError("assetType must be either image or video") - } - case let .video(url): - let asset = AVURLAsset(url: url) - guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { - completion(.failure(.noVideoExportSession)) - return - } - AttachmentData.exportVideoData(session: session, completion: completion) - - case let .drawing(drawing): - let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1) - completion(.success((image.pngData()!, .png))) - case let .gif(data): - completion(.success((data, .gif))) - } - } - - private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) { - guard !skipAllConversion else { - return (data, type) - } - - var data = data - var type = type - let image = CIImage(data: data)! - let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB - - // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG - // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) - // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion - if needsColorSpaceConversion || type == .heic { - let context = CIContext() - let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! - if type == .png { - data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)! - } else { - data = context.jpegRepresentation(of: image, colorSpace: colorSpace)! - type = .jpeg - } - } - - return (data, type) - } - - private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) { - session.outputFileType = .mp4 - session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") - session.exportAsynchronously { - guard session.status == .completed else { - completion(.failure(.videoExport(session.error!))) - return - } - do { - let data = try Data(contentsOf: session.outputURL!) - completion(.success((data, .mpeg4Movie))) - } catch { - completion(.failure(.videoExport(error))) - } - } - } - - enum AttachmentType { - case image, video - } - - enum Error: Swift.Error, LocalizedError { - case missingData - case videoExport(Swift.Error) - case noVideoExportSession - - var localizedDescription: String { - switch self { - case .missingData: - return "Missing Data" - case .videoExport(let error): - return "Exporting video: \(error)" - case .noVideoExportSession: - return "Couldn't create video export session" - } - } - } -} - -extension PHAsset { - var attachmentType: AttachmentData.AttachmentType? { - switch self.mediaType { - case .image: - return .image - case .video: - return .video - default: - return nil - } - } -} - -extension AttachmentData: Codable { - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case let .asset(asset): - try container.encode("asset", forKey: .type) - try container.encode(asset.localIdentifier, forKey: .assetIdentifier) - case let .image(originalData, originalType): - try container.encode("image", forKey: .type) - try container.encode(originalType, forKey: .imageType) - try container.encode(originalData, forKey: .imageData) - case .video(_): - throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded")) - case let .drawing(drawing): - try container.encode("drawing", forKey: .type) - let drawingData = drawing.dataRepresentation() - try container.encode(drawingData, forKey: .drawing) - case .gif(_): - throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded")) - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - switch try container.decode(String.self, forKey: .type) { - case "asset": - let identifier = try container.decode(String.self, forKey: .assetIdentifier) - guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else { - throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier") - } - self = .asset(asset) - case "image": - let data = try container.decode(Data.self, forKey: .imageData) - if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) { - self = .image(data, originalType: type) - } else { - guard let image = UIImage(data: data) else { - throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage") - } - let jpegData = image.jpegData(compressionQuality: 1)! - self = .image(jpegData, originalType: .jpeg) - } - case "drawing": - let drawingData = try container.decode(Data.self, forKey: .drawing) - let drawing = try PKDrawing(data: drawingData) - self = .drawing(drawing) - default: - throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing") - } - } - - enum CodingKeys: CodingKey { - case type - case imageData - case imageType - /// The local identifier of the PHAsset for this attachment - case assetIdentifier - /// The PKDrawing object for this attachment. - case drawing - } -} - -extension AttachmentData: Equatable { - static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool { - switch (lhs, rhs) { - case let (.asset(a), .asset(b)): - return a.localIdentifier == b.localIdentifier - case let (.image(a, originalType: aType), .image(b, originalType: bType)): - return a == b && aType == bType - case let (.video(a), .video(b)): - return a == b - case let (.drawing(a), .drawing(b)): - return a == b - default: - return false - } - } -} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift index 07c7eb61..01f252e2 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/LanguagePicker.swift @@ -30,7 +30,13 @@ struct LanguagePicker: View { if maybeIso639Code.last == "-" { maybeIso639Code = maybeIso639Code[..(_ request: Request) async throws -> (Result, Pagination?) { + return try await withCheckedThrowingContinuation { continuation in + run(request) { response in + switch response { + case .failure(let error): + continuation.resume(throwing: error) + case .success(let result, let pagination): + continuation.resume(returning: (result, pagination)) + } + } + } + } + func createURLRequest(request: Request) -> URLRequest? { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } components.path = request.endpoint.path @@ -122,6 +136,8 @@ public class Client { } if let accessToken = accessToken { urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + // We consider authenticated requests to be user-initiated. + urlRequest.attribution = .user } return urlRequest } @@ -223,6 +239,10 @@ public class Client { return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis") } + public static func getPreferences() -> Request { + return Request(method: .get, path: "/api/v1/preferences") + } + // MARK: - Accounts public static func getAccount(id: String) -> Request { return Request(method: .get, path: "/api/v1/accounts/\(id)") diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift index 491035ab..e8dfdde9 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift @@ -11,14 +11,18 @@ import Foundation public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { public let id: String public let title: String + public let replyPolicy: ReplyPolicy? + public let exclusive: Bool? public var timeline: Timeline { return .list(id: id) } - public init(id: String, title: String) { + public init(id: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) { self.id = id self.title = title + self.replyPolicy = replyPolicy + self.exclusive = exclusive } public static func ==(lhs: List, rhs: List) -> Bool { @@ -36,8 +40,15 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { return request } - public static func update(_ listID: String, title: String) -> Request { - return Request(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title])) + public static func update(_ listID: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) -> Request { + var params = ["title" => title] + if let replyPolicy { + params.append("replies_policy" => replyPolicy.rawValue) + } + if let exclusive { + params.append("exclusive" => exclusive) + } + return Request(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(params)) } public static func delete(_ listID: String) -> Request { @@ -59,5 +70,13 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { private enum CodingKeys: String, CodingKey { case id case title + case replyPolicy = "replies_policy" + case exclusive + } +} + +extension List { + public enum ReplyPolicy: String, Codable, Hashable, CaseIterable, Sendable { + case followed, list, none } } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift new file mode 100644 index 00000000..c93701e8 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift @@ -0,0 +1,37 @@ +// +// Preferences.swift +// Pachyderm +// +// Created by Shadowfacts on 10/26/23. +// + +import Foundation + +public struct Preferences: Codable, Sendable { + public let postingDefaultVisibility: Visibility + public let postingDefaultSensitive: Bool + public let postingDefaultLanguage: String + // Whether posts federate or not (local-only) on Hometown + public let postingDefaultFederation: Bool? + public let readingExpandMedia: ExpandMedia + public let readingExpandSpoilers: Bool + public let readingAutoplayGifs: Bool + + enum CodingKeys: String, CodingKey { + case postingDefaultVisibility = "posting:default:visibility" + case postingDefaultSensitive = "posting:default:sensitive" + case postingDefaultLanguage = "posting:default:language" + case postingDefaultFederation = "posting:default:federation" + case readingExpandMedia = "reading:expand:media" + case readingExpandSpoilers = "reading:expand:spoilers" + case readingAutoplayGifs = "reading:autoplay:gifs" + } +} + +extension Preferences { + public enum ExpandMedia: String, Codable, Sendable { + case `default` + case always = "show_all" + case never = "hide_all" + } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift index d9d5291d..efe3a76f 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift @@ -10,4 +10,6 @@ import Foundation public protocol ListProtocol { var id: String { get } var title: String { get } + var replyPolicy: List.ReplyPolicy? { get } + var exclusive: Bool? { get } } diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift new file mode 100644 index 00000000..c0a7eb8d --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift @@ -0,0 +1,97 @@ +// +// PostVisibility.swift +// TuskerPreferences +// +// Created by Shadowfacts on 10/26/23. +// + +import Foundation +import Pachyderm + +public enum PostVisibility: Codable, Hashable, CaseIterable { + case serverDefault + case visibility(Visibility) + + public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) } + + public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { + switch self { + case .serverDefault: + // If the server doesn't have a default visibility preference, we fallback to public. + // This isn't ideal, but I don't want to add a separate preference for "Default Post Visibility Fallback" :/ + serverDefault ?? .public + case .visibility(let vis): + vis + } + } + + public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility { + switch self { + case .serverDefault: + await serverDefault() ?? .public + case .visibility(let vis): + vis + } + } + + public var displayName: String { + switch self { + case .serverDefault: + return "Account Default" + case .visibility(let vis): + return vis.displayName + } + } + + public var imageName: String? { + switch self { + case .serverDefault: + return nil + case .visibility(let vis): + return vis.imageName + } + } +} + +public enum ReplyVisibility: Codable, Hashable, CaseIterable { + case sameAsPost + case visibility(Visibility) + + public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } + + public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { + switch self { + case .sameAsPost: + Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault) + case .visibility(let vis): + vis + } + } + + public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility { + switch self { + case .sameAsPost: + await Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault) + case .visibility(let vis): + vis + } + } + + public var displayName: String { + switch self { + case .sameAsPost: + return "Same as Default" + case .visibility(let vis): + return vis.displayName + } + } + + public var imageName: String? { + switch self { + case .sameAsPost: + return nil + case .visibility(let vis): + return vis.imageName + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift index 5328bdb9..6a099dd1 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift @@ -61,8 +61,13 @@ public final class Preferences: Codable, ObservableObject { self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode + self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false - self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility) + if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { + self.defaultPostVisibility = .visibility(existing) + } else { + self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility) + } self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) @@ -121,6 +126,7 @@ public final class Preferences: Codable, ObservableObject { try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions) try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode) + try container.encode(underlineTextLinks, forKey: .underlineTextLinks) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) @@ -175,9 +181,10 @@ public final class Preferences: Codable, ObservableObject { @Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode + @Published public var underlineTextLinks = false // MARK: Composing - @Published public var defaultPostVisibility = Visibility.public + @Published public var defaultPostVisibility = PostVisibility.serverDefault @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published public var requireAttachmentDescriptions = false @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs @@ -245,6 +252,7 @@ public final class Preferences: Codable, ObservableObject { case leadingStatusSwipeActions case trailingStatusSwipeActions case widescreenNavigationMode + case underlineTextLinks case defaultPostVisibility case defaultReplyVisibility @@ -288,42 +296,6 @@ public final class Preferences: Codable, ObservableObject { } -extension Preferences { - public enum ReplyVisibility: Codable, Hashable, CaseIterable { - case sameAsPost - case visibility(Visibility) - - public static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } - - public var resolved: Visibility { - switch self { - case .sameAsPost: - return Preferences.shared.defaultPostVisibility - case .visibility(let vis): - return vis - } - } - - public var displayName: String { - switch self { - case .sameAsPost: - return "Same as Default" - case .visibility(let vis): - return vis.displayName - } - } - - public var imageName: String? { - switch self { - case .sameAsPost: - return nil - case .visibility(let vis): - return vis.imageName - } - } - } -} - extension Preferences { public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { case useStatusSetting diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 022fdefd..8deb9778 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -12,6 +12,7 @@ import ComposeUI import UniformTypeIdentifiers import TuskerPreferences import Combine +import Pachyderm class ShareViewController: UIViewController { @@ -50,21 +51,26 @@ class ShareViewController: UIViewController { } private func createDraft(account: UserAccountInfo) async -> Draft { - let (text, attachments) = await getDraftConfigurationFromExtensionContext() + async let (text, attachments) = getDraftConfigurationFromExtensionContext() + + // TODO: I really don't like that there's a network request in the hot path here, but we don't have easy access to AccountPreferences :/ + let serverPrefs = try? await Client(baseURL: account.instanceURL, accessToken: account.accessToken).run(Client.getPreferences()).0 + let visibility = Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverPrefs?.postingDefaultVisibility) let draft = DraftsPersistentContainer.shared.createDraft( accountID: account.id, - text: text, + text: await text, contentWarning: "", inReplyToID: nil, - visibility: Preferences.shared.defaultPostVisibility, - localOnly: false + visibility: visibility, + language: serverPrefs?.postingDefaultLanguage, + localOnly: !(serverPrefs?.postingDefaultFederation ?? true) ) - for attachment in attachments { + for attachment in await attachments { DraftsPersistentContainer.shared.viewContext.insert(attachment) } - draft.draftAttachments = attachments + draft.draftAttachments = await attachments return draft } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 89f50a79..e1076c87 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -212,8 +212,6 @@ D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; }; D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; }; D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; }; - D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; }; - D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; }; D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; }; D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6A4531729EF64BA00032932 /* MainInterface.storyboard */; }; D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6A4531329EF64BA00032932 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -256,7 +254,6 @@ D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; }; D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.swift */; }; D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */; }; - D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; }; D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; }; D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; @@ -265,6 +262,7 @@ D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; + D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C041C32AED77730094D32D /* EditListSettingsService.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; }; D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; }; @@ -614,8 +612,6 @@ D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = ""; }; D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = ""; }; D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = ""; }; - D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = ""; }; - D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = ""; }; D6A4531329EF64BA00032932 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D6A4531529EF64BA00032932 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; D6A4531829EF64BA00032932 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; @@ -657,7 +653,6 @@ D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = ""; }; D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = ""; }; D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = ""; }; - D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = ""; }; D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = ""; }; D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = ""; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; @@ -667,6 +662,7 @@ D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = ""; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; + D6C041C32AED77730094D32D /* EditListSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSettingsService.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = ""; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; }; @@ -1291,8 +1287,6 @@ D6A3BC872321F78000FD64D5 /* Account Cell */ = { isa = PBXGroup; children = ( - D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */, - D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */, D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */, ); path = "Account Cell"; @@ -1421,7 +1415,6 @@ D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */, D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */, D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */, - D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */, D693DE5823FE24300061E07D /* InteractivePushTransition.swift */, D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */, D6E0DC8D216EDF1E00369478 /* Previewing.swift */, @@ -1639,6 +1632,7 @@ D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A551291F098700F496A8 /* RenameListService.swift */, + D6C041C32AED77730094D32D /* EditListSettingsService.swift */, D6F6A553291F0D9600F496A8 /* DeleteListService.swift */, D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */, D61F75B0293BD85300C0B37F /* CreateFilterService.swift */, @@ -1855,7 +1849,6 @@ D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */, D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */, D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */, - D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */, D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */, D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */, @@ -1980,7 +1973,6 @@ D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */, D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */, D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */, - D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */, D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */, D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */, D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, @@ -2144,6 +2136,7 @@ D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, + D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */, D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */, @@ -2219,7 +2212,6 @@ D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, - D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */, D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, diff --git a/Tusker/API/EditListSettingsService.swift b/Tusker/API/EditListSettingsService.swift new file mode 100644 index 00000000..27fa466a --- /dev/null +++ b/Tusker/API/EditListSettingsService.swift @@ -0,0 +1,46 @@ +// +// EditListSettingsService.swift +// Tusker +// +// Created by Shadowfacts on 10/28/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +@MainActor +class EditListSettingsService { + private let list: ListProtocol + private let mastodonController: MastodonController + private let present: (UIViewController) -> Void + + init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) { + self.list = list + self.mastodonController = mastodonController + self.present = present + } + + func run(title: String? = nil, replyPolicy: List.ReplyPolicy? = nil, exclusive: Bool? = nil) async { + do { + let req = List.update( + list.id, + title: title ?? list.title, + replyPolicy: replyPolicy ?? list.replyPolicy, + exclusive: exclusive ?? list.exclusive + ) + let (list, _) = try await mastodonController.run(req) + mastodonController.updatedList(list) + } catch { + let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in + Task { + await self.run(title: title, replyPolicy: replyPolicy, exclusive: exclusive) + } + })) + present(alert) + } + } + +} diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 1a56c7c4..abe79175 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -197,6 +197,8 @@ class MastodonController: ObservableObject { @MainActor func initialize() { + precondition(!transient, "Cannot initialize transient MastodonController") + // we want this to happen immediately, and synchronously so that the filters (which don't change that often) // are available when Filterers are constructed loadCachedFilters() @@ -221,6 +223,7 @@ class MastodonController: ObservableObject { loadLists() _ = await loadFilters() + await loadServerPreferences() } catch { Logging.general.error("MastodonController initialization failed: \(String(describing: error))") } @@ -362,6 +365,18 @@ class MastodonController: ObservableObject { } } + // MainActor because the accountPreferences instance is bound to the view context + @MainActor + private func loadServerPreferences() async { + guard instanceFeatures.hasServerPreferences, + let (prefs, _) = try? await run(Client.getPreferences()) else { + return + } + accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage + accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility + accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true + } + private func updateActiveInstance(from instance: Instance) { persistentContainer.performBackgroundTask { context in if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { @@ -438,16 +453,12 @@ class MastodonController: ObservableObject { guard let lists = try? persistentContainer.viewContext.fetch(req) else { return [] } - return lists.map { - List(id: $0.id, title: $0.title) - }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) + return lists.map(\.apiList).sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) } func getCachedList(id: String) -> List? { let req = ListMO.fetchRequest(id: id) - return (try? persistentContainer.viewContext.fetch(req).first).flatMap { - List(id: $0.id, title: $0.title) - } + return (try? persistentContainer.viewContext.fetch(req).first).map(\.apiList) } @MainActor @@ -464,7 +475,7 @@ class MastodonController: ObservableObject { } @MainActor - func renamedList(_ list: List) { + func updatedList(_ list: List) { var new = self.lists if let index = new.firstIndex(where: { $0.id == list.id }) { new[index] = list @@ -523,8 +534,12 @@ class MastodonController: ObservableObject { func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { var acctsToMention = [String]() - var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility - var localOnly = false + var visibility = if inReplyToID != nil { + Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) + } else { + Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) + } + var localOnly = instanceFeatures.localOnlyPosts && !accountPreferences!.serverDefaultFederation var contentWarning = "" if let inReplyToID = inReplyToID, @@ -563,6 +578,7 @@ class MastodonController: ObservableObject { contentWarning: contentWarning, inReplyToID: inReplyToID, visibility: visibility, + language: accountPreferences!.serverDefaultLanguage, localOnly: localOnly ) } diff --git a/Tusker/API/RenameListService.swift b/Tusker/API/RenameListService.swift index 9f3a9789..9b0ac9c4 100644 --- a/Tusker/API/RenameListService.swift +++ b/Tusker/API/RenameListService.swift @@ -47,9 +47,9 @@ class RenameListService { private func updateList(with title: String) async { do { - let req = List.update(list.id, title: title) + let req = List.update(list.id, title: title, replyPolicy: nil, exclusive: nil) let (list, _) = try await mastodonController.run(req) - mastodonController.renamedList(list) + mastodonController.updatedList(list) } catch { let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index 4eb1c03e..010622e1 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -173,13 +173,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - @objc func closeWindow() { - guard let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) else { - return - } - UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil) - } - #if !os(visionOS) private func swizzleStatusBar() { let selector = Selector(("handleTapAction:")) diff --git a/Tusker/CoreData/AccountMO.swift b/Tusker/CoreData/AccountMO.swift index df237ecc..a3df7ba1 100644 --- a/Tusker/CoreData/AccountMO.swift +++ b/Tusker/CoreData/AccountMO.swift @@ -59,9 +59,14 @@ public final class AccountMO: NSManagedObject, AccountProtocol { super.awakeFromFetch() managedObjectContext?.perform { - self.lastFetchedAt = Date() + self.touch() } } + + /// Update the `lastFetchedAt` date so this object isn't pruned early. + func touch() { + lastFetchedAt = Date() + } } diff --git a/Tusker/CoreData/AccountPreferences.swift b/Tusker/CoreData/AccountPreferences.swift index c8c0b388..306927c0 100644 --- a/Tusker/CoreData/AccountPreferences.swift +++ b/Tusker/CoreData/AccountPreferences.swift @@ -24,10 +24,22 @@ public final class AccountPreferences: NSManagedObject { @NSManaged public var accountID: String @NSManaged var createdAt: Date @NSManaged var pinnedTimelinesData: Data? + @NSManaged var serverDefaultFederation: Bool + @NSManaged var serverDefaultLanguage: String? + @NSManaged private var serverDefaultVisibilityString: String? @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines) var pinnedTimelines: [PinnedTimeline] + var serverDefaultVisibility: Visibility? { + get { + serverDefaultVisibilityString.flatMap(Visibility.init(rawValue:)) + } + set { + serverDefaultVisibilityString = newValue?.rawValue + } + } + static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { let prefs = AccountPreferences(context: context) prefs.accountID = account.id diff --git a/Tusker/CoreData/ListMO.swift b/Tusker/CoreData/ListMO.swift index b9cc59fb..d22a3942 100644 --- a/Tusker/CoreData/ListMO.swift +++ b/Tusker/CoreData/ListMO.swift @@ -25,6 +25,22 @@ public final class ListMO: NSManagedObject, ListProtocol { @NSManaged public var id: String @NSManaged public var title: String + @NSManaged private var replyPolicyString: String? + @NSManaged private var exclusiveInternal: Bool + + public var replyPolicy: List.ReplyPolicy? { + get { + replyPolicyString.flatMap(List.ReplyPolicy.init(rawValue:)) + } + set { + replyPolicyString = newValue?.rawValue + } + } + + public var exclusive: Bool? { + get { exclusiveInternal } + set { exclusiveInternal = newValue ?? false } + } } @@ -37,5 +53,16 @@ extension ListMO { func updateFrom(apiList list: List) { self.id = list.id self.title = list.title + self.replyPolicy = list.replyPolicy + self.exclusive = list.exclusive + } + + var apiList: List { + List( + id: id, + title: title, + replyPolicy: replyPolicy, + exclusive: exclusive + ) } } diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index 774f8726..e37f64e5 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -89,10 +89,15 @@ public final class StatusMO: NSManagedObject, StatusProtocol { super.awakeFromFetch() managedObjectContext?.perform { - self.lastFetchedAt = Date() + self.touch() } } + /// Update the `lastFetchedAt` date so this object isn't pruned early. + func touch() { + lastFetchedAt = Date() + } + } extension StatusMO { diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 092b622f..ba52345a 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -33,6 +33,9 @@ + + + @@ -59,7 +62,9 @@ + + diff --git a/Tusker/MenuController.swift b/Tusker/MenuController.swift index 3013b898..eb6da265 100644 --- a/Tusker/MenuController.swift +++ b/Tusker/MenuController.swift @@ -41,22 +41,25 @@ struct MenuController { static let prevSubTabCommand = UIKeyCommand(title: "Previous Sub Tab", action: #selector(TabbedPageViewController.selectPrevPage), input: "[", modifierFlags: [.command, .shift]) static func buildMainMenu(builder: UIMenuBuilder) { - builder.replace(menu: .file, with: buildFileMenu()) + builder.replace(menu: .file, with: buildFileMenu(builder: builder)) builder.insertChild(buildSubTabMenu(), atStartOfMenu: .view) builder.insertChild(buildSidebarShortcuts(), atStartOfMenu: .view) } - private static func buildFileMenu() -> UIMenu { + private static func buildFileMenu(builder: UIMenuBuilder) -> UIMenu { + var children: [UIMenuElement] = [ + composeCommand, + refreshCommand(discoverabilityTitle: nil), + ] + if let close = builder.menu(for: .close) { + children.append(close) + } return UIMenu( title: "File", image: nil, identifier: nil, options: [], - children: [ - composeCommand, - refreshCommand(discoverabilityTitle: nil), - UIKeyCommand(title: "Close", action: #selector(AppDelegate.closeWindow), input: "w", modifierFlags: .command), - ] + children: children ) } diff --git a/Tusker/Scenes/AuxiliarySceneDelegate.swift b/Tusker/Scenes/AuxiliarySceneDelegate.swift index 81b09431..284516a7 100644 --- a/Tusker/Scenes/AuxiliarySceneDelegate.swift +++ b/Tusker/Scenes/AuxiliarySceneDelegate.swift @@ -112,7 +112,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel case .list(id: let id): let req = ListMO.fetchRequest(id: id) if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first { - return ListTimelineViewController(for: List(id: id, title: list.title), mastodonController: mastodonController) + return ListTimelineViewController(for: list.apiList, mastodonController: mastodonController) } else { return TimelineViewController(for: timeline, mastodonController: mastodonController) } diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 4cc78263..af367161 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -37,7 +37,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate window = UIWindow(windowScene: windowScene) showAppOrOnboardingUI(session: session) - if connectionOptions.urlContexts.count > 0 { + if !connectionOptions.urlContexts.isEmpty { self.scene(scene, openURLContexts: connectionOptions.urlContexts) } @@ -52,14 +52,21 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate } func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - if URLContexts.count > 1 { - fatalError("Cannot open more than 1 URL") + guard let url = URLContexts.first?.url, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let rootViewController else { + return } - let url = URLContexts.first!.url - - if var components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let rootViewController = rootViewController { + if components.host == "compose" { + if let mastodonController = window!.windowScene!.session.mastodonController { + let draft = mastodonController.createDraft() + let text = components.queryItems?.first(where: { $0.name == "text" })?.value + draft.text = text ?? "" + rootViewController.compose(editing: draft, animated: true, isDucked: false) + } + } else { + // Assume anything else is a search query components.scheme = "https" let query = components.string! rootViewController.performSearch(query: query) diff --git a/Tusker/Screens/Large Image/Transitions/LargeImageInteractionController.swift b/Tusker/Screens/Large Image/Transitions/LargeImageInteractionController.swift index 4f8618b0..562846a8 100644 --- a/Tusker/Screens/Large Image/Transitions/LargeImageInteractionController.swift +++ b/Tusker/Screens/Large Image/Transitions/LargeImageInteractionController.swift @@ -25,7 +25,14 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition { } @objc func handleGesture(_ recognizer: UIPanGestureRecognizer) { - let translation = recognizer.translation(in: recognizer.view!.superview!) + guard let recognizerSuperview = recognizer.view?.superview else { + // Assume the gesture has ended b/c we don't have a view/superview anymore. + inProgress = false + direction = nil + cancel() + return + } + let translation = recognizer.translation(in: recognizerSuperview) var progress = translation.y / 200 if let direction = direction { progress *= direction @@ -63,7 +70,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition { override func cancel() { super.cancel() - viewController.isInteractivelyAnimatingDismissal = false + viewController?.isInteractivelyAnimatingDismissal = false } } diff --git a/Tusker/Screens/Lists/EditListAccountsViewController.swift b/Tusker/Screens/Lists/EditListAccountsViewController.swift index 76513c6c..40f03354 100644 --- a/Tusker/Screens/Lists/EditListAccountsViewController.swift +++ b/Tusker/Screens/Lists/EditListAccountsViewController.swift @@ -10,18 +10,21 @@ import UIKit import Pachyderm import Combine -class EditListAccountsViewController: EnhancedTableViewController { +class EditListAccountsViewController: UIViewController, CollectionViewController { private var list: List - let mastodonController: MastodonController + private let mastodonController: MastodonController - var changedAccounts = false + private var state = State.unloaded - var dataSource: DataSource! - var nextRange: RequestRange? + private(set) var changedAccounts = false - var searchResultsController: SearchResultsViewController! - var searchController: UISearchController! + private var dataSource: UICollectionViewDiffableDataSource! + var collectionView: UICollectionView! { view as? UICollectionView } + private var nextRange: RequestRange? + + private var searchResultsController: SearchResultsViewController! + private var searchController: UISearchController! private var listRenamedCancellable: AnyCancellable? @@ -29,13 +32,12 @@ class EditListAccountsViewController: EnhancedTableViewController { self.list = list self.mastodonController = mastodonController - super.init(style: .plain) + super.init(nibName: nil, bundle: nil) listChanged() listRenamedCancellable = mastodonController.$lists .compactMap { $0.first { $0.id == list.id } } - .removeDuplicates(by: { $0.title == $1.title }) .sink { [unowned self] in self.list = $0 self.listChanged() @@ -46,29 +48,45 @@ class EditListAccountsViewController: EnhancedTableViewController { fatalError("init(coder:) has not been implemeneted") } + override func loadView() { + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in + var config = sectionConfig + switch dataSource.itemIdentifier(for: indexPath)! { + case .loadingIndicator: + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + case .account(id: _): + config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + } + return config + } + config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in + switch dataSource.itemIdentifier(for: indexPath) { + case .account(id: let id): + let remove = UIContextualAction(style: .destructive, title: "Remove") { [unowned self] _, _, completion in + Task { + await self.removeAccount(id: id) + completion(true) + } + } + return UISwipeActionsConfiguration(actions: [remove]) + default: + return nil + } + } + let layout = UICollectionViewCompositionalLayout.list(using: config) + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.delegate = self + collectionView.allowsSelection = false + collectionView.backgroundColor = .appGroupedBackground + dataSource = createDataSource() + } + override func viewDidLoad() { super.viewDidLoad() - tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell") - - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 66 - tableView.allowsSelection = false - tableView.backgroundColor = .appGroupedBackground - - dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in - guard case let .account(id) = item else { fatalError() } - - let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell - cell.delegate = self - cell.updateUI(accountID: id) - cell.configurationUpdateHandler = { cell, state in - cell.backgroundConfiguration = .appListGroupedCell(for: state) - } - return cell - }) - dataSource.editListAccountsController = self - searchResultsController = SearchResultsViewController(mastodonController: mastodonController, scope: .people) searchResultsController.following = true searchResultsController.delegate = self @@ -84,23 +102,103 @@ class EditListAccountsViewController: EnhancedTableViewController { navigationItem.hidesSearchBarWhenScrolling = false 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) + }) + ])) } + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + cell.indicator.startAnimating() + } + let accountCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in + cell.delegate = self + cell.updateUI(accountID: itemIdentifier) + cell.configurationUpdateHandler = { cell, state in + cell.backgroundConfiguration = .appListGroupedCell(for: state) + } + } + return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .loadingIndicator: + return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) + case .account(id: let id): + return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id) + } + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) - navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed)) - + clearSelectionOnAppear(animated: animated) + Task { await loadAccounts() } } private func listChanged() { - title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title) + title = list.title } - func loadAccounts() async { + private func listSettingsMenuElements() -> [UIMenuElement] { + var elements = [UIMenuElement]() + if mastodonController.instanceFeatures.listRepliesPolicy { + let actions = List.ReplyPolicy.allCases.map { policy in + UIAction(title: policy.actionTitle, state: list.replyPolicy == policy ? .on : .off) { [unowned self] _ in + self.setReplyPolicy(policy) + } + } + elements.append(UIMenu(title: "Show replies…", image: UIImage(systemName: "arrowshape.turn.up.left"), children: actions)) + } + if mastodonController.instanceFeatures.exclusiveLists { + let actions = [ + UIAction(title: "Hidden from Home", state: list.exclusive == true ? .on : .off) { [unowned self] _ in + self.setExclusive(true) + }, + UIAction(title: "Shown on Home", state: list.exclusive == false ? .on : .off) { [unowned self] _ in + self.setExclusive(false) + }, + ] + elements.append(UIMenu(title: "Posts from this list are…", children: actions)) + } + return elements + } + + @MainActor + private func loadAccounts() async { + guard state == .unloaded else { return } + + state = .loading + + async let results = try await mastodonController.run(List.getAccounts(list.id)) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.accounts]) + snapshot.appendItems([.loadingIndicator]) + await dataSource.apply(snapshot) + do { - let request = List.getAccounts(list.id) - let (accounts, pagination) = try await mastodonController.run(request) + let (accounts, pagination) = try await results self.nextRange = pagination?.older await withCheckedContinuation { continuation in @@ -109,20 +207,61 @@ class EditListAccountsViewController: EnhancedTableViewController { } } - var snapshot = self.dataSource.snapshot() - if snapshot.indexOfSection(.accounts) == nil { - snapshot.appendSections([.accounts]) - } else { - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts)) - } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.accounts]) snapshot.appendItems(accounts.map { .account(id: $0.id) }) await dataSource.apply(snapshot) + + state = .loaded } catch { let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in toast.dismissToast(animated: true) await self.loadAccounts() } self.showToast(configuration: config, animated: true) + + state = .unloaded + await dataSource.apply(.init()) + } + } + + private func loadNextPage() async { + guard state == .loaded, + let nextRange else { return } + + state = .loading + + async let results = try await mastodonController.run(List.getAccounts(list.id, range: nextRange)) + + let origSnapshot = dataSource.snapshot() + var snapshot = origSnapshot + snapshot.appendItems([.loadingIndicator]) + await dataSource.apply(snapshot) + + do { + let (accounts, pagination) = try await results + self.nextRange = pagination?.older + + await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(accounts: accounts) { + continuation.resume() + } + } + + var snapshot = origSnapshot + snapshot.appendItems(accounts.map { .account(id: $0.id) }) + await dataSource.apply(snapshot) + + state = .loaded + } catch { + let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.loadNextPage() + } + self.showToast(configuration: config, animated: true) + + state = .loaded + await dataSource.apply(origSnapshot) } } @@ -156,19 +295,30 @@ class EditListAccountsViewController: EnhancedTableViewController { self.showToast(configuration: config, animated: true) } } - - // MARK: - Table view delegate - - override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .delete + + private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(replyPolicy: replyPolicy) + } } - // MARK: - Interaction - - @objc func renameButtonPressed() { - RenameListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }).run() + private func setExclusive(_ exclusive: Bool) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(exclusive: exclusive) + } + } + +} + +extension EditListAccountsViewController { + enum State { + case unloaded + case loading + case loaded + case loadingOlder } - } extension EditListAccountsViewController { @@ -176,24 +326,17 @@ extension EditListAccountsViewController { case accounts } enum Item: Hashable { + case loadingIndicator case account(id: String) } - - class DataSource: UITableViewDiffableDataSource { - weak var editListAccountsController: EditListAccountsViewController? - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return true - } - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - guard editingStyle == .delete, - case let .account(id) = itemIdentifier(for: indexPath) else { - return - } - +} + +extension EditListAccountsViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + if state == .loaded, + indexPath.item == collectionView.numberOfItems(inSection: indexPath.section) - 1 { Task { - await self.editListAccountsController?.removeAccount(id: id) + await loadNextPage() } } } @@ -216,3 +359,29 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate { } } } + +extension EditListAccountsViewController: UINavigationItemRenameDelegate { + func navigationItem(_: UINavigationItem, shouldEndRenamingWith title: String) -> Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) { + Task { + let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) + await service.run(title: title) + } + } +} + +private extension List.ReplyPolicy { + var actionTitle: String { + switch self { + case .followed: + "To accounts you follow" + case .list: + "To other list members" + case .none: + "Never" + } + } +} diff --git a/Tusker/Screens/Lists/ListTimelineViewController.swift b/Tusker/Screens/Lists/ListTimelineViewController.swift index eab7d025..23ffd7d7 100644 --- a/Tusker/Screens/Lists/ListTimelineViewController.swift +++ b/Tusker/Screens/Lists/ListTimelineViewController.swift @@ -59,7 +59,7 @@ class ListTimelineViewController: TimelineViewController { func presentEdit(animated: Bool) { let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController) - editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) + editListAccountsController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) let navController = UINavigationController(rootViewController: editListAccountsController) present(navController, animated: animated) } diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index d2df9c76..406d1eec 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -214,6 +214,10 @@ extension MainSplitViewController: UISplitViewControllerDelegate { if item == sidebar.selectedItem { itemNavStack = secondaryNavController.viewControllers secondaryNavController.viewControllers = [] + // Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy + for vc in itemNavStack { + vc.viewIfLoaded?.removeFromSuperview() + } } else { itemNavStack = navigationStacks[item] ?? [] navigationStacks.removeValue(forKey: item) @@ -339,6 +343,11 @@ extension MainSplitViewController: UISplitViewControllerDelegate { let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst) navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst) + // Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy + for vc in viewControllersToMove { + vc.viewIfLoaded?.removeFromSuperview() + } + if let prepend = prepend { navigationStacks[item] = [prepend] + viewControllersToMove } else { diff --git a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift index 2e306028..3628ebae 100644 --- a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift +++ b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift @@ -152,7 +152,8 @@ class InstanceSelectorTableViewController: UITableViewController { private func updateSpecificInstance(domain: String) { activityIndicator.startAnimating() - guard let components = parseURLComponents(input: domain) else { + guard let components = parseURLComponents(input: domain), + let url = components.url else { var snapshot = dataSource.snapshot() if snapshot.indexOfSection(.selected) != nil { snapshot.deleteSections([.selected]) @@ -161,7 +162,6 @@ class InstanceSelectorTableViewController: UITableViewController { activityIndicator.stopAnimating() return } - let url = components.url! let client = Client(baseURL: url, session: .appDefault) let request = Client.getInstance() diff --git a/Tusker/Screens/Preferences/AppearancePrefsView.swift b/Tusker/Screens/Preferences/AppearancePrefsView.swift index 1e59399f..10c46457 100644 --- a/Tusker/Screens/Preferences/AppearancePrefsView.swift +++ b/Tusker/Screens/Preferences/AppearancePrefsView.swift @@ -127,6 +127,9 @@ struct AppearancePrefsView : View { Toggle(isOn: $preferences.showLinkPreviews) { Text("Show Link Previews") } + Toggle(isOn: $preferences.underlineTextLinks) { + Text("Underline Links") + } NavigationLink("Leading Swipe Actions") { SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions) .edgesIgnoringSafeArea(.all) diff --git a/Tusker/Screens/Preferences/ComposingPrefsView.swift b/Tusker/Screens/Preferences/ComposingPrefsView.swift index 49ec21a5..0c15152b 100644 --- a/Tusker/Screens/Preferences/ComposingPrefsView.swift +++ b/Tusker/Screens/Preferences/ComposingPrefsView.swift @@ -28,9 +28,11 @@ struct ComposingPrefsView: View { var visibilitySection: some View { Section { Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) { - ForEach(Visibility.allCases, id: \.self) { visibility in + ForEach(PostVisibility.allCases, id: \.self) { visibility in HStack { - Image(systemName: visibility.imageName) + if let imageName = visibility.imageName { + Image(systemName: imageName) + } Text(visibility.displayName) } .tag(visibility) @@ -38,7 +40,7 @@ struct ComposingPrefsView: View { // navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291 } Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) { - ForEach(Preferences.ReplyVisibility.allCases, id: \.self) { visibility in + ForEach(ReplyVisibility.allCases, id: \.self) { visibility in HStack { if let imageName = visibility.imageName { Image(systemName: imageName) diff --git a/Tusker/Screens/Timeline/TimelineJumpButton.swift b/Tusker/Screens/Timeline/TimelineJumpButton.swift index 8bda27f3..2f1cf090 100644 --- a/Tusker/Screens/Timeline/TimelineJumpButton.swift +++ b/Tusker/Screens/Timeline/TimelineJumpButton.swift @@ -20,6 +20,8 @@ class TimelineJumpButton: UIView { var config = UIButton.Configuration.plain() config.image = UIImage(systemName: "arrow.up") config.contentInsets = .zero + // We don't want a background for this button, even when accessibility button shapes are enabled, because it's in the navbar. + config.background.backgroundColor = .clear return UIButton(configuration: config) }() diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index c8e83cb6..3c13f02a 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -446,7 +446,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { let originalPositionStatusIDs = position.statusIDs - let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil }) + var unloaded = [String]() + for id in position.statusIDs { + if let status = mastodonController.persistentContainer.status(for: id) { + // touch the status so that, even if it's old, it doesn't get pruned when we go into the background + status.touch() + } else { + unloaded.append(id) + } + } guard !unloaded.isEmpty else { return true } @@ -580,6 +588,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro var count = 0 while count < 5 { count += 1 + #if canImport(Sentry) + let crumb = Breadcrumb(level: .info, category: "TimelineViewController") + crumb.message = "scrollToItem, attempt=\(count)" + SentrySDK.addBreadcrumb(crumb) + #endif let origOffset = self.collectionView.contentOffset self.collectionView.layoutIfNeeded() self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) @@ -734,10 +747,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro @objc func refresh() { Task { @MainActor in - if case .notLoadedInitial = controller.state { - await controller.loadInitial() - } else { + if case .idle = controller.state, + !dataSource.snapshot().itemIdentifiers(inSection: .statuses).isEmpty { await controller.loadNewer() + } else { + await controller.loadInitial() } #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() @@ -1173,7 +1187,10 @@ extension TimelineViewController { let addedItems: Bool let statusItems = snapshot.itemIdentifiers(inSection: .statuses) - let gapIndex = statusItems.firstIndex(of: .gap)! + guard let gapIndex = statusItems.firstIndex(of: .gap) else { + // Not sure how this is reachable (maybe the gap cell was tapped twice and the requests raced?) but w/e + return + } switch direction { case .above: @@ -1300,6 +1317,9 @@ extension TimelineViewController: UICollectionViewDelegate { selected(status: status.reblog?.id ?? id, state: collapseState.copy()) } case .gap: + guard controller.state == .idle else { + return + } let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell cell.showsIndicator = true Task { diff --git a/Tusker/Screens/Utilities/EnhancedTableViewController.swift b/Tusker/Screens/Utilities/EnhancedTableViewController.swift deleted file mode 100644 index 6ea5e358..00000000 --- a/Tusker/Screens/Utilities/EnhancedTableViewController.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// EnhancedTableViewController.swift -// Tusker -// -// Created by Shadowfacts on 11/10/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import UIKit -import SafariServices - -class EnhancedTableViewController: UITableViewController { - - var dragEnabled = false - - override func viewDidLoad() { - super.viewDidLoad() - - if dragEnabled { - tableView.dragDelegate = self - } - } - - // MARK: Table View Delegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell { - cell.didSelectCell() - } - } - -} - -extension EnhancedTableViewController { - - override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - if let cell = tableView.cellForRow(at: indexPath) as? UITableViewCell & MenuPreviewProvider { - let cellLocation = cell.convert(point, from: tableView) - guard let (previewProvider, actionsProvider) = cell.getPreviewProviders(for: cellLocation, sourceViewController: self) else { - return nil - } - let actionProvider: UIContextMenuActionProvider = { (_) in - let suggested = self.getSuggestedContextMenuActions(tableView: tableView, indexPath: indexPath, point: point) - return UIMenu(title: "", image: nil, identifier: nil, options: [], children: suggested + actionsProvider()) - } - return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider) - } else { - return nil - } - } - - // todo: replace this with the UIKit suggested actions, if possible - @objc open func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] { - return [] - } - - override func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - if let viewController = animator.previewViewController { - animator.preferredCommitStyle = .pop - animator.addCompletion { - if let customPresenting = viewController as? CustomPreviewPresenting { - customPresenting.presentFromPreview(presenter: self) - } else { - self.show(viewController, sender: nil) - } - } - } - } - -} - -extension EnhancedTableViewController: UITableViewDragDelegate { - func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - guard let cell = tableView.cellForRow(at: indexPath) as? DraggableTableViewCell else { - return [] - } - return cell.dragItemsForBeginning(session: session) - } -} - -extension EnhancedTableViewController: TabBarScrollableViewController { - func tabBarScrollToTop() { - tableView.scrollToTop() - } -} - -extension EnhancedTableViewController: StatusBarTappableViewController { - func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { - tableView.scrollToTop() - return .stop - } -} diff --git a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift index 6cf2ae5b..8239f09a 100644 --- a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift +++ b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift @@ -212,10 +212,9 @@ extension TimelineLikeCollectionViewController { // apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods // but we always want to update the data source on the main thread for consistency, so this method does that func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool) async { - let task = Task { @MainActor in - self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) + await MainActor.run { + dataSource?.apply(snapshot, animatingDifferences: animatingDifferences) } - await task.value } @MainActor diff --git a/Tusker/Views/Account Cell/AccountTableViewCell.swift b/Tusker/Views/Account Cell/AccountTableViewCell.swift deleted file mode 100644 index 67036ec0..00000000 --- a/Tusker/Views/Account Cell/AccountTableViewCell.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// AccountTableViewCell.swift -// Tusker -// -// Created by Shadowfacts on 9/5/19. -// Copyright © 2019 Shadowfacts. All rights reserved. -// - -import UIKit -import SwiftSoup - -class AccountTableViewCell: UITableViewCell { - - weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)? - var mastodonController: MastodonController! { delegate?.apiController } - - @IBOutlet weak var avatarImageView: UIImageView! - @IBOutlet weak var displayNameLabel: AccountDisplayNameLabel! - @IBOutlet weak var usernameLabel: UILabel! - @IBOutlet weak var noteLabel: EmojiLabel! - - var accountID: String! - - private var avatarRequest: ImageCache.Request? - private var isGrayscale = false - - override func awakeFromNib() { - super.awakeFromNib() - - avatarImageView.layer.masksToBounds = true - avatarImageView.layer.cornerCurve = .continuous - - usernameLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light)) - usernameLabel.adjustsFontForContentSizeCategory = true - - noteLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) - noteLabel.adjustsFontForContentSizeCategory = true - - NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPrefrences), name: .preferencesChanged, object: nil) - } - - @objc func updateUIForPrefrences() { - avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) - - guard let account = mastodonController.persistentContainer.account(for: accountID) else { - // this table view cell could be cached in a table view (e.g., SearchResultsViewController) for an account that's since been purged - return - } - displayNameLabel.updateForAccountDisplayName(account: account) - - if isGrayscale != Preferences.shared.grayscaleImages { - updateGrayscaleableUI(account: account) - } - } - - func updateUI(accountID: String) { - self.accountID = accountID - guard let account = mastodonController.persistentContainer.account(for: accountID) else { - fatalError("Missing cached account \(accountID)") - } - - usernameLabel.text = "@\(account.acct)" - - updateGrayscaleableUI(account: account) - updateUIForPrefrences() - } - - private func updateGrayscaleableUI(account: AccountMO) { - isGrayscale = Preferences.shared.grayscaleImages - - let accountID = self.accountID - - avatarImageView.image = nil - if let avatarURL = account.avatar { - avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in - guard let self = self else { return } - self.avatarRequest = nil - - guard let image = image, - self.accountID == accountID, - let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else { return } - - DispatchQueue.main.async { - self.avatarImageView.image = transformedImage - } - } - } - - let doc = try! SwiftSoup.parse(account.note) - noteLabel.text = try! doc.text() - noteLabel.setEmojis(account.emojis, identifier: account.id) - } - - override func prepareForReuse() { - super.prepareForReuse() - - avatarRequest?.cancel() - } - -} - -extension AccountTableViewCell: SelectableTableViewCell { - func didSelectCell() { - delegate?.selected(account: accountID) - } -} - -extension AccountTableViewCell: MenuPreviewProvider { - func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { - guard let mastodonController = mastodonController else { return nil } - return ( - content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) }, - actions: { self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [] } - ) - } -} - -extension AccountTableViewCell: DraggableTableViewCell { - func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] { - guard let account = mastodonController.persistentContainer.account(for: accountID), - let currentAccountID = mastodonController.accountInfo?.id else { - return [] - } - let provider = NSItemProvider(object: account.url as NSURL) - let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) - activity.displaysAuxiliaryScene = true - provider.registerObject(activity, visibility: .all) - return [UIDragItem(itemProvider: provider)] - } -} diff --git a/Tusker/Views/Account Cell/AccountTableViewCell.xib b/Tusker/Views/Account Cell/AccountTableViewCell.xib deleted file mode 100644 index e2c188c0..00000000 --- a/Tusker/Views/Account Cell/AccountTableViewCell.xib +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tusker/Views/AccountDisplayNameView.swift b/Tusker/Views/AccountDisplayNameView.swift index 0365171c..22e3effd 100644 --- a/Tusker/Views/AccountDisplayNameView.swift +++ b/Tusker/Views/AccountDisplayNameView.swift @@ -38,6 +38,7 @@ struct AccountDisplayNameView: View { let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange) guard !matches.isEmpty else { return } + let emojiSize = self.emojiSize let emojiImages = MultiThreadDictionary() let group = DispatchGroup() diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index 747b2b0c..9a5661f1 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -12,6 +12,7 @@ import Pachyderm import SafariServices import WebURL import WebURLFoundationExtras +import Combine private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let dataDetectorsScheme = "x-apple-data-detectors" @@ -52,6 +53,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { // The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing. private weak var currentTargetedPreview: UITargetedPreview? + private var underlineTextLinksCancellable: AnyCancellable? + override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) commonInit() @@ -78,10 +81,30 @@ class ContentTextView: LinkTextView, BaseEmojiLabel { linkTextAttributes = [ .foregroundColor: UIColor.tintColor ] + updateLinkUnderlineStyle() // the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:))) addGestureRecognizer(recognizer) + + NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil) + underlineTextLinksCancellable = + Preferences.shared.$underlineTextLinks + .sink { [unowned self] in + self.updateLinkUnderlineStyle(preference: $0) + } + } + + @objc private func _updateLinkUnderlineStyle() { + updateLinkUnderlineStyle() + } + + private func updateLinkUnderlineStyle(preference: Bool = Preferences.shared.underlineTextLinks) { + if UIAccessibility.buttonShapesEnabled || preference { + linkTextAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + } else { + linkTextAttributes.removeValue(forKey: .underlineStyle) + } } // MARK: - Emojis diff --git a/Version.xcconfig b/Version.xcconfig index 53cd7e4c..5c9d4c77 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -10,7 +10,7 @@ // https://help.apple.com/xcode/#/dev745c5c974 MARKETING_VERSION = 2023.8 -CURRENT_PROJECT_VERSION = 104 +CURRENT_PROJECT_VERSION = 106 CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev