Compare commits

...

14 Commits

Author SHA1 Message Date
Shadowfacts 3ea1ad5622 Bump build number and update changelog 2023-01-01 15:28:55 -05:00
Shadowfacts 5898da3234 Maybe fix race condition when account is loaded as profile statuses VC is dealloc'd 2023-01-01 15:27:25 -05:00
Shadowfacts 9dd966f639 Fix duplicate saved instances not being uniqued correctly 2023-01-01 15:27:25 -05:00
Shadowfacts 48662ef1f3 Bump build number and update changelog 2023-01-01 15:12:21 -05:00
Shadowfacts 854d48e54e Unique saved hashtag/instance items
This may happen when migrating to iCloud, if the same hashtag is saved
on multiple devices.
2023-01-01 14:49:04 -05:00
Shadowfacts d4c560d7fc Add createdAt to AccountPreferences and TimelinePosition to guard against race conditions when creating/migrating 2023-01-01 12:58:44 -05:00
Shadowfacts 91b7ce3008 Add pointer interaction to ToastView 2023-01-01 12:35:40 -05:00
Shadowfacts 4dca231a06 Add loading animation while syncing timeline position 2023-01-01 12:25:44 -05:00
Shadowfacts b81c83a250 Add iCloud env entitlement and ITSAppUsesNonExemptEncryption 2022-12-31 16:58:39 -05:00
Shadowfacts f9e619d9e7 Deduplicate updated timeline positions when handling remote changes 2022-12-31 16:58:20 -05:00
Shadowfacts ae7962ae50 Better Sentry messages 2022-12-31 16:57:43 -05:00
Shadowfacts 5027660b52 Maybe fix crash when restoring unloaded statuses due to race condition 2022-12-31 16:57:13 -05:00
Shadowfacts 358d81b5cf Fix crash when accessing SegmentedPageViewController before it's loaded 2022-12-31 16:46:00 -05:00
Shadowfacts 79b9108a8f Add CloudKit status indicator to advanced prefs 2022-12-31 11:24:42 -05:00
20 changed files with 251 additions and 58 deletions

View File

@ -1,5 +1,32 @@
# Changelog
## 2022.1 (59)
This build is a hotfix for a crash when migrating saved instances to iCloud.
## 2022.1 (58)
Features/Improvements:
- Sync timeline positions via iCloud
- Add pinned timelines customization (synced via iCloud)
- Sync saved hashtags & instances via iCloud
- Add filters for hiding reblogs and replies from home timeline
- Show uncropped attachments in the timeline for posts that have only one attachment
- Add more prominent Follow button to profile pages
- Add About and Acknowledgements pages to Preferences
- Automatically report certain kinds of errors
- iPadOS/macOS: Add window titles to indicate which account is in use
- iPadOS: Limit content to readable width
- VoiceOver: Improve label for toggle collapse button in conversation view
Bugfixes:
- Fix attachments in timeline somtimes being untappable
- Fix previewing links in the main status in a conversation activating the link
- Don't show reblog swipe action when reblogging is forbidden
- Fix unknown notifications appearing in the Mentions view
- Fix crash when fetching present items in certain circumstances
- Fix relationship (following/blocked/etc.) change breaking profile header layout
- Fix crash when restored timeline state includes unloaded statuses
- macOS: Fix add attachment buttons not matching system accent color
## 2022.1 (53)
Features/Improvements:
- Apply filters to Trending Posts

View File

@ -288,6 +288,7 @@
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; };
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; };
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; };
@ -672,6 +673,7 @@
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; };
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = "<group>"; };
@ -1485,6 +1487,7 @@
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
D6114E1627F8BB210080E273 /* VersionTests.swift */,
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */,
D6D4DDE6212518A200E1C4BB /* Info.plist */,
);
path = TuskerTests;
@ -2136,6 +2139,7 @@
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */,
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -2278,7 +2282,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 59;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@ -2345,7 +2349,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 59;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2496,7 +2500,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 59;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@ -2524,7 +2528,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 59;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@ -2633,7 +2637,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 59;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2659,7 +2663,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53;
CURRENT_PROJECT_VERSION = 59;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -155,12 +155,14 @@ class MastodonController: ObservableObject {
// are available when Filterers are constructed
loadCachedFilters()
if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first {
accountPreferences = existing
} else {
accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext)
persistentContainer.save(context: persistentContainer.viewContext)
}
loadAccountPreferences()
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
self.loadAccountPreferences()
}
.store(in: &cancellables)
Task {
do {
@ -177,6 +179,16 @@ class MastodonController: ObservableObject {
}
}
@MainActor
private func loadAccountPreferences() {
if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first {
accountPreferences = existing
} else {
accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext)
persistentContainer.save(context: persistentContainer.viewContext)
}
}
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
if account != nil {
completion?(.success(account))

View File

@ -16,10 +16,12 @@ public final class AccountPreferences: NSManagedObject {
@nonobjc class func fetchRequest(account: LocalData.UserAccountInfo) -> NSFetchRequest<AccountPreferences> {
let req = NSFetchRequest<AccountPreferences>(entityName: "AccountPreferences")
req.predicate = NSPredicate(format: "accountID = %@", account.id)
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
return req
}
@NSManaged public var accountID: String
@NSManaged var createdAt: Date
@NSManaged var pinnedTimelinesData: Data?
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: [])
@ -28,6 +30,7 @@ public final class AccountPreferences: NSManagedObject {
static func `default`(account: LocalData.UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context)
prefs.accountID = account.id
prefs.createdAt = Date()
prefs.pinnedTimelines = [.home, .public(local: true), .public(local: false)]
return prefs
}

View File

@ -153,7 +153,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
loadPersistentStores { (description, error) in
if let error = error {
logger.error("Unable to load persistent store: \(String(describing: error), privacy: .public)")
fatalError("Unable to load persistent store")
fatalError("Unable to load persistent store: \(String(describing: error))")
}
if description.configuration == "Cloud" {
@ -256,18 +256,19 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
return statusMO
}
func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform {
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
self.save(context: self.backgroundContext)
func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
let context = context ?? backgroundContext
context.perform {
statuses.forEach { self.upsert(status: $0, context: context) }
self.save(context: context)
statuses.forEach { self.statusSubject.send($0.id) }
completion?()
}
}
func addAll(statuses: [Status]) async {
func addAll(statuses: [Status], in context: NSManagedObjectContext? = nil) async {
return await withCheckedContinuation { continuation in
addAll(statuses: statuses) {
addAll(statuses: statuses, in: context) {
continuation.resume()
}
}
@ -506,10 +507,11 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
self.backgroundContext.performAndWait {
if let result = try? self.backgroundContext.execute(req) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction] {
let transactions = result.result as? [NSPersistentHistoryTransaction],
!transactions.isEmpty {
var changedHashtags = false
var changedInstances = false
var changedTimelinePositions: [NSManagedObjectID] = []
var changedTimelinePositions = Set<NSManagedObjectID>()
var changedAccountPrefs = false
outer: for transaction in transactions {
for change in transaction.changes ?? [] {
@ -518,7 +520,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} else if change.changedObjectID.entity.name == "SavedInstance" {
changedInstances = true
} else if change.changedObjectID.entity.name == "TimelinePosition" {
changedTimelinePositions.append(change.changedObjectID)
changedTimelinePositions.insert(change.changedObjectID)
} else if change.changedObjectID.entity.name == "AccountPreferences" {
changedAccountPrefs = true
}

View File

@ -16,10 +16,12 @@ 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))
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
return req
}
@NSManaged public var accountID: String
@NSManaged public var createdAt: Date
@NSManaged private var timelineKind: String
@NSManaged public var centerStatusID: String?
@NSManaged private var statusIDsData: Data?
@ -36,6 +38,7 @@ public final class TimelinePosition: NSManagedObject {
self.init(context: context)
self.timeline = timeline
self.accountID = account.id
self.createdAt = Date()
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22A380" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22C65" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/>
@ -30,6 +30,7 @@
</entity>
<entity name="AccountPreferences" representedClassName="AccountPreferences" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
</entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
@ -120,6 +121,7 @@
<entity name="TimelinePosition" representedClassName="TimelinePosition" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="centerStatusID" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="statusIDsData" optional="YES" attributeType="Binary" valueTransformerName="TimelinePositionStatusIDsTransformer"/>
<attribute name="timelineKind" optional="YES" attributeType="String"/>
</entity>

View File

@ -8,16 +8,31 @@
import Foundation
extension Array where Element: Hashable {
func uniques() -> [Element] {
var buffer = [Element]()
var added = Set<Element>()
extension Array {
func uniques<ID: Hashable>(by identify: (Element) -> ID) -> [Element] {
var uniques = Set<Hashed<Element, ID>>()
for elem in self {
if !added.contains(elem) {
buffer.append(elem)
added.insert(elem)
}
uniques.insert(Hashed(element: elem, id: identify(elem)))
}
return buffer
return uniques.map(\.element)
}
}
extension Array where Element: Hashable {
func uniques() -> [Element] {
return uniques(by: { $0 })
}
}
fileprivate struct Hashed<Element, ID: Hashable>: Hashable {
let element: Element
let id: ID
static func ==(lhs: Self, rhs: Self) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
@ -56,7 +58,7 @@
<key>NSMicrophoneUsageDescription</key>
<string>Post videos from the camera.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Save photos directly from other people's posts.</string>
<string>Save photos directly from other people&apos;s posts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Post photos from the photo library.</string>
<key>NSUserActivityTypes</key>

View File

@ -206,6 +206,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
}
items = items.uniques()
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.label))
return items
}
@ -215,7 +216,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url)
} catch {
return []
}

View File

@ -240,6 +240,7 @@ class MainSidebarViewController: UIViewController {
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
}
items = items.uniques()
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
return items
}
@ -249,7 +250,7 @@ class MainSidebarViewController: UIViewController {
let req = SavedInstance.fetchRequest(account: mastodonController.accountInfo!)
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
return try mastodonController.persistentContainer.viewContext.fetch(req).uniques(by: \.url)
} catch {
return []
}

View File

@ -8,15 +8,18 @@
import SwiftUI
import Pachyderm
import CoreData
import CloudKit
struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared
@State private var imageCacheSize: Int64 = 0
@State private var mastodonCacheSize: Int64 = 0
@State private var cloudKitStatus: CKAccountStatus?
var body: some View {
List {
formattingSection
cloudKitSection
errorReportingSection
cachingSection
}
@ -49,6 +52,39 @@ struct AdvancedPrefsView : View {
}
}
var cloudKitSection: some View {
Section {
HStack {
Text("iCloud Status")
Spacer()
switch cloudKitStatus {
case nil:
EmptyView()
case .available:
Text("Available")
case .couldNotDetermine:
Text("Could not determine")
case .noAccount:
Text("No account")
case .restricted:
Text("Restricted")
case .temporarilyUnavailable:
Text("Temporarily Unavailable")
@unknown default:
Text(String(describing: cloudKitStatus!))
}
}
}.task {
CKContainer.default().accountStatus { status, error in
if let error {
Logging.general.error("Unable to get CloudKit status: \(String(describing: error))")
} else {
self.cloudKitStatus = status
}
}
}
}
var errorReportingSection: some View {
Section {
Toggle("Report Errors Automatically", isOn: $preferences.reportErrorsAutomatically)

View File

@ -103,7 +103,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
.sink { [unowned self] id in
switch state {
case .unloaded:
Task {
Task { [self] in
await self.load()
}
case .loaded, .setupInitialSnapshot:

View File

@ -213,12 +213,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
clearSelectionOnAppear(animated: animated)
if case .notLoadedInitial = controller.state {
if restoreState() {
Task {
Task {
if await restoreState() {
await checkPresent(jumpImmediately: false)
}
} else {
Task {
} else {
await controller.loadInitial()
}
}
@ -329,18 +327,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return activity
}
func restoreState() -> Bool {
func restoreState() async -> Bool {
guard persistsState,
Preferences.shared.timelineStateRestoration,
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
return false
}
loadViewIfNeeded()
Task {
await controller.restoreInitial {
await loadStatusesToRestore(position: position)
applyItemsToRestore(position: position)
}
await controller.restoreInitial {
await loadStatusesToRestore(position: position)
applyItemsToRestore(position: position)
}
return true
}
@ -367,17 +363,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
}
}
await mastodonController.persistentContainer.addAll(statuses: statuses)
await mastodonController.persistentContainer.addAll(statuses: statuses, in: mastodonController.persistentContainer.viewContext)
}
private func applyItemsToRestore(position: TimelinePosition) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
let statusIDs = position.statusIDs
let centerStatusID = position.centerStatusID
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),
if let centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID),
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
@ -392,7 +390,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
break
}
}
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerID)")
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
@ -490,15 +488,29 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return false
}
if !alwaysPrompt {
_ = self.restoreState()
Task {
_ = await 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()
toast.isUserInteractionEnabled = false
UIView.animateKeyframes(withDuration: 1, delay: 0, options: .repeat) {
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
toast.imageView!.transform = CGAffineTransform(rotationAngle: 0.5 * .pi)
}
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
// the translation is because the symbol isn't perfectly centered
toast.imageView!.transform = CGAffineTransform(translationX: -0.5, y: 0).rotated(by: .pi)
}
}
Task {
_ = await self.restoreState()
toast.dismissToast(animated: true)
}
}
showToast(configuration: config, animated: true)
UIAccessibility.post(notification: .announcement, argument: "Synced Position Updated")

View File

@ -59,7 +59,9 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
guard let vc = currentViewController as? TimelineViewController else {
return false
}
_ = vc.restoreState()
Task {
_ = await vc.restoreState()
}
return true
}),
]
@ -85,7 +87,7 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
}
func stateRestorationActivity() -> NSUserActivity? {
return (currentViewController as! TimelineViewController).stateRestorationActivity()
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
}
func restoreActivity(_ activity: NSUserActivity) {

View File

@ -23,8 +23,8 @@ class SegmentedPageViewController<Page: SegmentedPageViewControllerPage>: UIPage
var currentIndex: Int! {
pages.firstIndex(of: currentPage)
}
var currentViewController: UIViewController {
viewControllers!.first!
var currentViewController: UIViewController! {
viewControllers?.first
}
let segmentedControl = ScrollingSegmentedControl<Page>()

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.aps-environment</key>

View File

@ -59,6 +59,7 @@ extension ToastConfiguration {
switch error.type {
case .invalidRequest, .invalidResponse, .invalidModel(_), .mastodonError(_):
SentrySDK.capture(error: error) { scope in
scope.setFingerprint([String(describing: error)])
let crumb = Breadcrumb(level: .error, category: "error")
crumb.message = title
crumb.data = [

View File

@ -12,6 +12,8 @@ class ToastView: UIView {
let configuration: ToastConfiguration
private(set) var imageView: UIImageView?
private var panRecognizer: UIPanGestureRecognizer!
private var shrinkAnimator: UIViewPropertyAnimator?
private var recognizedGesture = false
@ -51,6 +53,8 @@ class ToastView: UIView {
layer.shadowOpacity = 0.5
layer.masksToBounds = false
addInteraction(UIPointerInteraction(delegate: self))
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal
@ -61,6 +65,7 @@ class ToastView: UIView {
imageView.tintColor = .white
imageView.contentMode = .scaleAspectFit
stack.addArrangedSubview(imageView)
self.imageView = imageView
}
let titleLabel = UILabel()
@ -279,3 +284,21 @@ extension ToastView: UIGestureRecognizerDelegate {
return true
}
}
extension ToastView: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
return defaultRegion
}
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
return UIPointerStyle(effect: .highlight(UITargetedPreview(view: self)))
}
func pointerInteraction(_ interaction: UIPointerInteraction, willEnter region: UIPointerRegion, animator: UIPointerInteractionAnimating) {
shouldDismissAutomatically = false
}
func pointerInteraction(_ interaction: UIPointerInteraction, willExit region: UIPointerRegion, animator: UIPointerInteractionAnimating) {
shouldDismissAutomatically = true
}
}

View File

@ -0,0 +1,45 @@
//
// ArrayUniqueTests.swift
// TuskerTests
//
// Created by Shadowfacts on 1/1/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import XCTest
@testable import Tusker
final class ArrayUniqueTests: XCTestCase {
func testUniquesBy() {
let a = Test(string: "test")
let b = Test(string: "test")
XCTAssertNotEqual(a.id, b.id)
XCTAssertNotEqual(a.hashValue, b.hashValue)
XCTAssertEqual([a, b].uniques(by: \.string), [a])
}
class Test: NSObject {
let id = UUID()
let string: String
init(string: String) {
self.string = string
}
override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? Self else {
return false
}
return id == other.id && string == other.string
}
override var hash: Int {
var hasher = Hasher()
hasher.combine(id)
hasher.combine(string)
return hasher.finalize()
}
}
}