Merge branch 'develop' into vision

This commit is contained in:
Shadowfacts 2023-11-07 22:26:22 -05:00
commit 1da25300ca
45 changed files with 740 additions and 775 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}
}

View File

@ -30,7 +30,13 @@ struct LanguagePicker: View {
if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
}
let code = Locale.LanguageCode(String(maybeIso639Code))
let identifier = String(maybeIso639Code)
// mul (for multiple languages) and unk (unknown) are ISO codes, but not ones that akkoma permits, so we ignore them on all platforms
guard identifier != "mul",
identifier != "und" else {
return nil
}
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage {
return code
} else {
@ -39,13 +45,10 @@ struct LanguagePicker: View {
}
private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first {
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage {
return code
} else {
return nil
}
if let identifier = Locale.preferredLanguages.first,
case let code = Locale.LanguageCode(identifier),
code.isISOLanguage {
return code
} else {
return nil
}
@ -66,6 +69,8 @@ struct LanguagePicker: View {
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
}
.accessibilityLabel("Post Language")
.padding(5)
.hoverEffect()
.sheet(isPresented: $isShowingSheet) {
NavigationStack {
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
@ -140,10 +145,12 @@ private struct LanguagePickerList: View {
// make sure recents always contains the currently selected lang
let recents = addRecentLang(languageCode)
recentLangs = recents
.filter { $0 != "mul" && $0 != "und" }
.map { Lang(code: .init($0)) }
.sorted { $0.name < $1.name }
langs = Locale.LanguageCode.isoLanguageCodes
.filter { $0.identifier != "mul" && $0.identifier != "und" }
.map { Lang(code: $0) }
.sorted { $0.name < $1.name }
}

View File

@ -171,6 +171,18 @@ public class InstanceFeatures: ObservableObject {
hasMastodonVersion(4, 2, 0)
}
public var hasServerPreferences: Bool {
hasMastodonVersion(2, 8, 0)
}
public var listRepliesPolicy: Bool {
hasMastodonVersion(3, 3, 0)
}
public var exclusiveLists: Bool {
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
}
public init() {
}

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "Pachyderm",
platforms: [
.iOS(.v14),
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -105,6 +105,20 @@ public class Client {
return task
}
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) 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<Result>(request: Request<Result>) -> 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<Preferences> {
return Request(method: .get, path: "/api/v1/preferences")
}
// MARK: - Accounts
public static func getAccount(id: String) -> Request<Account> {
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")

View File

@ -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<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title]))
public static func update(_ listID: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) -> Request<List> {
var params = ["title" => title]
if let replyPolicy {
params.append("replies_policy" => replyPolicy.rawValue)
}
if let exclusive {
params.append("exclusive" => exclusive)
}
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(params))
}
public static func delete(_ listID: String) -> Request<Empty> {
@ -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
}
}

View File

@ -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"
}
}

View File

@ -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 }
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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 = "<group>"; };
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = "<group>"; };
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = "<group>"; };
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 = "<group>"; };
D6A4531829EF64BA00032932 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
@ -657,7 +653,6 @@
D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = "<group>"; };
D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = "<group>"; };
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = "<group>"; };
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; };
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; };
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
@ -667,6 +662,7 @@
D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; };
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
D6C041C32AED77730094D32D /* EditListSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSettingsService.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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)
}
}
}

View File

@ -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
)
}

View File

@ -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))

View File

@ -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:"))

View File

@ -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()
}
}

View File

@ -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

View File

@ -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
)
}
}

View File

@ -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 {

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23A344" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -33,6 +33,9 @@
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
<attribute name="serverDefaultFederation" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="serverDefaultLanguage" optional="YES" attributeType="String"/>
<attribute name="serverDefaultVisibilityString" optional="YES" attributeType="String"/>
</entity>
<entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES">
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
@ -59,7 +62,9 @@
<attribute name="url" attributeType="URI"/>
</entity>
<entity name="List" representedClassName="ListMO" syncable="YES">
<attribute name="exclusiveInternal" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="replyPolicyString" optional="YES" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>

View File

@ -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
)
}

View File

@ -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)
}

View File

@ -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<UIOpenURLContext>) {
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)

View File

@ -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
}
}

View File

@ -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<Section, Item>!
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<Section, Item> {
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [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<Section, Item>()
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<Section, Item>()
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<Section, Item> {
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"
}
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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)
}()

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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<Section, Item>, 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

View File

@ -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)]
}
}

View File

@ -1,76 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="100" id="KGk-i7-Jjw" customClass="AccountTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="100"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="100"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Rp2-O5-Vew">
<rect key="frame" x="16" y="8" width="50" height="50"/>
<constraints>
<constraint firstAttribute="width" secondItem="Rp2-O5-Vew" secondAttribute="height" multiplier="1:1" id="1AQ-lU-ptd"/>
<constraint firstAttribute="height" constant="50" id="NqI-m0-owe"/>
</constraints>
</imageView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="Iif-9m-vM5">
<rect key="frame" x="74" y="11" width="230" height="78"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Fhc-bZ-lkB" customClass="AccountDisplayNameLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="230" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JMo-QH-1is">
<rect key="frame" x="0.0" y="20.5" width="230" height="18"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Note" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bNO-qR-YEe" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="38.5" width="230" height="39.5"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstAttribute="bottomMargin" secondItem="Iif-9m-vM5" secondAttribute="bottom" id="dV0-Vm-DUb"/>
<constraint firstItem="Iif-9m-vM5" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="ihr-er-kLO"/>
<constraint firstAttribute="trailingMargin" secondItem="Iif-9m-vM5" secondAttribute="trailing" id="q7a-DT-WPF"/>
<constraint firstItem="Iif-9m-vM5" firstAttribute="leading" secondItem="Rp2-O5-Vew" secondAttribute="trailing" constant="8" id="sk1-KY-Ttj"/>
<constraint firstItem="Rp2-O5-Vew" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="xpB-wY-5d6"/>
<constraint firstItem="Rp2-O5-Vew" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" id="yd4-AU-qbj"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="avatarImageView" destination="Rp2-O5-Vew" id="3Gw-Xg-bd5"/>
<outlet property="displayNameLabel" destination="Fhc-bZ-lkB" id="1b0-3k-KR8"/>
<outlet property="noteLabel" destination="bNO-qR-YEe" id="4oO-c0-BOT"/>
<outlet property="usernameLabel" destination="JMo-QH-1is" id="ElX-ua-xcQ"/>
</connections>
<point key="canvasLocation" x="173.91304347826087" y="35.491071428571423"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -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<String, Image>()
let group = DispatchGroup()

View File

@ -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

View File

@ -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