forked from shadowfacts/Tusker
Merge branch 'develop' into vision
This commit is contained in:
commit
1da25300ca
21
CHANGELOG.md
21
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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,16 +45,13 @@ struct LanguagePicker: View {
|
|||
}
|
||||
|
||||
private var codeFromPreferredLanguages: Locale.LanguageCode? {
|
||||
if let identifier = Locale.preferredLanguages.first {
|
||||
let code = Locale.LanguageCode(identifier)
|
||||
if code.isISOLanguage {
|
||||
if let identifier = Locale.preferredLanguages.first,
|
||||
case let code = Locale.LanguageCode(identifier),
|
||||
code.isISOLanguage {
|
||||
return code
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var languageCode: Binding<Locale.LanguageCode> {
|
||||
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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:"))
|
||||
|
|
|
@ -59,10 +59,15 @@ 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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AccountMO {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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,9 +102,54 @@ 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)
|
||||
})
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
|
||||
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)
|
||||
|
||||
clearSelectionOnAppear(animated: animated)
|
||||
|
||||
Task {
|
||||
await loadAccounts()
|
||||
|
@ -94,13 +157,48 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
}
|
||||
|
||||
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 {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.accounts])
|
||||
} else {
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -157,43 +296,47 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
enum Section: Hashable {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}()
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue