Compare commits
8 Commits
fa31c28e92
...
16cd045588
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 16cd045588 | |
Shadowfacts | 15a7cd5f65 | |
Shadowfacts | e676075d5b | |
Shadowfacts | 967bff063b | |
Shadowfacts | 3cba0bce34 | |
Shadowfacts | 60b182ac18 | |
Shadowfacts | 619878ac85 | |
Shadowfacts | 169f1a0191 |
|
@ -219,6 +219,7 @@
|
||||||
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 */; };
|
||||||
|
@ -602,6 +603,7 @@
|
||||||
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>"; };
|
||||||
|
@ -916,6 +918,7 @@
|
||||||
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 */,
|
||||||
|
@ -1984,6 +1987,7 @@
|
||||||
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 */,
|
||||||
|
|
|
@ -18,6 +18,8 @@ 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)!
|
||||||
|
@ -50,6 +52,8 @@ 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
|
||||||
|
@ -153,12 +157,17 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if description.configuration == "Cloud" {
|
if description.configuration == "Cloud" {
|
||||||
self.backgroundContext.perform {
|
let context = self.backgroundContext
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
@ -435,9 +444,12 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimelineState(timeline: Timeline) -> TimelineState? {
|
func getTimelinePosition(timeline: Timeline) -> TimelinePosition? {
|
||||||
|
guard let accountInfo else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let req = TimelineState.fetchRequest(timeline: timeline)
|
let req = TimelinePosition.fetchRequest(timeline: timeline, account: accountInfo)
|
||||||
return try viewContext.fetch(req).first
|
return try viewContext.fetch(req).first
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
|
@ -483,7 +495,6 @@ 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
|
||||||
|
@ -496,28 +507,39 @@ 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 changes: (hashtags: Bool, instances: Bool) = (false, false)
|
var changedHashtags = 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" {
|
||||||
changes.hashtags = true
|
changedHashtags = true
|
||||||
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
||||||
changes.instances = true
|
changedInstances = true
|
||||||
}
|
} else if change.changedObjectID.entity.name == "TimelinePosition" {
|
||||||
if changes.hashtags && changes.instances {
|
changedTimelinePositions.append(change.changedObjectID)
|
||||||
break outer
|
} else if change.changedObjectID.entity.name == "AccountPreferences" {
|
||||||
|
changedAccountPrefs = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if changes.hashtags {
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
if changedHashtags {
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -525,3 +547,8 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Foundation.Notification.Name {
|
||||||
|
static let timelinePositionChanged = Notification.Name("timelinePositionChanged")
|
||||||
|
static let accountPreferencesChangedRemotely = Notification.Name("accountPreferencesChangedRemotely")
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
//
|
||||||
|
// 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)...]
|
||||||
|
}
|
|
@ -13,10 +13,8 @@ import Pachyderm
|
||||||
@objc(TimelineState)
|
@objc(TimelineState)
|
||||||
public final class TimelineState: NSManagedObject {
|
public final class TimelineState: NSManagedObject {
|
||||||
|
|
||||||
@nonobjc public class func fetchRequest(timeline: Timeline) -> NSFetchRequest<TimelineState> {
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<TimelineState> {
|
||||||
let req = NSFetchRequest<TimelineState>(entityName: "TimelineState")
|
return NSFetchRequest<TimelineState>(entityName: "TimelineState")
|
||||||
req.predicate = NSPredicate(format: "timelineKind = %@", toTimelineKind(timeline))
|
|
||||||
return req
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NSManaged private var timelineKind: String
|
@NSManaged private var timelineKind: String
|
||||||
|
@ -25,65 +23,10 @@ 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)...]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -117,6 +117,12 @@
|
||||||
</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"/>
|
||||||
|
@ -126,6 +132,7 @@
|
||||||
<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"/>
|
||||||
|
|
|
@ -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 > 500 || (textLength != nil && textLength! > 500) {
|
height > 600 || (textLength != nil && textLength! > 500) {
|
||||||
longEnoughToCollapse = true
|
longEnoughToCollapse = true
|
||||||
} else {
|
} else {
|
||||||
longEnoughToCollapse = false
|
longEnoughToCollapse = false
|
||||||
|
|
|
@ -61,6 +61,7 @@ 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)
|
||||||
|
@ -108,6 +109,7 @@ 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)
|
||||||
|
@ -163,6 +165,7 @@ 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
|
||||||
|
@ -213,6 +216,7 @@ 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
|
||||||
|
|
|
@ -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 < %@)) AND (timelines.@count = 0)", minDate as NSDate)
|
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", 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 {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -35,12 +35,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,10 @@ 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ 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
|
||||||
|
@ -121,6 +122,21 @@ 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
|
||||||
|
@ -209,7 +225,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
checkPresentIfEnoughTimeElapsed()
|
syncAndCheckPresentIfEnoughTimeElapsed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -233,19 +249,29 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
saveState()
|
saveState()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveState() {
|
private func currentCenterVisibleIndexPath(snapshot: NSDiffableDataSourceSnapshot<Section, Item>?) -> IndexPath? {
|
||||||
guard isViewLoaded,
|
let snapshot = snapshot ?? dataSource.snapshot()
|
||||||
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)
|
||||||
|
@ -288,10 +314,11 @@ 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 state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) ?? TimelineState(timeline: timeline, context: mastodonController.persistentContainer.viewContext)
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
state.setStatuses(ids)
|
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) ?? TimelinePosition(timeline: timeline, account: accountInfo, context: context)
|
||||||
state.centerStatusID = centerVisibleID
|
position.statusIDs = ids
|
||||||
mastodonController.persistentContainer.save(context: mastodonController.persistentContainer.viewContext)
|
position.centerStatusID = centerVisibleID
|
||||||
|
mastodonController.persistentContainer.save(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateRestorationActivity() -> NSUserActivity? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
|
@ -304,22 +331,47 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreState() -> Bool {
|
func restoreState() -> Bool {
|
||||||
guard persistsState,
|
guard persistsState,
|
||||||
Preferences.shared.timelineStateRestoration,
|
Preferences.shared.timelineStateRestoration,
|
||||||
let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) else {
|
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let statusIDs = state.statusMOs.map(\.id)
|
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
controller.restoreInitial {
|
Task {
|
||||||
var snapshot = dataSource.snapshot()
|
await controller.restoreInitial {
|
||||||
|
await loadStatusesToRestore(position: position)
|
||||||
|
applyItemsToRestore(position: position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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])
|
snapshot.appendSections([.statuses])
|
||||||
let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
let items = position.statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
|
||||||
snapshot.appendItems(items, toSection: .statuses)
|
snapshot.appendItems(items, toSection: .statuses)
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
if let centerID = state.centerStatusID,
|
if let centerID = position.centerStatusID,
|
||||||
let index = statusIDs.firstIndex(of: centerID),
|
let index = position.statusIDs.firstIndex(of: centerID),
|
||||||
let indexPath = self.dataSource.indexPath(for: items[index]) {
|
let indexPath = self.dataSource.indexPath(for: items[index]) {
|
||||||
// it sometimes takes multiple attempts to convert on the right scroll position
|
// 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
|
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
|
||||||
|
@ -340,8 +392,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeTimelineDescriptionCell() {
|
private func removeTimelineDescriptionCell() {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
|
@ -394,7 +444,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
view.window?.windowScene == scene else {
|
view.window?.windowScene == scene else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
checkPresentIfEnoughTimeElapsed()
|
syncAndCheckPresentIfEnoughTimeElapsed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func sceneDidEnterBackground(_ notification: Foundation.Notification) {
|
@objc private func sceneDidEnterBackground(_ notification: Foundation.Notification) {
|
||||||
|
@ -407,6 +457,49 @@ 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 {
|
||||||
|
@ -432,16 +525,20 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkPresentIfEnoughTimeElapsed() {
|
private func syncAndCheckPresentIfEnoughTimeElapsed() {
|
||||||
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) {
|
||||||
|
// no-op
|
||||||
|
} else {
|
||||||
Task {
|
Task {
|
||||||
await checkPresent(jumpImmediately: false)
|
await checkPresent(jumpImmediately: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkPresent(jumpImmediately: Bool) async {
|
func checkPresent(jumpImmediately: Bool) async {
|
||||||
if case .idle = controller.state,
|
if case .idle = controller.state,
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
import Combine
|
||||||
|
|
||||||
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
|
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
|
||||||
|
|
||||||
|
@ -18,7 +19,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
|
|
||||||
private var pinnedTimelinesObservation: NSKeyValueObservation?
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(mastodonController: MastodonController) {
|
init(mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
@ -53,15 +54,26 @@ 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
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
pinnedTimelinesObservation = mastodonController.accountPreferences.observe(\.pinnedTimelinesData, changeHandler: { [unowned self] _, _ in
|
mastodonController.accountPreferences.publisher(for: \.pinnedTimelinesData)
|
||||||
|
.map { _ in () }
|
||||||
|
.merge(with: NotificationCenter.default.publisher(for: .accountPreferencesChangedRemotely).map { _ in () })
|
||||||
|
.sink { _ in
|
||||||
let pages = self.mastodonController.accountPreferences.pinnedTimelines.map {
|
let pages = self.mastodonController.accountPreferences.pinnedTimelines.map {
|
||||||
Page(mastodonController: self.mastodonController, timeline: $0)
|
Page(mastodonController: self.mastodonController, timeline: $0)
|
||||||
}
|
}
|
||||||
self.setPages(pages, animated: false)
|
self.setPages(pages, animated: false)
|
||||||
})
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
|
|
@ -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: () -> Void) {
|
func restoreInitial(doRestore: () async -> Void) async {
|
||||||
guard state == .notLoadedInitial else {
|
guard state == .notLoadedInitial || state == .idle else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state = .restoringInitial
|
state = .restoringInitial
|
||||||
doRestore()
|
await doRestore()
|
||||||
state = .idle
|
state = .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -234,7 +234,7 @@ class TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
case .idle:
|
case .idle:
|
||||||
switch to {
|
switch to {
|
||||||
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
case .restoringInitial, .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -141,17 +141,20 @@ class AttachmentView: GIFImageView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func blurHashSize() -> CGSize {
|
var attachmentAspectRatio: CGFloat? {
|
||||||
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 {
|
||||||
aspectRatio = CGFloat(width) / CGFloat(height)
|
return 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 {
|
||||||
aspectRatio = CGFloat(width) / CGFloat(height)
|
return 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 {
|
||||||
|
|
|
@ -19,7 +19,9 @@ 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?
|
||||||
|
@ -68,6 +70,8 @@ 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]()
|
||||||
|
@ -75,6 +79,8 @@ 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)
|
||||||
|
@ -83,6 +89,9 @@ 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
|
||||||
|
@ -96,6 +105,7 @@ class AttachmentsContainerView: UIView {
|
||||||
left,
|
left,
|
||||||
right
|
right
|
||||||
])
|
])
|
||||||
|
attachmentStacks.add(stack)
|
||||||
fillView(stack)
|
fillView(stack)
|
||||||
sendSubviewToBack(stack)
|
sendSubviewToBack(stack)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -116,13 +126,16 @@ 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 outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
|
let innerStack = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
|
||||||
left,
|
|
||||||
createAttachmentsStack(axis: .vertical, arrangedSubviews: [
|
|
||||||
topRight,
|
topRight,
|
||||||
bottomRight
|
bottomRight
|
||||||
])
|
])
|
||||||
|
attachmentStacks.add(innerStack)
|
||||||
|
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
|
||||||
|
left,
|
||||||
|
innerStack,
|
||||||
])
|
])
|
||||||
|
attachmentStacks.add(outerStack)
|
||||||
fillView(outerStack)
|
fillView(outerStack)
|
||||||
sendSubviewToBack(outerStack)
|
sendSubviewToBack(outerStack)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -145,6 +158,7 @@ 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
|
||||||
|
@ -153,13 +167,16 @@ 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 outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
|
let right = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
|
||||||
left,
|
|
||||||
createAttachmentsStack(axis: .vertical, arrangedSubviews: [
|
|
||||||
topRight,
|
topRight,
|
||||||
bottomRight
|
bottomRight
|
||||||
])
|
])
|
||||||
|
attachmentStacks.add(right)
|
||||||
|
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
|
||||||
|
left,
|
||||||
|
right,
|
||||||
])
|
])
|
||||||
|
attachmentStacks.add(outerStack)
|
||||||
fillView(outerStack)
|
fillView(outerStack)
|
||||||
sendSubviewToBack(outerStack)
|
sendSubviewToBack(outerStack)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -201,17 +218,21 @@ 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 outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
|
let right = createAttachmentsStack(axis: .vertical, arrangedSubviews: [
|
||||||
left,
|
|
||||||
createAttachmentsStack(axis: .vertical, arrangedSubviews: [
|
|
||||||
topRight,
|
topRight,
|
||||||
moreView
|
moreView
|
||||||
])
|
])
|
||||||
|
attachmentStacks.add(right)
|
||||||
|
let outerStack = createAttachmentsStack(axis: .horizontal, arrangedSubviews: [
|
||||||
|
left,
|
||||||
|
right,
|
||||||
])
|
])
|
||||||
|
attachmentStacks.add(outerStack)
|
||||||
fillView(outerStack)
|
fillView(outerStack)
|
||||||
sendSubviewToBack(outerStack)
|
sendSubviewToBack(outerStack)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
@ -227,7 +248,19 @@ 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
|
||||||
|
|
|
@ -293,6 +293,7 @@ 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)
|
||||||
|
|
|
@ -104,9 +104,6 @@
|
||||||
<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"/>
|
||||||
|
|
|
@ -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.contentTextView.bounds.height)
|
statusState.resolveFor(status: status, height: contentContainer.visibleSubviewHeight)
|
||||||
if statusState.collapsible! && showStatusAutomatically {
|
if statusState.collapsible! && showStatusAutomatically {
|
||||||
statusState.collapsed = false
|
statusState.collapsed = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,11 +24,7 @@ class StatusContentContainer: UIView {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachmentsView = AttachmentsContainerView().configure {
|
let attachmentsView = AttachmentsContainerView()
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
$0.heightAnchor.constraint(equalTo: $0.widthAnchor, multiplier: 9/16),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
let pollView = StatusPollView()
|
let pollView = StatusPollView()
|
||||||
|
|
||||||
|
@ -44,6 +40,10 @@ 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)
|
||||||
|
|
||||||
|
|
|
@ -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="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
<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">
|
||||||
<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="21207"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
|
||||||
<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,9 +132,6 @@
|
||||||
<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"/>
|
||||||
|
|
Loading…
Reference in New Issue