Compare commits

..

No commits in common. "16cd045588b6b67eb8c59338fdb489da6150ec4e" and "fa31c28e92a3823d2d93cdcda06f8febfeeb6624" have entirely different histories.

21 changed files with 174 additions and 380 deletions

View File

@ -219,7 +219,6 @@
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; }; D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; };
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 */; };
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; }; D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; };
D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; }; D6A3BC7D232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */; };
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */; }; D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */; };
@ -603,7 +602,6 @@
D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = "<group>"; }; D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = "<group>"; };
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>"; };
D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupTableViewCell.swift; sourceTree = "<group>"; }; D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupTableViewCell.swift; sourceTree = "<group>"; };
D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActionNotificationGroupTableViewCell.xib; sourceTree = "<group>"; }; D6A3BC7B232195C600FD64D5 /* ActionNotificationGroupTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActionNotificationGroupTableViewCell.xib; sourceTree = "<group>"; };
D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationGroupTableViewCell.swift; sourceTree = "<group>"; }; D6A3BC7E2321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowNotificationGroupTableViewCell.swift; sourceTree = "<group>"; };
@ -918,7 +916,6 @@
D61F759A29384F9C00C0B37F /* FilterMO.swift */, D61F759A29384F9C00C0B37F /* FilterMO.swift */,
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */, D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
D6D706A62948D4D0000827ED /* TimlineState.swift */, D6D706A62948D4D0000827ED /* TimlineState.swift */,
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */,
D68A76E229524D2A001DA1B3 /* ListMO.swift */, D68A76E229524D2A001DA1B3 /* ListMO.swift */,
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */, D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
@ -1987,7 +1984,6 @@
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */, D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */, D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */, D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */, D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */, D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */, D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,

View File

@ -18,8 +18,6 @@ fileprivate let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, catego
class MastodonCachePersistentStore: NSPersistentCloudKitContainer { class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
private let accountInfo: LocalData.UserAccountInfo?
private static let managedObjectModel: NSManagedObjectModel = { private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")! let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)! return NSManagedObjectModel(contentsOf: url)!
@ -52,8 +50,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
let relationshipSubject = PassthroughSubject<String, Never>() let relationshipSubject = PassthroughSubject<String, Never>()
init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) { init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) {
self.accountInfo = accountInfo
let group = DispatchGroup() let group = DispatchGroup()
var instancesToMigrate: [URL]? = nil var instancesToMigrate: [URL]? = nil
var hashtagsToMigrate: [Hashtag]? = nil var hashtagsToMigrate: [Hashtag]? = nil
@ -157,17 +153,12 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
if description.configuration == "Cloud" { if description.configuration == "Cloud" {
let context = self.backgroundContext self.backgroundContext.perform {
context.perform {
instancesToMigrate?.forEach({ url in instancesToMigrate?.forEach({ url in
if !context.objectExists(for: SavedInstance.fetchRequest(url: url, account: accountInfo!)) { _ = SavedInstance(url: url, account: accountInfo!, context: self.backgroundContext)
_ = SavedInstance(url: url, account: accountInfo!, context: self.backgroundContext)
}
}) })
hashtagsToMigrate?.forEach({ hashtag in hashtagsToMigrate?.forEach({ hashtag in
if !context.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name, account: accountInfo!)) { _ = SavedHashtag(hashtag: hashtag, account: accountInfo!, context: self.backgroundContext)
_ = SavedHashtag(hashtag: hashtag, account: accountInfo!, context: self.backgroundContext)
}
}) })
self.save(context: self.backgroundContext) self.save(context: self.backgroundContext)
} }
@ -444,12 +435,9 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
} }
func getTimelinePosition(timeline: Timeline) -> TimelinePosition? { func getTimelineState(timeline: Timeline) -> TimelineState? {
guard let accountInfo else {
return nil
}
do { do {
let req = TimelinePosition.fetchRequest(timeline: timeline, account: accountInfo) let req = TimelineState.fetchRequest(timeline: timeline)
return try viewContext.fetch(req).first return try viewContext.fetch(req).first
} catch { } catch {
return nil return nil
@ -495,6 +483,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
return changes return changes
} }
// the remote change notifications only handle deletes, inserts get handled by the regular managed object did change notifications
@objc private func remoteChanges(_ notification: Foundation.Notification) { @objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else { guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return return
@ -507,39 +496,28 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
self.backgroundContext.performAndWait { self.backgroundContext.performAndWait {
if let result = try? self.backgroundContext.execute(req) as? NSPersistentHistoryResult, if let result = try? self.backgroundContext.execute(req) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction] { let transactions = result.result as? [NSPersistentHistoryTransaction] {
var changedHashtags = false var changes: (hashtags: Bool, instances: Bool) = (false, false)
var changedInstances = false
var changedTimelinePositions: [NSManagedObjectID] = []
var changedAccountPrefs = false
outer: for transaction in transactions { outer: for transaction in transactions {
for change in transaction.changes ?? [] { for change in transaction.changes ?? [] {
if change.changedObjectID.entity.name == "SavedHashtag" { if change.changedObjectID.entity.name == "SavedHashtag" {
changedHashtags = true changes.hashtags = true
} else if change.changedObjectID.entity.name == "SavedInstance" { } else if change.changedObjectID.entity.name == "SavedInstance" {
changedInstances = true changes.instances = true
} else if change.changedObjectID.entity.name == "TimelinePosition" { }
changedTimelinePositions.append(change.changedObjectID) if changes.hashtags && changes.instances {
} else if change.changedObjectID.entity.name == "AccountPreferences" { break outer
changedAccountPrefs = true
} }
} }
} }
DispatchQueue.main.async { if changes.hashtags {
if changedHashtags { DispatchQueue.main.async {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
} }
if changedInstances { }
if changes.instances {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
} }
for id in changedTimelinePositions {
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
continue
}
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
}
if changedAccountPrefs {
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
}
} }
} }
} }
@ -547,8 +525,3 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
} }
extension Foundation.Notification.Name {
static let timelinePositionChanged = Notification.Name("timelinePositionChanged")
static let accountPreferencesChangedRemotely = Notification.Name("accountPreferencesChangedRemotely")
}

View File

@ -1,83 +0,0 @@
//
// TimelinePosition.swift
// Tusker
//
// Created by Shadowfacts on 12/23/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
@objc(TimelinePosition)
public final class TimelinePosition: NSManagedObject {
@nonobjc class func fetchRequest(timeline: Timeline, account: LocalData.UserAccountInfo) -> NSFetchRequest<TimelinePosition> {
let req = NSFetchRequest<TimelinePosition>(entityName: "TimelinePosition")
req.predicate = NSPredicate(format: "accountID = %@ AND timelineKind = %@", account.id, toTimelineKind(timeline))
return req
}
@NSManaged public var accountID: String
@NSManaged private var timelineKind: String
@NSManaged public var centerStatusID: String?
@NSManaged private var statusIDsData: Data?
@LazilyDecoding(arrayFrom: \TimelinePosition.statusIDsData)
var statusIDs: [String]
var timeline: Timeline {
get { fromTimelineKind(timelineKind) }
set { timelineKind = toTimelineKind(newValue) }
}
convenience init(timeline: Timeline, account: LocalData.UserAccountInfo, context: NSManagedObjectContext) {
self.init(context: context)
self.timeline = timeline
self.accountID = account.id
}
}
// blergh, this is the simplest way of getting the Timeline into a format that A) CoreData can handle and B) is usable in the predicate
func toTimelineKind(_ timeline: Timeline) -> String {
switch timeline {
case .home:
return "home"
case .public(local: true):
return "local"
case .public(local: false):
return "federated"
case .direct:
return "direct"
case .tag(hashtag: let name):
return "hashtag:\(name)"
case .list(id: let id):
return "list:\(id)"
}
}
func fromTimelineKind(_ kind: String) -> Timeline {
if kind == "home" {
return .home
} else if kind == "local" {
return .public(local: true)
} else if kind == "federated" {
return .public(local: false)
} else if kind == "direct" {
return .direct
} else if kind.starts(with: "hashtag:") {
return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind)))
} else if kind.starts(with: "list:") {
return .list(id: String(trimmingPrefix("list:", of: kind)))
} else {
fatalError("invalid timeline kind \(kind)")
}
}
// replace with Collection.trimmingPrefix
@available(iOS, obsoleted: 16.0)
private func trimmingPrefix(_ prefix: String, of str: String) -> Substring {
return str[str.index(str.startIndex, offsetBy: prefix.count)...]
}

View File

@ -13,8 +13,10 @@ import Pachyderm
@objc(TimelineState) @objc(TimelineState)
public final class TimelineState: NSManagedObject { public final class TimelineState: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<TimelineState> { @nonobjc public class func fetchRequest(timeline: Timeline) -> NSFetchRequest<TimelineState> {
return NSFetchRequest<TimelineState>(entityName: "TimelineState") let req = NSFetchRequest<TimelineState>(entityName: "TimelineState")
req.predicate = NSPredicate(format: "timelineKind = %@", toTimelineKind(timeline))
return req
} }
@NSManaged private var timelineKind: String @NSManaged private var timelineKind: String
@ -23,10 +25,65 @@ public final class TimelineState: NSManagedObject {
var timeline: Timeline { var timeline: Timeline {
get { fromTimelineKind(timelineKind) } get { fromTimelineKind(timelineKind) }
set { timelineKind = toTimelineKind(newValue) }
} }
var statusMOs: [StatusMO] { var statusMOs: [StatusMO] {
statuses.array as! [StatusMO] statuses.array as! [StatusMO]
} }
convenience init(timeline: Timeline, context: NSManagedObjectContext) {
self.init(context: context)
self.timeline = timeline
}
func setStatuses(_ statusIDs: [String]) {
let context = managedObjectContext!
// todo: this feels really inefficient, but I'm not sure if it's better or worse than doing a single "id IN %@" fetch and sorting after
let mos = statusIDs.compactMap { try? context.fetch(StatusMO.fetchRequest(id: $0)).first }
self.statuses = NSOrderedSet(array: mos)
}
}
// blergh, this is the simplest way of getting the Timeline into a format that A) CoreData can handle and B) is usable in the predicate
private func toTimelineKind(_ timeline: Timeline) -> String {
switch timeline {
case .home:
return "home"
case .public(local: true):
return "local"
case .public(local: false):
return "federated"
case .direct:
return "direct"
case .tag(hashtag: let name):
return "hashtag:\(name)"
case .list(id: let id):
return "list:\(id)"
}
}
private func fromTimelineKind(_ kind: String) -> Timeline {
if kind == "home" {
return .home
} else if kind == "local" {
return .public(local: true)
} else if kind == "federated" {
return .public(local: false)
} else if kind == "direct" {
return .direct
} else if kind.starts(with: "hashtag:") {
return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind)))
} else if kind.starts(with: "list:") {
return .list(id: String(trimmingPrefix("list:", of: kind)))
} else {
fatalError("invalid timeline kind \(kind)")
}
}
// replace with Collection.trimmingPrefix
@available(iOS, obsoleted: 16.0)
private func trimmingPrefix(_ prefix: String, of str: String) -> Substring {
return str[str.index(str.startIndex, offsetBy: prefix.count)...]
} }

View File

@ -117,12 +117,6 @@
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<entity name="TimelinePosition" representedClassName="TimelinePosition" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="centerStatusID" optional="YES" attributeType="String"/>
<attribute name="statusIDsData" optional="YES" attributeType="Binary" valueTransformerName="TimelinePositionStatusIDsTransformer"/>
<attribute name="timelineKind" optional="YES" attributeType="String"/>
</entity>
<entity name="TimelineState" representedClassName="TimelineState" syncable="YES"> <entity name="TimelineState" representedClassName="TimelineState" syncable="YES">
<attribute name="centerStatusID" optional="YES" attributeType="String"/> <attribute name="centerStatusID" optional="YES" attributeType="String"/>
<attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/> <attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/>
@ -132,7 +126,6 @@
<memberEntity name="SavedHashtag"/> <memberEntity name="SavedHashtag"/>
<memberEntity name="SavedInstance"/> <memberEntity name="SavedInstance"/>
<memberEntity name="AccountPreferences"/> <memberEntity name="AccountPreferences"/>
<memberEntity name="TimelinePosition"/>
</configuration> </configuration>
<configuration name="Local"> <configuration name="Local">
<memberEntity name="Account"/> <memberEntity name="Account"/>

View File

@ -14,7 +14,7 @@ extension CollapseState {
func resolveFor(status: StatusMO, height: CGFloat, textLength: Int? = nil) { func resolveFor(status: StatusMO, height: CGFloat, textLength: Int? = nil) {
let longEnoughToCollapse: Bool let longEnoughToCollapse: Bool
if Preferences.shared.collapseLongPosts, if Preferences.shared.collapseLongPosts,
height > 600 || (textLength != nil && textLength! > 500) { height > 500 || (textLength != nil && textLength! > 500) {
longEnoughToCollapse = true longEnoughToCollapse = true
} else { } else {
longEnoughToCollapse = false longEnoughToCollapse = false

View File

@ -61,7 +61,6 @@ class Preferences: Codable, ObservableObject {
} }
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs) self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
@ -109,7 +108,6 @@ class Preferences: Codable, ObservableObject {
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode) try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning) try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs) try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
try container.encode(openLinksInApps, forKey: .openLinksInApps) try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari) try container.encode(useInAppSafari, forKey: .useInAppSafari)
@ -165,7 +163,6 @@ class Preferences: Codable, ObservableObject {
} }
@Published var blurMediaBehindContentWarning = true @Published var blurMediaBehindContentWarning = true
@Published var automaticallyPlayGifs = true @Published var automaticallyPlayGifs = true
@Published var showUncroppedMediaInline = true
// MARK: Behavior // MARK: Behavior
@Published var openLinksInApps = true @Published var openLinksInApps = true
@ -216,7 +213,6 @@ class Preferences: Codable, ObservableObject {
case attachmentBlurMode case attachmentBlurMode
case blurMediaBehindContentWarning case blurMediaBehindContentWarning
case automaticallyPlayGifs case automaticallyPlayGifs
case showUncroppedMediaInline
case openLinksInApps case openLinksInApps
case useInAppSafari case useInAppSafari

View File

@ -131,7 +131,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
minDate.addTimeInterval(-7 * 24 * 60 * 60) minDate.addTimeInterval(-7 * 24 * 60 * 60)
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest() let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate) statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (timelines.@count = 0)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq) let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
deleteStatusReq.resultType = .resultTypeCount deleteStatusReq.resultType = .resultTypeCount
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult { if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {

View File

@ -36,14 +36,14 @@ struct ComposeToolbar: View {
MenuPicker(selection: $draft.visibility, options: Self.visibilityOptions, buttonStyle: .iconOnly) MenuPicker(selection: $draft.visibility, options: Self.visibilityOptions, buttonStyle: .iconOnly)
// // the button has a bunch of extra space by default, but combined with what we add it's too much // // the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8) // .padding(.horizontal, -8)
if mastodonController.instanceFeatures.localOnlyPosts { if mastodonController.instanceFeatures.localOnlyPosts {
MenuPicker(selection: $draft.localOnly, options: [ MenuPicker(selection: $draft.localOnly, options: [
.init(value: true, title: "Local-only", subtitle: "Only \(mastodonController.accountInfo!.instanceURL.host!)", image: UIImage(named: "link.broken")), .init(value: true, title: "Local-only", subtitle: "Only \(mastodonController.accountInfo!.instanceURL.host!)", image: UIImage(named: "link.broken")),
.init(value: false, title: "Federated", image: UIImage(systemName: "link")) .init(value: false, title: "Federated", image: UIImage(systemName: "link"))
], buttonStyle: .iconOnly) ], buttonStyle: .iconOnly)
.padding(.horizontal, -8) // .padding(.horizontal, -8)
} }
if let currentInput = uiState.currentInput, currentInput.toolbarElements.contains(.emojiPicker) { if let currentInput = uiState.currentInput, currentInput.toolbarElements.contains(.emojiPicker) {

View File

@ -35,6 +35,12 @@ struct BehaviorPrefsView: View {
Toggle(isOn: $preferences.timelineStateRestoration) { Toggle(isOn: $preferences.timelineStateRestoration) {
Text("Maintain Position Across App Launches") Text("Maintain Position Across App Launches")
} }
Toggle(isOn: $preferences.hideReblogsInTimelines) {
Text("Hide Reblogs")
}
Toggle(isOn: $preferences.hideRepliesInTimelines) {
Text("Hide Replies")
}
} header: { } header: {
Text("Timeline") Text("Timeline")
} }

View File

@ -37,10 +37,6 @@ struct MediaPrefsView: View {
Toggle(isOn: $preferences.automaticallyPlayGifs) { Toggle(isOn: $preferences.automaticallyPlayGifs) {
Text("Automatically Play GIFs") Text("Automatically Play GIFs")
} }
Toggle(isOn: $preferences.showUncroppedMediaInline) {
Text("Show Uncropped Media Inline")
}
} }
} }
} }

View File

@ -25,7 +25,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var collectionView: UICollectionView! private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var cancellables = Set<AnyCancellable>()
private var contentOffsetObservation: NSKeyValueObservation? private var contentOffsetObservation: NSKeyValueObservation?
private var activityToRestore: NSUserActivity? private var activityToRestore: NSUserActivity?
// the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing // the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing
@ -122,21 +121,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(sceneDidEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(sceneDidEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.publisher(for: .timelinePositionChanged)
.filter { [unowned self] in
if let timelinePosition = $0.object as? TimelinePosition,
timelinePosition.accountID == self.mastodonController.accountInfo?.id,
timelinePosition.timeline == self.timeline {
return true
} else {
return false
}
}
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [unowned self] _ in
_ = syncPositionIfNecessary(alwaysPrompt: true)
}
.store(in: &cancellables)
} }
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
@ -225,7 +209,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
} else { } else {
syncAndCheckPresentIfEnoughTimeElapsed() checkPresentIfEnoughTimeElapsed()
} }
} }
@ -249,29 +233,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
saveState() saveState()
} }
private func currentCenterVisibleIndexPath(snapshot: NSDiffableDataSourceSnapshot<Section, Item>?) -> IndexPath? { private func saveState() {
let snapshot = snapshot ?? dataSource.snapshot() guard isViewLoaded,
persistsState else {
return
}
let visible = collectionView.indexPathsForVisibleItems.sorted() let visible = collectionView.indexPathsForVisibleItems.sorted()
let snapshot = dataSource.snapshot()
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY) let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
guard !visible.isEmpty, guard !visible.isEmpty,
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses), let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
let rawCenterVisible = collectionView.indexPathForItem(at: midPoint), let rawCenterVisible = collectionView.indexPathForItem(at: midPoint),
let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else { let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else {
return nil
}
return centerVisible
}
private func saveState() {
guard isViewLoaded,
persistsState,
let accountInfo = mastodonController.accountInfo else {
return
}
let snapshot = dataSource.snapshot()
guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else {
return return
} }
let allItems = snapshot.itemIdentifiers(inSection: .statuses) let allItems = snapshot.itemIdentifiers(inSection: .statuses)
@ -314,11 +288,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)") stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
let context = mastodonController.persistentContainer.viewContext let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) ?? TimelineState(timeline: timeline, context: mastodonController.persistentContainer.viewContext)
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) ?? TimelinePosition(timeline: timeline, account: accountInfo, context: context) state.setStatuses(ids)
position.statusIDs = ids state.centerStatusID = centerVisibleID
position.centerStatusID = centerVisibleID mastodonController.persistentContainer.save(context: mastodonController.persistentContainer.viewContext)
mastodonController.persistentContainer.save(context: context)
} }
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
@ -331,68 +304,45 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return activity return activity
} }
func restoreState() -> Bool { private func restoreState() -> Bool {
guard persistsState, guard persistsState,
Preferences.shared.timelineStateRestoration, Preferences.shared.timelineStateRestoration,
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else { let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) else {
return false return false
} }
let statusIDs = state.statusMOs.map(\.id)
loadViewIfNeeded() loadViewIfNeeded()
Task { controller.restoreInitial {
await controller.restoreInitial { var snapshot = dataSource.snapshot()
await loadStatusesToRestore(position: position) snapshot.appendSections([.statuses])
applyItemsToRestore(position: position) let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) {
if let centerID = state.centerStatusID,
let index = statusIDs.firstIndex(of: centerID),
let indexPath = self.dataSource.indexPath(for: items[index]) {
// it sometimes takes multiple attempts to convert on the right scroll position
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
var count = 0
while count < 5 {
count += 1
let origOffset = self.collectionView.contentOffset
self.collectionView.layoutIfNeeded()
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
let newOffset = self.collectionView.contentOffset
if abs(origOffset.y - newOffset.y) <= 1 {
break
}
}
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
} }
} }
return true return true
} }
@MainActor
private func loadStatusesToRestore(position: TimelinePosition) async {
let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil })
guard !unloaded.isEmpty else {
return
}
await withTaskGroup(of: Void.self) { group in
for id in unloaded {
group.addTask { @MainActor in
if let (status, _) = try? await self.mastodonController.run(Client.getStatus(id: id)) {
self.mastodonController.persistentContainer.addOrUpdate(status: status)
}
}
}
}
}
private func applyItemsToRestore(position: TimelinePosition) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
let items = position.statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) {
if let centerID = position.centerStatusID,
let index = position.statusIDs.firstIndex(of: centerID),
let indexPath = self.dataSource.indexPath(for: items[index]) {
// it sometimes takes multiple attempts to convert on the right scroll position
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
var count = 0
while count < 5 {
count += 1
let origOffset = self.collectionView.contentOffset
self.collectionView.layoutIfNeeded()
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
let newOffset = self.collectionView.contentOffset
if abs(origOffset.y - newOffset.y) <= 1 {
break
}
}
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
}
}
private func removeTimelineDescriptionCell() { private func removeTimelineDescriptionCell() {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems([.publicTimelineDescription]) snapshot.deleteItems([.publicTimelineDescription])
@ -444,7 +394,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
view.window?.windowScene == scene else { view.window?.windowScene == scene else {
return return
} }
syncAndCheckPresentIfEnoughTimeElapsed() checkPresentIfEnoughTimeElapsed()
} }
@objc private func sceneDidEnterBackground(_ notification: Foundation.Notification) { @objc private func sceneDidEnterBackground(_ notification: Foundation.Notification) {
@ -457,49 +407,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
saveState() saveState()
} }
private func syncPositionIfNecessary(alwaysPrompt: Bool) -> Bool {
guard persistsState,
let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
return false
}
let snapshot = dataSource.snapshot()
guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot),
snapshot.sectionIdentifiers.contains(.statuses) else {
return false
}
let statusesSection = snapshot.itemIdentifiers(inSection: .statuses)
let centerVisibleStatusID: String
switch statusesSection[centerVisible.row] {
case .gap:
guard case .status(let id, _, _) = statusesSection[centerVisible.row + 1] else {
fatalError()
}
centerVisibleStatusID = id
case .status(let id, _, _):
centerVisibleStatusID = id
default:
fatalError()
}
guard timelinePosition.centerStatusID != centerVisibleStatusID else {
return false
}
if !alwaysPrompt {
_ = self.restoreState()
} else {
var config = ToastConfiguration(title: "Sync Position")
config.edge = .top
config.dismissAutomaticallyAfter = 5
config.systemImageName = "arrow.triangle.2.circlepath"
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
_ = self.restoreState()
}
showToast(configuration: config, animated: true)
UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated")
}
return true
}
@objc func refresh() { @objc func refresh() {
Task { Task {
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
@ -525,18 +432,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
private func syncAndCheckPresentIfEnoughTimeElapsed() { private func checkPresentIfEnoughTimeElapsed() {
guard let disappearedAt, guard let disappearedAt,
-disappearedAt.timeIntervalSinceNow > 60 * 60 /* 1 hour */ else { -disappearedAt.timeIntervalSinceNow > 60 * 60 /* 1 hour */ else {
return return
} }
self.disappearedAt = nil self.disappearedAt = nil
if syncPositionIfNecessary(alwaysPrompt: false) { Task {
// no-op await checkPresent(jumpImmediately: false)
} else {
Task {
await checkPresent(jumpImmediately: false)
}
} }
} }

View File

@ -9,7 +9,6 @@
import UIKit import UIKit
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import Combine
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> { class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
@ -19,7 +18,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var cancellables = Set<AnyCancellable>() private var pinnedTimelinesObservation: NSKeyValueObservation?
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -54,26 +53,15 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
await vc.checkPresent(jumpImmediately: true) await vc.checkPresent(jumpImmediately: true)
} }
return true return true
}), })
UIAccessibilityCustomAction(name: "Jump to Sync Position", actionHandler: { [unowned self] _ in
guard let vc = currentViewController as? TimelineViewController else {
return false
}
_ = vc.restoreState()
return true
}),
] ]
mastodonController.accountPreferences.publisher(for: \.pinnedTimelinesData) pinnedTimelinesObservation = mastodonController.accountPreferences.observe(\.pinnedTimelinesData, changeHandler: { [unowned self] _, _ in
.map { _ in () } let pages = self.mastodonController.accountPreferences.pinnedTimelines.map {
.merge(with: NotificationCenter.default.publisher(for: .accountPreferencesChangedRemotely).map { _ in () }) Page(mastodonController: self.mastodonController, timeline: $0)
.sink { _ in
let pages = self.mastodonController.accountPreferences.pinnedTimelines.map {
Page(mastodonController: self.mastodonController, timeline: $0)
}
self.setPages(pages, animated: false)
} }
.store(in: &cancellables) self.setPages(pages, animated: false)
})
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {

View File

@ -80,12 +80,12 @@ class TimelineLikeController<Item> {
} }
/// Used to indicate to the controller that the initial set of posts have been restored externally. /// Used to indicate to the controller that the initial set of posts have been restored externally.
func restoreInitial(doRestore: () async -> Void) async { func restoreInitial(doRestore: () -> Void) {
guard state == .notLoadedInitial || state == .idle else { guard state == .notLoadedInitial else {
return return
} }
state = .restoringInitial state = .restoringInitial
await doRestore() doRestore()
state = .idle state = .idle
} }
@ -234,7 +234,7 @@ class TimelineLikeController<Item> {
} }
case .idle: case .idle:
switch to { switch to {
case .restoringInitial, .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _): case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
return true return true
default: default:
return false return false

View File

@ -141,20 +141,17 @@ class AttachmentView: GIFImageView {
} }
} }
var attachmentAspectRatio: CGFloat? { private func blurHashSize() -> CGSize {
if let meta = self.attachment.meta { if let meta = self.attachment.meta {
let aspectRatio: CGFloat
if let width = meta.width, let height = meta.height { if let width = meta.width, let height = meta.height {
return CGFloat(width) / CGFloat(height) aspectRatio = CGFloat(width) / CGFloat(height)
} else if let orig = meta.original, } else if let orig = meta.original,
let width = orig.width, let height = orig.height { let width = orig.width, let height = orig.height {
return CGFloat(width) / CGFloat(height) aspectRatio = CGFloat(width) / CGFloat(height)
} else {
return CGSize(width: 32, height: 32)
} }
}
return nil
}
private func blurHashSize() -> CGSize {
if let aspectRatio = attachmentAspectRatio {
if aspectRatio > 1 { if aspectRatio > 1 {
return CGSize(width: 32, height: 32 / aspectRatio) return CGSize(width: 32, height: 32 / aspectRatio)
} else { } else {

View File

@ -19,9 +19,7 @@ class AttachmentsContainerView: UIView {
var attachments: [Attachment]! var attachments: [Attachment]!
let attachmentViews: NSHashTable<AttachmentView> = .weakObjects() let attachmentViews: NSHashTable<AttachmentView> = .weakObjects()
let attachmentStacks: NSHashTable<UIStackView> = .weakObjects()
var moreView: UIView? var moreView: UIView?
private var aspectRatioConstraint: NSLayoutConstraint?
var blurView: UIVisualEffectView? var blurView: UIVisualEffectView?
var hideButtonView: UIVisualEffectView? var hideButtonView: UIVisualEffectView?
@ -70,8 +68,6 @@ class AttachmentsContainerView: UIView {
attachmentViews.allObjects.forEach { $0.removeFromSuperview() } attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
attachmentViews.removeAllObjects() attachmentViews.removeAllObjects()
attachmentStacks.allObjects.forEach { $0.removeFromSuperview() }
attachmentStacks.removeAllObjects()
moreView?.removeFromSuperview() moreView?.removeFromSuperview()
var accessibilityElements = [Any]() var accessibilityElements = [Any]()
@ -79,8 +75,6 @@ class AttachmentsContainerView: UIView {
if attachments.count > 0 { if attachments.count > 0 {
self.isHidden = false self.isHidden = false
var aspectRatio: CGFloat = 16/9
switch attachments.count { switch attachments.count {
case 1: case 1:
let attachmentView = createAttachmentView(index: 0, hSize: .full, vSize: .full) let attachmentView = createAttachmentView(index: 0, hSize: .full, vSize: .full)
@ -89,9 +83,6 @@ class AttachmentsContainerView: UIView {
fillView(attachmentView) fillView(attachmentView)
sendSubviewToBack(attachmentView) sendSubviewToBack(attachmentView)
accessibilityElements.append(attachmentView) accessibilityElements.append(attachmentView)
if let attachmentAspectRatio = attachmentView.attachmentAspectRatio {
aspectRatio = attachmentAspectRatio
}
case 2: case 2:
let left = createAttachmentView(index: 0, hSize: .half, vSize: .full) let left = createAttachmentView(index: 0, hSize: .half, vSize: .full)
left.layer.cornerRadius = 5 left.layer.cornerRadius = 5
@ -105,7 +96,6 @@ class AttachmentsContainerView: UIView {
left, left,
right right
]) ])
attachmentStacks.add(stack)
fillView(stack) fillView(stack)
sendSubviewToBack(stack) sendSubviewToBack(stack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -126,16 +116,13 @@ class AttachmentsContainerView: UIView {
bottomRight.layer.cornerRadius = 5 bottomRight.layer.cornerRadius = 5
bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner
bottomRight.layer.masksToBounds = true bottomRight.layer.masksToBounds = true
let innerStack = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
bottomRight
])
attachmentStacks.add(innerStack)
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [ let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left, left,
innerStack, createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
bottomRight
])
]) ])
attachmentStacks.add(outerStack)
fillView(outerStack) fillView(outerStack)
sendSubviewToBack(outerStack) sendSubviewToBack(outerStack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -158,7 +145,6 @@ class AttachmentsContainerView: UIView {
topLeft, topLeft,
bottomLeft bottomLeft
]) ])
attachmentStacks.add(left)
let topRight = createAttachmentView(index: 1, hSize: .half, vSize: .half) let topRight = createAttachmentView(index: 1, hSize: .half, vSize: .half)
topRight.layer.cornerRadius = 5 topRight.layer.cornerRadius = 5
topRight.layer.maskedCorners = .layerMaxXMinYCorner topRight.layer.maskedCorners = .layerMaxXMinYCorner
@ -167,16 +153,13 @@ class AttachmentsContainerView: UIView {
bottomRight.layer.cornerRadius = 5 bottomRight.layer.cornerRadius = 5
bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner bottomRight.layer.maskedCorners = .layerMaxXMaxYCorner
bottomRight.layer.masksToBounds = true bottomRight.layer.masksToBounds = true
let right = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
bottomRight
])
attachmentStacks.add(right)
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [ let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left, left,
right, createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
bottomRight
])
]) ])
attachmentStacks.add(outerStack)
fillView(outerStack) fillView(outerStack)
sendSubviewToBack(outerStack) sendSubviewToBack(outerStack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -218,21 +201,17 @@ class AttachmentsContainerView: UIView {
topLeft, topLeft,
bottomLeft bottomLeft
]) ])
attachmentStacks.add(left)
let topRight = createAttachmentView(index: 1, hSize: .half, vSize: .half) let topRight = createAttachmentView(index: 1, hSize: .half, vSize: .half)
topRight.layer.cornerRadius = 5 topRight.layer.cornerRadius = 5
topRight.layer.maskedCorners = .layerMaxXMinYCorner topRight.layer.maskedCorners = .layerMaxXMinYCorner
topRight.layer.masksToBounds = true topRight.layer.masksToBounds = true
let right = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
moreView
])
attachmentStacks.add(right)
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [ let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
left, left,
right, createAttachmentsStack(axis: .vertical, arrangedSubviews: [
topRight,
moreView
])
]) ])
attachmentStacks.add(outerStack)
fillView(outerStack) fillView(outerStack)
sendSubviewToBack(outerStack) sendSubviewToBack(outerStack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -248,19 +227,7 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(topRight) accessibilityElements.append(topRight)
accessibilityElements.append(bottomLeft) accessibilityElements.append(bottomLeft)
accessibilityElements.append(moreView) accessibilityElements.append(moreView)
}
if Preferences.shared.showUncroppedMediaInline {
if aspectRatioConstraint?.multiplier != aspectRatio {
aspectRatioConstraint?.isActive = false
aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: aspectRatio)
aspectRatioConstraint!.isActive = true
}
} else {
if aspectRatioConstraint == nil {
aspectRatioConstraint = widthAnchor.constraint(equalTo: heightAnchor, multiplier: 16/9)
aspectRatioConstraint!.isActive = true
}
} }
} else { } else {
self.isHidden = true self.isHidden = true

View File

@ -293,7 +293,6 @@ class ProfileHeaderView: UIView {
} }
followButton.configuration!.showsActivityIndicator = true followButton.configuration!.showsActivityIndicator = true
followButton.isEnabled = false followButton.isEnabled = false
UIImpactFeedbackGenerator(style: .light).impactOccurred()
Task { Task {
do { do {
let (relationship, _) = try await mastodonController.run(req) let (relationship, _) = try await mastodonController.run(req)

View File

@ -104,6 +104,9 @@
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="128.5" width="343" height="0.0"/> <rect key="frame" x="0.0" y="128.5" width="343" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/> <color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" secondItem="IF9-9U-Gk0" secondAttribute="width" multiplier="9:16" priority="999" id="5oh-eK-J5d"/>
</constraints>
</view> </view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TLv-Xu-tT1" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TLv-Xu-tT1" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="132.5" width="343" height="39.5"/> <rect key="frame" x="0.0" y="132.5" width="343" height="39.5"/>

View File

@ -107,7 +107,7 @@ extension StatusCollectionViewCell {
if statusState.unknown { if statusState.unknown {
// layout so that we can take the content height into consideration when deciding whether to collapse // layout so that we can take the content height into consideration when deciding whether to collapse
layoutIfNeeded() layoutIfNeeded()
statusState.resolveFor(status: status, height: contentContainer.visibleSubviewHeight) statusState.resolveFor(status: status, height: contentContainer.contentTextView.bounds.height)
if statusState.collapsible! && showStatusAutomatically { if statusState.collapsible! && showStatusAutomatically {
statusState.collapsed = false statusState.collapsed = false
} }

View File

@ -24,7 +24,11 @@ class StatusContentContainer: UIView {
]) ])
} }
let attachmentsView = AttachmentsContainerView() let attachmentsView = AttachmentsContainerView().configure {
NSLayoutConstraint.activate([
$0.heightAnchor.constraint(equalTo: $0.widthAnchor, multiplier: 9/16),
])
}
let pollView = StatusPollView() let pollView = StatusPollView()
@ -40,10 +44,6 @@ class StatusContentContainer: UIView {
private var isCollapsed = false private var isCollapsed = false
var visibleSubviewHeight: CGFloat {
subviews.filter { !$0.isHidden }.map(\.bounds.height).reduce(0, +)
}
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -132,6 +132,9 @@
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target"> <view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="171.5" width="277" height="0.0"/> <rect key="frame" x="0.0" y="171.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/> <color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" secondItem="nbq-yr-2mA" secondAttribute="width" multiplier="9:16" priority="999" id="Rvt-zs-fkd"/>
</constraints>
</view> </view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="x3b-Zl-9F0" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="x3b-Zl-9F0" customClass="StatusPollView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="173.5" width="277" height="0.0"/> <rect key="frame" x="0.0" y="173.5" width="277" height="0.0"/>