Compare commits

...

20 Commits

Author SHA1 Message Date
Shadowfacts e4eff2d362 Bump version and update changelog 2023-10-28 14:14:02 -05:00
Shadowfacts 37311e5f17 Fix potential crash due to race condition in timeline gap filling 2023-10-28 14:03:08 -05:00
Shadowfacts af5a0b7bbd Fix crash with large image dismiss gesture 2023-10-28 13:58:39 -05:00
Shadowfacts 3aa45cb365 Maybe fix crash due to reading ScaledMetric on background thread
ScaledMetric.wrappedValue calls into Font.scaleFactor(textStyle:in:)
which uses a dictionary setter
2023-10-28 13:56:25 -05:00
Shadowfacts a07b398cbe Maybe fix crash due to VC hierarchy consistency check failing on split collapse/expand 2023-10-28 13:52:54 -05:00
Shadowfacts 2ccec2f4df Fix crash if URLComponents.url is nil in instance selector 2023-10-28 13:47:44 -05:00
Shadowfacts 0de9a9fd37 Fix list timeline refresh failing if initial load returned no statuses 2023-10-28 13:36:11 -05:00
Shadowfacts bd21e88e8b Add UI for changing list reply policy and exclusivity
Closes #428
2023-10-28 12:16:14 -05:00
Shadowfacts 2464e2530f Remove dead code 2023-10-27 17:29:51 -05:00
Shadowfacts 44021d3ad2 Convert edit list screen to collection view 2023-10-27 17:29:51 -05:00
Shadowfacts a46eaafbcf Add reply policy and exclusive fields to lists 2023-10-27 17:00:53 -05:00
Shadowfacts eb496243c7 Use server preference for local-only on Hometown
Closes #281
2023-10-27 15:12:48 -05:00
Shadowfacts 6e5e0c3bb5 Use server preferences for default visibility and language
Closes #282
2023-10-27 14:59:21 -05:00
Shadowfacts dfc8234908 Attribute authenticated API requests to the user
Closes #134
2023-10-26 17:30:31 -05:00
Shadowfacts 157c8629a9 Add underline links preference
Closes #397
2023-10-24 16:02:03 -04:00
Shadowfacts bde21fbc6c Fix crash due to prematurely pruned statuses being fetched
If the app hasn't launched in long enough, we may be displaying old statuses as a result of state restoration. If the user leaves the app, those statuses can't get pruned, because the user may return. We need to make sure the lastFetchedAt date is current, since awakeFromFetch won't be called until the object is faulted in (which wasn't happening immediately during state restoration).
2023-10-24 15:50:58 -04:00
Shadowfacts 74820e8922 Underline links when button shapes accessibility setting is on 2023-10-24 15:50:58 -04:00
Shadowfacts f7a9075b77 Fix timeline jump button having background when button shapes accessibility setting is on 2023-10-24 15:50:58 -04:00
Shadowfacts 4af56e48bf Clean up TimelineLikeCollectionViewController.apply(_:animatingDifferences:) 2023-10-24 14:56:39 -04:00
Shadowfacts c4bf5d406d Fix older notifications not loading when initially visible set fits on one screen
Closes #346
2023-10-19 21:21:50 -04:00
39 changed files with 711 additions and 458 deletions

View File

@ -1,5 +1,20 @@
# Changelog # Changelog
## 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,37 @@
//
// Preferences.swift
// Pachyderm
//
// Created by Shadowfacts on 10/26/23.
//
import Foundation
public struct Preferences: Codable, Sendable {
public let postingDefaultVisibility: Visibility
public let postingDefaultSensitive: Bool
public let postingDefaultLanguage: String
// Whether posts federate or not (local-only) on Hometown
public let postingDefaultFederation: Bool?
public let readingExpandMedia: ExpandMedia
public let readingExpandSpoilers: Bool
public let readingAutoplayGifs: Bool
enum CodingKeys: String, CodingKey {
case postingDefaultVisibility = "posting:default:visibility"
case postingDefaultSensitive = "posting:default:sensitive"
case postingDefaultLanguage = "posting:default:language"
case postingDefaultFederation = "posting:default:federation"
case readingExpandMedia = "reading:expand:media"
case readingExpandSpoilers = "reading:expand:spoilers"
case readingAutoplayGifs = "reading:autoplay:gifs"
}
}
extension Preferences {
public enum ExpandMedia: String, Codable, Sendable {
case `default`
case always = "show_all"
case never = "hide_all"
}
}

View File

@ -10,4 +10,6 @@ import Foundation
public protocol ListProtocol { 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 }
} }

View File

@ -0,0 +1,97 @@
//
// PostVisibility.swift
// TuskerPreferences
//
// Created by Shadowfacts on 10/26/23.
//
import Foundation
import Pachyderm
public enum PostVisibility: Codable, Hashable, CaseIterable {
case serverDefault
case visibility(Visibility)
public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self {
case .serverDefault:
// If the server doesn't have a default visibility preference, we fallback to public.
// This isn't ideal, but I don't want to add a separate preference for "Default Post Visibility Fallback" :/
serverDefault ?? .public
case .visibility(let vis):
vis
}
}
public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility {
switch self {
case .serverDefault:
await serverDefault() ?? .public
case .visibility(let vis):
vis
}
}
public var displayName: String {
switch self {
case .serverDefault:
return "Account Default"
case .visibility(let vis):
return vis.displayName
}
}
public var imageName: String? {
switch self {
case .serverDefault:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}
public enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost
case visibility(Visibility)
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self {
case .sameAsPost:
Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault)
case .visibility(let vis):
vis
}
}
public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility {
switch self {
case .sameAsPost:
await Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault)
case .visibility(let vis):
vis
}
}
public var displayName: String {
switch self {
case .sameAsPost:
return "Same as Default"
case .visibility(let vis):
return vis.displayName
}
}
public var imageName: String? {
switch self {
case .sameAsPost:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}

View File

@ -61,8 +61,13 @@ public final class Preferences: Codable, ObservableObject {
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions self.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

View File

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

View File

@ -214,8 +214,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, ); }; };
@ -258,7 +256,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 */; };
@ -267,6 +264,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; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; 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 */; };
@ -618,8 +616,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>"; };
@ -661,7 +657,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>"; };
@ -671,6 +666,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>"; };
@ -1296,8 +1292,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";
@ -1427,7 +1421,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 */,
@ -1645,6 +1638,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 */,
@ -1861,7 +1855,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 */,
@ -1987,7 +1980,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 */,
@ -2151,6 +2143,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 */,
@ -2227,7 +2220,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 */,

View File

@ -0,0 +1,46 @@
//
// EditListSettingsService.swift
// Tusker
//
// Created by Shadowfacts on 10/28/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class EditListSettingsService {
private let list: ListProtocol
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
self.list = list
self.mastodonController = mastodonController
self.present = present
}
func run(title: String? = nil, replyPolicy: List.ReplyPolicy? = nil, exclusive: Bool? = nil) async {
do {
let req = List.update(
list.id,
title: title ?? list.title,
replyPolicy: replyPolicy ?? list.replyPolicy,
exclusive: exclusive ?? list.exclusive
)
let (list, _) = try await mastodonController.run(req)
mastodonController.updatedList(list)
} catch {
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
Task {
await self.run(title: title, replyPolicy: replyPolicy, exclusive: exclusive)
}
}))
present(alert)
}
}
}

View File

@ -193,6 +193,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()
@ -217,6 +219,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))")
} }
@ -358,6 +361,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 {
@ -434,16 +449,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
@ -460,7 +471,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
@ -519,8 +530,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,
@ -559,6 +574,7 @@ class MastodonController: ObservableObject {
contentWarning: contentWarning, contentWarning: contentWarning,
inReplyToID: inReplyToID, inReplyToID: inReplyToID,
visibility: visibility, visibility: visibility,
language: accountPreferences!.serverDefaultLanguage,
localOnly: localOnly localOnly: localOnly
) )
} }

View File

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

View File

@ -59,10 +59,15 @@ 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()
}
} }
extension AccountMO { extension AccountMO {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,9 +102,54 @@ 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)
})
]))
}
} }
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed)) private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(accountID: itemIdentifier)
cell.configurationUpdateHandler = { cell, state in
cell.backgroundConfiguration = .appListGroupedCell(for: state)
}
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
case .account(id: let id):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
Task { Task {
await loadAccounts() await loadAccounts()
@ -94,13 +157,48 @@ class EditListAccountsViewController: EnhancedTableViewController {
} }
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)
} }
} }
@ -157,43 +296,47 @@ class EditListAccountsViewController: EnhancedTableViewController {
} }
} }
// 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 {
enum Section: Hashable { enum Section: Hashable {
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?
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete,
case let .account(id) = itemIdentifier(for: indexPath) else {
return
} }
extension EditListAccountsViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if state == .loaded,
indexPath.item == collectionView.numberOfItems(inSection: indexPath.section) - 1 {
Task { Task {
await self.editListAccountsController?.removeAccount(id: id) await loadNextPage()
} }
} }
} }
@ -216,3 +359,29 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
} }
} }
} }
extension EditListAccountsViewController: UINavigationItemRenameDelegate {
func navigationItem(_: UINavigationItem, shouldEndRenamingWith title: String) -> Bool {
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) {
Task {
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
await service.run(title: title)
}
}
}
private extension List.ReplyPolicy {
var actionTitle: String {
switch self {
case .followed:
"To accounts you follow"
case .list:
"To other list members"
case .none:
"Never"
}
}
}

View File

@ -59,7 +59,7 @@ class ListTimelineViewController: TimelineViewController {
func presentEdit(animated: Bool) { 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)
} }

View File

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

View File

@ -539,6 +539,10 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 { if indexPath.row == itemsInSection - 1 {
Task { Task {
// Because of grouping, all cells from the first load may fit on screen,
// in which case, we try to load older while still in the loadingInitial state.
// So, wait for that to finish before trying to load more.
await controller.finishPendingOperation()
await controller.loadOlder() await controller.loadOlder()
} }
} }

View File

@ -150,7 +150,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])
@ -159,7 +160,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()

View File

@ -124,6 +124,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)

View File

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

View File

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

View File

@ -440,7 +440,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
} }
@ -718,10 +726,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()
@ -1157,7 +1166,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:
@ -1284,6 +1296,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 {

View File

@ -1,92 +0,0 @@
//
// EnhancedTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/10/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import SafariServices
class EnhancedTableViewController: UITableViewController {
var dragEnabled = false
override func viewDidLoad() {
super.viewDidLoad()
if dragEnabled {
tableView.dragDelegate = self
}
}
// MARK: Table View Delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell {
cell.didSelectCell()
}
}
}
extension EnhancedTableViewController {
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
if let cell = tableView.cellForRow(at: indexPath) as? UITableViewCell & MenuPreviewProvider {
let cellLocation = cell.convert(point, from: tableView)
guard let (previewProvider, actionsProvider) = cell.getPreviewProviders(for: cellLocation, sourceViewController: self) else {
return nil
}
let actionProvider: UIContextMenuActionProvider = { (_) in
let suggested = self.getSuggestedContextMenuActions(tableView: tableView, indexPath: indexPath, point: point)
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: suggested + actionsProvider())
}
return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider)
} else {
return nil
}
}
// todo: replace this with the UIKit suggested actions, if possible
@objc open func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
return []
}
override func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: self)
} else {
self.show(viewController, sender: nil)
}
}
}
}
}
extension EnhancedTableViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let cell = tableView.cellForRow(at: indexPath) as? DraggableTableViewCell else {
return []
}
return cell.dragItemsForBeginning(session: session)
}
}
extension EnhancedTableViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
tableView.scrollToTop()
}
}
extension EnhancedTableViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
tableView.scrollToTop()
return .stop
}
}

View File

@ -211,11 +211,9 @@ extension TimelineLikeCollectionViewController {
extension TimelineLikeCollectionViewController { 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
@MainActor
func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async { func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async {
let task = Task { @MainActor in await self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
await task.value
} }
@MainActor @MainActor

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import OSLog import OSLog
import Combine
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject { protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
associatedtype TimelineItem: Sendable associatedtype TimelineItem: Sendable
@ -42,7 +43,7 @@ class TimelineLikeController<Item: Sendable> {
private unowned var delegate: any TimelineLikeControllerDelegate<Item> private unowned var delegate: any TimelineLikeControllerDelegate<Item>
private let ownerType: String private let ownerType: String
private(set) var state = State.notLoadedInitial { @AsyncObservable private(set) var state = State.notLoadedInitial {
willSet { willSet {
guard state.canTransition(to: newValue) else { guard state.canTransition(to: newValue) else {
logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)") logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
@ -57,6 +58,19 @@ class TimelineLikeController<Item: Sendable> {
self.ownerType = ownerType self.ownerType = ownerType
} }
/// Waits for the controller to finish the current operation and arrive at the idle state.
///
/// If the current state is `notLoadedInitial`, this will wait until the controller
/// settles after the initial load.
func finishPendingOperation() async {
guard state != .idle else {
return
}
for await state in $state where state == .idle {
break
}
}
func loadInitial() async { func loadInitial() async {
guard state == .notLoadedInitial || state == .idle else { guard state == .notLoadedInitial || state == .idle else {
return return
@ -369,3 +383,17 @@ enum TimelineGapDirection {
} }
} }
} }
// I would love to be able to do this with @Observable, but it's not clear how to do so.
@propertyWrapper
private class AsyncObservable<Value>: ObservableObject {
@Published var wrappedValue: Value
var projectedValue: AsyncPublisher<Published<Value>.Publisher> {
$wrappedValue.values
}
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}

View File

@ -1,130 +0,0 @@
//
// AccountTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 9/5/19.
// Copyright © 2019 Shadowfacts. All rights reserved.
//
import UIKit
import SwiftSoup
class AccountTableViewCell: UITableViewCell {
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var noteLabel: EmojiLabel!
var accountID: String!
private var avatarRequest: ImageCache.Request?
private var isGrayscale = false
override func awakeFromNib() {
super.awakeFromNib()
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerCurve = .continuous
usernameLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light))
usernameLabel.adjustsFontForContentSizeCategory = true
noteLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15))
noteLabel.adjustsFontForContentSizeCategory = true
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPrefrences), name: .preferencesChanged, object: nil)
}
@objc func updateUIForPrefrences() {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
// this table view cell could be cached in a table view (e.g., SearchResultsViewController) for an account that's since been purged
return
}
displayNameLabel.updateForAccountDisplayName(account: account)
if isGrayscale != Preferences.shared.grayscaleImages {
updateGrayscaleableUI(account: account)
}
}
func updateUI(accountID: String) {
self.accountID = accountID
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
fatalError("Missing cached account \(accountID)")
}
usernameLabel.text = "@\(account.acct)"
updateGrayscaleableUI(account: account)
updateUIForPrefrences()
}
private func updateGrayscaleableUI(account: AccountMO) {
isGrayscale = Preferences.shared.grayscaleImages
let accountID = self.accountID
avatarImageView.image = nil
if let avatarURL = account.avatar {
avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in
guard let self = self else { return }
self.avatarRequest = nil
guard let image = image,
self.accountID == accountID,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else { return }
DispatchQueue.main.async {
self.avatarImageView.image = transformedImage
}
}
}
let doc = try! SwiftSoup.parse(account.note)
noteLabel.text = try! doc.text()
noteLabel.setEmojis(account.emojis, identifier: account.id)
}
override func prepareForReuse() {
super.prepareForReuse()
avatarRequest?.cancel()
}
}
extension AccountTableViewCell: SelectableTableViewCell {
func didSelectCell() {
delegate?.selected(account: accountID)
}
}
extension AccountTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController else { return nil }
return (
content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) },
actions: { self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [] }
)
}
}
extension AccountTableViewCell: DraggableTableViewCell {
func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] {
guard let account = mastodonController.persistentContainer.account(for: accountID),
let currentAccountID = mastodonController.accountInfo?.id else {
return []
}
let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}

View File

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

View File

@ -38,6 +38,7 @@ struct AccountDisplayNameView: View {
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange) 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()

View File

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

View File

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