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
|
# 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)
|
## 2023.8 (104)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Show search operators on Mastodon 4.2
|
- Show search operators on Mastodon 4.2
|
||||||
|
|
|
@ -111,7 +111,7 @@ class PostService: ObservableObject {
|
||||||
do {
|
do {
|
||||||
(data, utType) = try await getData(for: attachment)
|
(data, utType) = try await getData(for: attachment)
|
||||||
currentStep += 1
|
currentStep += 1
|
||||||
} catch let error as AttachmentData.Error {
|
} catch let error as DraftAttachment.ExportError {
|
||||||
throw Error.attachmentData(index: index, cause: error)
|
throw Error.attachmentData(index: index, cause: error)
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
@ -169,7 +169,7 @@ class PostService: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Error: Swift.Error, LocalizedError {
|
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 attachmentUpload(index: Int, cause: Client.Error)
|
||||||
case posting(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 ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
|
||||||
public typealias EmojiImageView = (Emoji) -> 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 config: ComposeUIConfig
|
||||||
@Published public var mastodonController: ComposeMastodonContext
|
@Published public var mastodonController: ComposeMastodonContext
|
||||||
let fetchAvatar: AvatarImageView.FetchAvatar
|
let fetchAvatar: AvatarImageView.FetchAvatar
|
||||||
|
@ -106,6 +110,7 @@ public final class ComposeController: ViewController {
|
||||||
emojiImageView: @escaping EmojiImageView
|
emojiImageView: @escaping EmojiImageView
|
||||||
) {
|
) {
|
||||||
self.draft = draft
|
self.draft = draft
|
||||||
|
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
|
||||||
self.config = config
|
self.config = config
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.fetchAvatar = fetchAvatar
|
self.fetchAvatar = fetchAvatar
|
||||||
|
|
|
@ -137,6 +137,8 @@ extension DraftAttachment {
|
||||||
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
||||||
|
|
||||||
private let imageType = UTType.image.identifier
|
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 jpegType = UTType.jpeg.identifier
|
||||||
private let pngType = UTType.png.identifier
|
private let pngType = UTType.png.identifier
|
||||||
private let mp4Type = UTType.mpeg4Movie.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?
|
// 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
|
// 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
|
// 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 {
|
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
|
||||||
|
@ -273,20 +275,13 @@ extension DraftAttachment {
|
||||||
var data = data
|
var data = data
|
||||||
var type = type
|
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 image = CIImage(data: data)!
|
||||||
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
||||||
|
|
||||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
// 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)
|
// 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 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 context = CIContext()
|
||||||
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
||||||
if type == .png {
|
if type == .png {
|
||||||
|
|
|
@ -81,6 +81,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
contentWarning: String,
|
contentWarning: String,
|
||||||
inReplyToID: String?,
|
inReplyToID: String?,
|
||||||
visibility: Visibility,
|
visibility: Visibility,
|
||||||
|
language: String?,
|
||||||
localOnly: Bool
|
localOnly: Bool
|
||||||
) -> Draft {
|
) -> Draft {
|
||||||
let draft = Draft(context: viewContext)
|
let draft = Draft(context: viewContext)
|
||||||
|
@ -92,6 +93,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
draft.contentWarningEnabled = !contentWarning.isEmpty
|
draft.contentWarningEnabled = !contentWarning.isEmpty
|
||||||
draft.inReplyToID = inReplyToID
|
draft.inReplyToID = inReplyToID
|
||||||
draft.visibility = visibility
|
draft.visibility = visibility
|
||||||
|
draft.language = language
|
||||||
draft.localOnly = localOnly
|
draft.localOnly = localOnly
|
||||||
save()
|
save()
|
||||||
return draft
|
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 == "-" {
|
if maybeIso639Code.last == "-" {
|
||||||
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
|
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 {
|
if code.isISOLanguage {
|
||||||
return code
|
return code
|
||||||
} else {
|
} else {
|
||||||
|
@ -39,13 +45,10 @@ struct LanguagePicker: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var codeFromPreferredLanguages: Locale.LanguageCode? {
|
private var codeFromPreferredLanguages: Locale.LanguageCode? {
|
||||||
if let identifier = Locale.preferredLanguages.first {
|
if let identifier = Locale.preferredLanguages.first,
|
||||||
let code = Locale.LanguageCode(identifier)
|
case let code = Locale.LanguageCode(identifier),
|
||||||
if code.isISOLanguage {
|
code.isISOLanguage {
|
||||||
return code
|
return code
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -66,6 +69,8 @@ struct LanguagePicker: View {
|
||||||
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
|
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Post Language")
|
.accessibilityLabel("Post Language")
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
.sheet(isPresented: $isShowingSheet) {
|
.sheet(isPresented: $isShowingSheet) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
|
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
|
||||||
|
@ -140,10 +145,12 @@ private struct LanguagePickerList: View {
|
||||||
// make sure recents always contains the currently selected lang
|
// make sure recents always contains the currently selected lang
|
||||||
let recents = addRecentLang(languageCode)
|
let recents = addRecentLang(languageCode)
|
||||||
recentLangs = recents
|
recentLangs = recents
|
||||||
|
.filter { $0 != "mul" && $0 != "und" }
|
||||||
.map { Lang(code: .init($0)) }
|
.map { Lang(code: .init($0)) }
|
||||||
.sorted { $0.name < $1.name }
|
.sorted { $0.name < $1.name }
|
||||||
|
|
||||||
langs = Locale.LanguageCode.isoLanguageCodes
|
langs = Locale.LanguageCode.isoLanguageCodes
|
||||||
|
.filter { $0.identifier != "mul" && $0.identifier != "und" }
|
||||||
.map { Lang(code: $0) }
|
.map { Lang(code: $0) }
|
||||||
.sorted { $0.name < $1.name }
|
.sorted { $0.name < $1.name }
|
||||||
}
|
}
|
||||||
|
|
|
@ -171,6 +171,18 @@ public class InstanceFeatures: ObservableObject {
|
||||||
hasMastodonVersion(4, 2, 0)
|
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() {
|
public init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Pachyderm",
|
name: "Pachyderm",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v14),
|
.iOS(.v15),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// 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
|
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? {
|
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
|
||||||
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
|
||||||
components.path = request.endpoint.path
|
components.path = request.endpoint.path
|
||||||
|
@ -122,6 +136,8 @@ public class Client {
|
||||||
}
|
}
|
||||||
if let accessToken = accessToken {
|
if let accessToken = accessToken {
|
||||||
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
// We consider authenticated requests to be user-initiated.
|
||||||
|
urlRequest.attribution = .user
|
||||||
}
|
}
|
||||||
return urlRequest
|
return urlRequest
|
||||||
}
|
}
|
||||||
|
@ -223,6 +239,10 @@ public class Client {
|
||||||
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
|
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
|
// MARK: - Accounts
|
||||||
public static func getAccount(id: String) -> Request<Account> {
|
public static func getAccount(id: String) -> Request<Account> {
|
||||||
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
|
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 struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String
|
public let title: String
|
||||||
|
public let replyPolicy: ReplyPolicy?
|
||||||
|
public let exclusive: Bool?
|
||||||
|
|
||||||
public var timeline: Timeline {
|
public var timeline: Timeline {
|
||||||
return .list(id: id)
|
return .list(id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(id: String, title: String) {
|
public init(id: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.replyPolicy = replyPolicy
|
||||||
|
self.exclusive = exclusive
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func ==(lhs: List, rhs: List) -> Bool {
|
public static func ==(lhs: List, rhs: List) -> Bool {
|
||||||
|
@ -36,8 +40,15 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func update(_ listID: String, title: String) -> Request<List> {
|
public static func update(_ listID: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) -> Request<List> {
|
||||||
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title]))
|
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> {
|
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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case title
|
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 {
|
public protocol ListProtocol {
|
||||||
var id: String { get }
|
var id: String { get }
|
||||||
var title: 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.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
||||||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||||
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
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.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
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(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
|
||||||
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
||||||
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
|
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
|
||||||
|
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
|
||||||
|
|
||||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
||||||
|
@ -175,9 +181,10 @@ public final class Preferences: Codable, ObservableObject {
|
||||||
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
||||||
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
||||||
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
|
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
|
||||||
|
@Published public var underlineTextLinks = false
|
||||||
|
|
||||||
// MARK: Composing
|
// MARK: Composing
|
||||||
@Published public var defaultPostVisibility = Visibility.public
|
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
||||||
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
||||||
@Published public var requireAttachmentDescriptions = false
|
@Published public var requireAttachmentDescriptions = false
|
||||||
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||||
|
@ -245,6 +252,7 @@ public final class Preferences: Codable, ObservableObject {
|
||||||
case leadingStatusSwipeActions
|
case leadingStatusSwipeActions
|
||||||
case trailingStatusSwipeActions
|
case trailingStatusSwipeActions
|
||||||
case widescreenNavigationMode
|
case widescreenNavigationMode
|
||||||
|
case underlineTextLinks
|
||||||
|
|
||||||
case defaultPostVisibility
|
case defaultPostVisibility
|
||||||
case defaultReplyVisibility
|
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 {
|
extension Preferences {
|
||||||
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||||
case useStatusSetting
|
case useStatusSetting
|
||||||
|
|
|
@ -12,6 +12,7 @@ import ComposeUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import TuskerPreferences
|
import TuskerPreferences
|
||||||
import Combine
|
import Combine
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
class ShareViewController: UIViewController {
|
class ShareViewController: UIViewController {
|
||||||
|
|
||||||
|
@ -50,21 +51,26 @@ class ShareViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDraft(account: UserAccountInfo) async -> Draft {
|
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(
|
let draft = DraftsPersistentContainer.shared.createDraft(
|
||||||
accountID: account.id,
|
accountID: account.id,
|
||||||
text: text,
|
text: await text,
|
||||||
contentWarning: "",
|
contentWarning: "",
|
||||||
inReplyToID: nil,
|
inReplyToID: nil,
|
||||||
visibility: Preferences.shared.defaultPostVisibility,
|
visibility: visibility,
|
||||||
localOnly: false
|
language: serverPrefs?.postingDefaultLanguage,
|
||||||
|
localOnly: !(serverPrefs?.postingDefaultFederation ?? true)
|
||||||
)
|
)
|
||||||
|
|
||||||
for attachment in attachments {
|
for attachment in await attachments {
|
||||||
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||||
}
|
}
|
||||||
draft.draftAttachments = attachments
|
draft.draftAttachments = await attachments
|
||||||
|
|
||||||
return draft
|
return draft
|
||||||
}
|
}
|
||||||
|
|
|
@ -212,8 +212,6 @@
|
||||||
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
|
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
|
||||||
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
|
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
|
||||||
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.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 */; };
|
D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; };
|
||||||
D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6A4531729EF64BA00032932 /* MainInterface.storyboard */; };
|
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, ); }; };
|
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 */; };
|
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; };
|
||||||
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.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 */; };
|
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 */; };
|
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; };
|
||||||
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; };
|
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; };
|
||||||
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.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 */; };
|
D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; };
|
||||||
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
|
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 */; };
|
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 */; };
|
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
|
||||||
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
|
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
|
||||||
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1291,8 +1287,6 @@
|
||||||
D6A3BC872321F78000FD64D5 /* Account Cell */ = {
|
D6A3BC872321F78000FD64D5 /* Account Cell */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */,
|
|
||||||
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */,
|
|
||||||
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */,
|
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */,
|
||||||
);
|
);
|
||||||
path = "Account Cell";
|
path = "Account Cell";
|
||||||
|
@ -1421,7 +1415,6 @@
|
||||||
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
|
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
|
||||||
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */,
|
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */,
|
||||||
D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */,
|
D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */,
|
||||||
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
|
|
||||||
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
|
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
|
||||||
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
|
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
|
||||||
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
|
D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
|
||||||
|
@ -1639,6 +1632,7 @@
|
||||||
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
|
D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
|
||||||
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
|
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
|
||||||
D6F6A551291F098700F496A8 /* RenameListService.swift */,
|
D6F6A551291F098700F496A8 /* RenameListService.swift */,
|
||||||
|
D6C041C32AED77730094D32D /* EditListSettingsService.swift */,
|
||||||
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
|
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
|
||||||
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
|
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
|
||||||
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
|
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
|
||||||
|
@ -1855,7 +1849,6 @@
|
||||||
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
|
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
|
||||||
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
|
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
|
||||||
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
|
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
|
||||||
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
|
|
||||||
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
|
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
|
||||||
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
|
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
|
||||||
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
|
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
|
||||||
|
@ -1980,7 +1973,6 @@
|
||||||
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
|
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
|
||||||
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
|
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
|
||||||
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
|
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
|
||||||
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
|
|
||||||
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
|
||||||
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */,
|
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */,
|
||||||
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
||||||
|
@ -2144,6 +2136,7 @@
|
||||||
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */,
|
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */,
|
||||||
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
|
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
|
||||||
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
|
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
|
||||||
|
D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */,
|
||||||
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */,
|
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */,
|
||||||
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */,
|
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */,
|
||||||
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */,
|
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */,
|
||||||
|
@ -2219,7 +2212,6 @@
|
||||||
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
|
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
|
||||||
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
|
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
|
||||||
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
|
||||||
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
|
|
||||||
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */,
|
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */,
|
||||||
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
|
||||||
D6D4DDD0212518A000E1C4BB /* AppDelegate.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
|
@MainActor
|
||||||
func initialize() {
|
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)
|
// we want this to happen immediately, and synchronously so that the filters (which don't change that often)
|
||||||
// are available when Filterers are constructed
|
// are available when Filterers are constructed
|
||||||
loadCachedFilters()
|
loadCachedFilters()
|
||||||
|
@ -221,6 +223,7 @@ class MastodonController: ObservableObject {
|
||||||
|
|
||||||
loadLists()
|
loadLists()
|
||||||
_ = await loadFilters()
|
_ = await loadFilters()
|
||||||
|
await loadServerPreferences()
|
||||||
} catch {
|
} catch {
|
||||||
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
|
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) {
|
private func updateActiveInstance(from instance: Instance) {
|
||||||
persistentContainer.performBackgroundTask { context in
|
persistentContainer.performBackgroundTask { context in
|
||||||
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
|
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 {
|
guard let lists = try? persistentContainer.viewContext.fetch(req) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return lists.map {
|
return lists.map(\.apiList).sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||||
List(id: $0.id, title: $0.title)
|
|
||||||
}.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCachedList(id: String) -> List? {
|
func getCachedList(id: String) -> List? {
|
||||||
let req = ListMO.fetchRequest(id: id)
|
let req = ListMO.fetchRequest(id: id)
|
||||||
return (try? persistentContainer.viewContext.fetch(req).first).flatMap {
|
return (try? persistentContainer.viewContext.fetch(req).first).map(\.apiList)
|
||||||
List(id: $0.id, title: $0.title)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
@ -464,7 +475,7 @@ class MastodonController: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func renamedList(_ list: List) {
|
func updatedList(_ list: List) {
|
||||||
var new = self.lists
|
var new = self.lists
|
||||||
if let index = new.firstIndex(where: { $0.id == list.id }) {
|
if let index = new.firstIndex(where: { $0.id == list.id }) {
|
||||||
new[index] = list
|
new[index] = list
|
||||||
|
@ -523,8 +534,12 @@ class MastodonController: ObservableObject {
|
||||||
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
|
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
|
||||||
var acctsToMention = [String]()
|
var acctsToMention = [String]()
|
||||||
|
|
||||||
var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
|
var visibility = if inReplyToID != nil {
|
||||||
var localOnly = false
|
Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
|
||||||
|
} else {
|
||||||
|
Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
|
||||||
|
}
|
||||||
|
var localOnly = instanceFeatures.localOnlyPosts && !accountPreferences!.serverDefaultFederation
|
||||||
var contentWarning = ""
|
var contentWarning = ""
|
||||||
|
|
||||||
if let inReplyToID = inReplyToID,
|
if let inReplyToID = inReplyToID,
|
||||||
|
@ -563,6 +578,7 @@ class MastodonController: ObservableObject {
|
||||||
contentWarning: contentWarning,
|
contentWarning: contentWarning,
|
||||||
inReplyToID: inReplyToID,
|
inReplyToID: inReplyToID,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
|
language: accountPreferences!.serverDefaultLanguage,
|
||||||
localOnly: localOnly
|
localOnly: localOnly
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,9 +47,9 @@ class RenameListService {
|
||||||
|
|
||||||
private func updateList(with title: String) async {
|
private func updateList(with title: String) async {
|
||||||
do {
|
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)
|
let (list, _) = try await mastodonController.run(req)
|
||||||
mastodonController.renamedList(list)
|
mastodonController.updatedList(list)
|
||||||
} catch {
|
} catch {
|
||||||
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
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)
|
#if !os(visionOS)
|
||||||
private func swizzleStatusBar() {
|
private func swizzleStatusBar() {
|
||||||
let selector = Selector(("handleTapAction:"))
|
let selector = Selector(("handleTapAction:"))
|
||||||
|
|
|
@ -59,9 +59,14 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
|
||||||
super.awakeFromFetch()
|
super.awakeFromFetch()
|
||||||
|
|
||||||
managedObjectContext?.perform {
|
managedObjectContext?.perform {
|
||||||
self.lastFetchedAt = Date()
|
self.touch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the `lastFetchedAt` date so this object isn't pruned early.
|
||||||
|
func touch() {
|
||||||
|
lastFetchedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,10 +24,22 @@ public final class AccountPreferences: NSManagedObject {
|
||||||
@NSManaged public var accountID: String
|
@NSManaged public var accountID: String
|
||||||
@NSManaged var createdAt: Date
|
@NSManaged var createdAt: Date
|
||||||
@NSManaged var pinnedTimelinesData: Data?
|
@NSManaged var pinnedTimelinesData: Data?
|
||||||
|
@NSManaged var serverDefaultFederation: Bool
|
||||||
|
@NSManaged var serverDefaultLanguage: String?
|
||||||
|
@NSManaged private var serverDefaultVisibilityString: String?
|
||||||
|
|
||||||
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines)
|
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines)
|
||||||
var pinnedTimelines: [PinnedTimeline]
|
var pinnedTimelines: [PinnedTimeline]
|
||||||
|
|
||||||
|
var serverDefaultVisibility: Visibility? {
|
||||||
|
get {
|
||||||
|
serverDefaultVisibilityString.flatMap(Visibility.init(rawValue:))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
serverDefaultVisibilityString = newValue?.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
|
static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
|
||||||
let prefs = AccountPreferences(context: context)
|
let prefs = AccountPreferences(context: context)
|
||||||
prefs.accountID = account.id
|
prefs.accountID = account.id
|
||||||
|
|
|
@ -25,6 +25,22 @@ public final class ListMO: NSManagedObject, ListProtocol {
|
||||||
|
|
||||||
@NSManaged public var id: String
|
@NSManaged public var id: String
|
||||||
@NSManaged public var title: 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) {
|
func updateFrom(apiList list: List) {
|
||||||
self.id = list.id
|
self.id = list.id
|
||||||
self.title = list.title
|
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()
|
super.awakeFromFetch()
|
||||||
|
|
||||||
managedObjectContext?.perform {
|
managedObjectContext?.perform {
|
||||||
self.lastFetchedAt = Date()
|
self.touch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the `lastFetchedAt` date so this object isn't pruned early.
|
||||||
|
func touch() {
|
||||||
|
lastFetchedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StatusMO {
|
extension StatusMO {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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">
|
<entity name="Account" representedClassName="AccountMO" syncable="YES">
|
||||||
<attribute name="acct" attributeType="String"/>
|
<attribute name="acct" attributeType="String"/>
|
||||||
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
@ -33,6 +33,9 @@
|
||||||
<attribute name="accountID" optional="YES" attributeType="String"/>
|
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
|
<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>
|
||||||
<entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES">
|
<entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES">
|
||||||
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
|
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
|
||||||
|
@ -59,7 +62,9 @@
|
||||||
<attribute name="url" attributeType="URI"/>
|
<attribute name="url" attributeType="URI"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="List" representedClassName="ListMO" syncable="YES">
|
<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="id" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="replyPolicyString" optional="YES" attributeType="String"/>
|
||||||
<attribute name="title" optional="YES" attributeType="String"/>
|
<attribute name="title" optional="YES" attributeType="String"/>
|
||||||
<uniquenessConstraints>
|
<uniquenessConstraints>
|
||||||
<uniquenessConstraint>
|
<uniquenessConstraint>
|
||||||
|
|
|
@ -41,22 +41,25 @@ struct MenuController {
|
||||||
static let prevSubTabCommand = UIKeyCommand(title: "Previous Sub Tab", action: #selector(TabbedPageViewController.selectPrevPage), input: "[", modifierFlags: [.command, .shift])
|
static let prevSubTabCommand = UIKeyCommand(title: "Previous Sub Tab", action: #selector(TabbedPageViewController.selectPrevPage), input: "[", modifierFlags: [.command, .shift])
|
||||||
|
|
||||||
static func buildMainMenu(builder: UIMenuBuilder) {
|
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(buildSubTabMenu(), atStartOfMenu: .view)
|
||||||
builder.insertChild(buildSidebarShortcuts(), 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(
|
return UIMenu(
|
||||||
title: "File",
|
title: "File",
|
||||||
image: nil,
|
image: nil,
|
||||||
identifier: nil,
|
identifier: nil,
|
||||||
options: [],
|
options: [],
|
||||||
children: [
|
children: children
|
||||||
composeCommand,
|
|
||||||
refreshCommand(discoverabilityTitle: nil),
|
|
||||||
UIKeyCommand(title: "Close", action: #selector(AppDelegate.closeWindow), input: "w", modifierFlags: .command),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -112,7 +112,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
|
||||||
case .list(id: let id):
|
case .list(id: let id):
|
||||||
let req = ListMO.fetchRequest(id: id)
|
let req = ListMO.fetchRequest(id: id)
|
||||||
if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first {
|
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 {
|
} else {
|
||||||
return TimelineViewController(for: timeline, mastodonController: mastodonController)
|
return TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
window = UIWindow(windowScene: windowScene)
|
window = UIWindow(windowScene: windowScene)
|
||||||
|
|
||||||
showAppOrOnboardingUI(session: session)
|
showAppOrOnboardingUI(session: session)
|
||||||
if connectionOptions.urlContexts.count > 0 {
|
if !connectionOptions.urlContexts.isEmpty {
|
||||||
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
|
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,14 +52,21 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||||
if URLContexts.count > 1 {
|
guard let url = URLContexts.first?.url,
|
||||||
fatalError("Cannot open more than 1 URL")
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
let rootViewController else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = URLContexts.first!.url
|
if components.host == "compose" {
|
||||||
|
if let mastodonController = window!.windowScene!.session.mastodonController {
|
||||||
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
let draft = mastodonController.createDraft()
|
||||||
let rootViewController = rootViewController {
|
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"
|
components.scheme = "https"
|
||||||
let query = components.string!
|
let query = components.string!
|
||||||
rootViewController.performSearch(query: query)
|
rootViewController.performSearch(query: query)
|
||||||
|
|
|
@ -25,7 +25,14 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleGesture(_ recognizer: UIPanGestureRecognizer) {
|
@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
|
var progress = translation.y / 200
|
||||||
if let direction = direction {
|
if let direction = direction {
|
||||||
progress *= direction
|
progress *= direction
|
||||||
|
@ -63,7 +70,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
|
||||||
|
|
||||||
override func cancel() {
|
override func cancel() {
|
||||||
super.cancel()
|
super.cancel()
|
||||||
viewController.isInteractivelyAnimatingDismissal = false
|
viewController?.isInteractivelyAnimatingDismissal = false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,18 +10,21 @@ import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
class EditListAccountsViewController: EnhancedTableViewController {
|
class EditListAccountsViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
private var list: List
|
private var list: List
|
||||||
let mastodonController: MastodonController
|
private let mastodonController: MastodonController
|
||||||
|
|
||||||
var changedAccounts = false
|
private var state = State.unloaded
|
||||||
|
|
||||||
var dataSource: DataSource!
|
private(set) var changedAccounts = false
|
||||||
var nextRange: RequestRange?
|
|
||||||
|
|
||||||
var searchResultsController: SearchResultsViewController!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
var searchController: UISearchController!
|
var collectionView: UICollectionView! { view as? UICollectionView }
|
||||||
|
private var nextRange: RequestRange?
|
||||||
|
|
||||||
|
private var searchResultsController: SearchResultsViewController!
|
||||||
|
private var searchController: UISearchController!
|
||||||
|
|
||||||
private var listRenamedCancellable: AnyCancellable?
|
private var listRenamedCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
@ -29,13 +32,12 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
self.list = list
|
self.list = list
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
|
||||||
super.init(style: .plain)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
listChanged()
|
listChanged()
|
||||||
|
|
||||||
listRenamedCancellable = mastodonController.$lists
|
listRenamedCancellable = mastodonController.$lists
|
||||||
.compactMap { $0.first { $0.id == list.id } }
|
.compactMap { $0.first { $0.id == list.id } }
|
||||||
.removeDuplicates(by: { $0.title == $1.title })
|
|
||||||
.sink { [unowned self] in
|
.sink { [unowned self] in
|
||||||
self.list = $0
|
self.list = $0
|
||||||
self.listChanged()
|
self.listChanged()
|
||||||
|
@ -46,29 +48,45 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
fatalError("init(coder:) has not been implemeneted")
|
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() {
|
override func viewDidLoad() {
|
||||||
super.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 = SearchResultsViewController(mastodonController: mastodonController, scope: .people)
|
||||||
searchResultsController.following = true
|
searchResultsController.following = true
|
||||||
searchResultsController.delegate = self
|
searchResultsController.delegate = self
|
||||||
|
@ -84,23 +102,103 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
navigationItem.hidesSearchBarWhenScrolling = false
|
navigationItem.hidesSearchBarWhenScrolling = false
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
navigationItem.preferredSearchBarPlacement = .stacked
|
navigationItem.preferredSearchBarPlacement = .stacked
|
||||||
|
|
||||||
|
navigationItem.renameDelegate = self
|
||||||
|
navigationItem.titleMenuProvider = { [unowned self] suggested in
|
||||||
|
var children = suggested
|
||||||
|
children.append(contentsOf: self.listSettingsMenuElements())
|
||||||
|
return UIMenu(children: children)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [
|
||||||
|
// uncached so that menu always reflects the current state of the list
|
||||||
|
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
|
||||||
|
var elements = self.listSettingsMenuElements()
|
||||||
|
elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in
|
||||||
|
RenameListService(list: self.list, mastodonController: self.mastodonController, present: {
|
||||||
|
self.present($0, animated: true)
|
||||||
|
}).run()
|
||||||
|
}), at: 0)
|
||||||
|
elementHandler(elements)
|
||||||
|
})
|
||||||
|
]))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
|
||||||
|
cell.indicator.startAnimating()
|
||||||
|
}
|
||||||
|
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, itemIdentifier in
|
||||||
|
cell.delegate = self
|
||||||
|
cell.updateUI(accountID: itemIdentifier)
|
||||||
|
cell.configurationUpdateHandler = { cell, state in
|
||||||
|
cell.backgroundConfiguration = .appListGroupedCell(for: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||||
|
switch itemIdentifier {
|
||||||
|
case .loadingIndicator:
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
|
||||||
|
case .account(id: let id):
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
|
clearSelectionOnAppear(animated: animated)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await loadAccounts()
|
await loadAccounts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func listChanged() {
|
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 {
|
do {
|
||||||
let request = List.getAccounts(list.id)
|
let (accounts, pagination) = try await results
|
||||||
let (accounts, pagination) = try await mastodonController.run(request)
|
|
||||||
self.nextRange = pagination?.older
|
self.nextRange = pagination?.older
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
|
@ -109,20 +207,61 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
if snapshot.indexOfSection(.accounts) == nil {
|
snapshot.appendSections([.accounts])
|
||||||
snapshot.appendSections([.accounts])
|
|
||||||
} else {
|
|
||||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
|
|
||||||
}
|
|
||||||
snapshot.appendItems(accounts.map { .account(id: $0.id) })
|
snapshot.appendItems(accounts.map { .account(id: $0.id) })
|
||||||
await dataSource.apply(snapshot)
|
await dataSource.apply(snapshot)
|
||||||
|
|
||||||
|
state = .loaded
|
||||||
} catch {
|
} catch {
|
||||||
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
|
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
await self.loadAccounts()
|
await self.loadAccounts()
|
||||||
}
|
}
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
|
state = .unloaded
|
||||||
|
await dataSource.apply(.init())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadNextPage() async {
|
||||||
|
guard state == .loaded,
|
||||||
|
let nextRange else { return }
|
||||||
|
|
||||||
|
state = .loading
|
||||||
|
|
||||||
|
async let results = try await mastodonController.run(List.getAccounts(list.id, range: nextRange))
|
||||||
|
|
||||||
|
let origSnapshot = dataSource.snapshot()
|
||||||
|
var snapshot = origSnapshot
|
||||||
|
snapshot.appendItems([.loadingIndicator])
|
||||||
|
await dataSource.apply(snapshot)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (accounts, pagination) = try await results
|
||||||
|
self.nextRange = pagination?.older
|
||||||
|
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addAll(accounts: accounts) {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = origSnapshot
|
||||||
|
snapshot.appendItems(accounts.map { .account(id: $0.id) })
|
||||||
|
await dataSource.apply(snapshot)
|
||||||
|
|
||||||
|
state = .loaded
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.loadNextPage()
|
||||||
|
}
|
||||||
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
|
state = .loaded
|
||||||
|
await dataSource.apply(origSnapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,19 +295,30 @@ class EditListAccountsViewController: EnhancedTableViewController {
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Table view delegate
|
private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) {
|
||||||
|
Task {
|
||||||
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
|
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
|
||||||
return .delete
|
await service.run(replyPolicy: replyPolicy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interaction
|
private func setExclusive(_ exclusive: Bool) {
|
||||||
|
Task {
|
||||||
@objc func renameButtonPressed() {
|
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
|
||||||
RenameListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }).run()
|
await service.run(exclusive: exclusive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EditListAccountsViewController {
|
||||||
|
enum State {
|
||||||
|
case unloaded
|
||||||
|
case loading
|
||||||
|
case loaded
|
||||||
|
case loadingOlder
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EditListAccountsViewController {
|
extension EditListAccountsViewController {
|
||||||
|
@ -176,24 +326,17 @@ extension EditListAccountsViewController {
|
||||||
case accounts
|
case accounts
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
|
case loadingIndicator
|
||||||
case account(id: String)
|
case account(id: String)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
class DataSource: UITableViewDiffableDataSource<Section, Item> {
|
|
||||||
weak var editListAccountsController: EditListAccountsViewController?
|
extension EditListAccountsViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||||
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
if state == .loaded,
|
||||||
return true
|
indexPath.item == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
|
||||||
}
|
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
|
||||||
guard editingStyle == .delete,
|
|
||||||
case let .account(id) = itemIdentifier(for: indexPath) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
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) {
|
func presentEdit(animated: Bool) {
|
||||||
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
|
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)
|
let navController = UINavigationController(rootViewController: editListAccountsController)
|
||||||
present(navController, animated: animated)
|
present(navController, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
|
@ -214,6 +214,10 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
if item == sidebar.selectedItem {
|
if item == sidebar.selectedItem {
|
||||||
itemNavStack = secondaryNavController.viewControllers
|
itemNavStack = secondaryNavController.viewControllers
|
||||||
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 {
|
} else {
|
||||||
itemNavStack = navigationStacks[item] ?? []
|
itemNavStack = navigationStacks[item] ?? []
|
||||||
navigationStacks.removeValue(forKey: item)
|
navigationStacks.removeValue(forKey: item)
|
||||||
|
@ -339,6 +343,11 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst)
|
let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst)
|
||||||
navController.viewControllers.removeLast(navController.viewControllers.count - 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 {
|
if let prepend = prepend {
|
||||||
navigationStacks[item] = [prepend] + viewControllersToMove
|
navigationStacks[item] = [prepend] + viewControllersToMove
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -152,7 +152,8 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
private func updateSpecificInstance(domain: String) {
|
private func updateSpecificInstance(domain: String) {
|
||||||
activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
|
|
||||||
guard let components = parseURLComponents(input: domain) else {
|
guard let components = parseURLComponents(input: domain),
|
||||||
|
let url = components.url else {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
if snapshot.indexOfSection(.selected) != nil {
|
if snapshot.indexOfSection(.selected) != nil {
|
||||||
snapshot.deleteSections([.selected])
|
snapshot.deleteSections([.selected])
|
||||||
|
@ -161,7 +162,6 @@ class InstanceSelectorTableViewController: UITableViewController {
|
||||||
activityIndicator.stopAnimating()
|
activityIndicator.stopAnimating()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let url = components.url!
|
|
||||||
|
|
||||||
let client = Client(baseURL: url, session: .appDefault)
|
let client = Client(baseURL: url, session: .appDefault)
|
||||||
let request = Client.getInstance()
|
let request = Client.getInstance()
|
||||||
|
|
|
@ -127,6 +127,9 @@ struct AppearancePrefsView : View {
|
||||||
Toggle(isOn: $preferences.showLinkPreviews) {
|
Toggle(isOn: $preferences.showLinkPreviews) {
|
||||||
Text("Show Link Previews")
|
Text("Show Link Previews")
|
||||||
}
|
}
|
||||||
|
Toggle(isOn: $preferences.underlineTextLinks) {
|
||||||
|
Text("Underline Links")
|
||||||
|
}
|
||||||
NavigationLink("Leading Swipe Actions") {
|
NavigationLink("Leading Swipe Actions") {
|
||||||
SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions)
|
SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions)
|
||||||
.edgesIgnoringSafeArea(.all)
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
|
|
@ -28,9 +28,11 @@ struct ComposingPrefsView: View {
|
||||||
var visibilitySection: some View {
|
var visibilitySection: some View {
|
||||||
Section {
|
Section {
|
||||||
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) {
|
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) {
|
||||||
ForEach(Visibility.allCases, id: \.self) { visibility in
|
ForEach(PostVisibility.allCases, id: \.self) { visibility in
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: visibility.imageName)
|
if let imageName = visibility.imageName {
|
||||||
|
Image(systemName: imageName)
|
||||||
|
}
|
||||||
Text(visibility.displayName)
|
Text(visibility.displayName)
|
||||||
}
|
}
|
||||||
.tag(visibility)
|
.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
|
// 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")) {
|
Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) {
|
||||||
ForEach(Preferences.ReplyVisibility.allCases, id: \.self) { visibility in
|
ForEach(ReplyVisibility.allCases, id: \.self) { visibility in
|
||||||
HStack {
|
HStack {
|
||||||
if let imageName = visibility.imageName {
|
if let imageName = visibility.imageName {
|
||||||
Image(systemName: imageName)
|
Image(systemName: imageName)
|
||||||
|
|
|
@ -20,6 +20,8 @@ class TimelineJumpButton: UIView {
|
||||||
var config = UIButton.Configuration.plain()
|
var config = UIButton.Configuration.plain()
|
||||||
config.image = UIImage(systemName: "arrow.up")
|
config.image = UIImage(systemName: "arrow.up")
|
||||||
config.contentInsets = .zero
|
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)
|
return UIButton(configuration: config)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -446,7 +446,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
|
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
|
||||||
let originalPositionStatusIDs = position.statusIDs
|
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 {
|
guard !unloaded.isEmpty else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -580,6 +588,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
var count = 0
|
var count = 0
|
||||||
while count < 5 {
|
while count < 5 {
|
||||||
count += 1
|
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
|
let origOffset = self.collectionView.contentOffset
|
||||||
self.collectionView.layoutIfNeeded()
|
self.collectionView.layoutIfNeeded()
|
||||||
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
|
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
|
||||||
|
@ -734,10 +747,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
@objc func refresh() {
|
@objc func refresh() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
if case .notLoadedInitial = controller.state {
|
if case .idle = controller.state,
|
||||||
await controller.loadInitial()
|
!dataSource.snapshot().itemIdentifiers(inSection: .statuses).isEmpty {
|
||||||
} else {
|
|
||||||
await controller.loadNewer()
|
await controller.loadNewer()
|
||||||
|
} else {
|
||||||
|
await controller.loadInitial()
|
||||||
}
|
}
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
collectionView.refreshControl?.endRefreshing()
|
collectionView.refreshControl?.endRefreshing()
|
||||||
|
@ -1173,7 +1187,10 @@ extension TimelineViewController {
|
||||||
let addedItems: Bool
|
let addedItems: Bool
|
||||||
|
|
||||||
let statusItems = snapshot.itemIdentifiers(inSection: .statuses)
|
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 {
|
switch direction {
|
||||||
case .above:
|
case .above:
|
||||||
|
@ -1300,6 +1317,9 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
selected(status: status.reblog?.id ?? id, state: collapseState.copy())
|
selected(status: status.reblog?.id ?? id, state: collapseState.copy())
|
||||||
}
|
}
|
||||||
case .gap:
|
case .gap:
|
||||||
|
guard controller.state == .idle else {
|
||||||
|
return
|
||||||
|
}
|
||||||
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
||||||
cell.showsIndicator = true
|
cell.showsIndicator = true
|
||||||
Task {
|
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
|
// 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
|
// 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 {
|
func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async {
|
||||||
let task = Task { @MainActor in
|
await MainActor.run {
|
||||||
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
|
dataSource?.apply(snapshot, animatingDifferences: animatingDifferences)
|
||||||
}
|
}
|
||||||
await task.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@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)
|
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
|
||||||
guard !matches.isEmpty else { return }
|
guard !matches.isEmpty else { return }
|
||||||
|
|
||||||
|
let emojiSize = self.emojiSize
|
||||||
let emojiImages = MultiThreadDictionary<String, Image>()
|
let emojiImages = MultiThreadDictionary<String, Image>()
|
||||||
|
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
|
|
|
@ -12,6 +12,7 @@ import Pachyderm
|
||||||
import SafariServices
|
import SafariServices
|
||||||
import WebURL
|
import WebURL
|
||||||
import WebURLFoundationExtras
|
import WebURLFoundationExtras
|
||||||
|
import Combine
|
||||||
|
|
||||||
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
|
||||||
private let dataDetectorsScheme = "x-apple-data-detectors"
|
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.
|
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
|
||||||
private weak var currentTargetedPreview: UITargetedPreview?
|
private weak var currentTargetedPreview: UITargetedPreview?
|
||||||
|
|
||||||
|
private var underlineTextLinksCancellable: AnyCancellable?
|
||||||
|
|
||||||
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
||||||
super.init(frame: frame, textContainer: textContainer)
|
super.init(frame: frame, textContainer: textContainer)
|
||||||
commonInit()
|
commonInit()
|
||||||
|
@ -78,10 +81,30 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
linkTextAttributes = [
|
linkTextAttributes = [
|
||||||
.foregroundColor: UIColor.tintColor
|
.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
|
// 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(_:)))
|
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
|
||||||
addGestureRecognizer(recognizer)
|
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
|
// MARK: - Emojis
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
// https://help.apple.com/xcode/#/dev745c5c974
|
// https://help.apple.com/xcode/#/dev745c5c974
|
||||||
|
|
||||||
MARKETING_VERSION = 2023.8
|
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 = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
||||||
|
|
Loading…
Reference in New Issue