forked from shadowfacts/Tusker
parent
60921cb95f
commit
9d2324b587
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// TimelineMarkers.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 2/14/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TimelineMarkers: Decodable {
|
||||
public let home: Marker?
|
||||
public let notifications: Marker?
|
||||
|
||||
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
|
||||
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue))
|
||||
}
|
||||
|
||||
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
|
||||
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
|
||||
"\(timeline.rawValue)[last_read_id]" => lastReadID,
|
||||
]))
|
||||
}
|
||||
|
||||
public enum Timeline: String {
|
||||
case home
|
||||
case notifications
|
||||
}
|
||||
|
||||
public struct Marker: Decodable {
|
||||
public let lastReadID: String
|
||||
public let version: Int
|
||||
public let updatedAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case lastReadID = "last_read_id"
|
||||
case version
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -75,6 +75,7 @@ class Preferences: Codable, ObservableObject {
|
|||
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
|
||||
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
|
||||
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
|
||||
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
|
||||
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
|
||||
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
|
||||
|
||||
|
@ -127,6 +128,7 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
|
||||
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
|
||||
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
|
||||
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
|
||||
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
|
||||
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
|
||||
|
||||
|
@ -188,6 +190,7 @@ class Preferences: Codable, ObservableObject {
|
|||
@Published var oppositeCollapseKeywords: [String] = []
|
||||
@Published var confirmBeforeReblog = false
|
||||
@Published var timelineStateRestoration = true
|
||||
@Published var timelineSyncMode = TimelineSyncMode.icloud
|
||||
@Published var hideReblogsInTimelines = false
|
||||
@Published var hideRepliesInTimelines = false
|
||||
|
||||
|
@ -242,6 +245,7 @@ class Preferences: Codable, ObservableObject {
|
|||
case oppositeCollapseKeywords
|
||||
case confirmBeforeReblog
|
||||
case timelineStateRestoration
|
||||
case timelineSyncMode
|
||||
case hideReblogsInTimelines
|
||||
case hideRepliesInTimelines
|
||||
|
||||
|
@ -395,3 +399,10 @@ extension Preferences {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
enum TimelineSyncMode: String, Codable {
|
||||
case mastodon
|
||||
case icloud
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,8 +37,17 @@ struct BehaviorPrefsView: View {
|
|||
Toggle(isOn: $preferences.timelineStateRestoration) {
|
||||
Text("Maintain Position Across App Launches")
|
||||
}
|
||||
|
||||
Picker(selection: $preferences.timelineSyncMode) {
|
||||
Text("iCloud").tag(Preferences.TimelineSyncMode.icloud)
|
||||
Text("Mastodon").tag(Preferences.TimelineSyncMode.mastodon)
|
||||
} label: {
|
||||
Text("Sync Timeline Position via")
|
||||
}
|
||||
} header: {
|
||||
Text("Timeline")
|
||||
} footer: {
|
||||
Text("Syncing via the Mastodon API can be more reliable than iCloud, but is not compatible with the Mastodon web interface.")
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
}
|
||||
|
|
|
@ -151,7 +151,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
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,
|
||||
if Preferences.shared.timelineSyncMode == .icloud,
|
||||
let timelinePosition = $0.object as? TimelinePosition,
|
||||
timelinePosition.accountID == self.mastodonController.accountInfo?.id,
|
||||
timelinePosition.timeline == self.timeline {
|
||||
return true
|
||||
|
@ -162,7 +163,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||
.sink { [unowned self] _ in
|
||||
Task {
|
||||
_ = await syncPositionIfNecessary(alwaysPrompt: true)
|
||||
_ = await syncPositionFromICloudIfNecessary(alwaysPrompt: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
@ -338,13 +339,33 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
} else {
|
||||
fatalError()
|
||||
}
|
||||
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
|
||||
switch Preferences.shared.timelineSyncMode {
|
||||
case .icloud:
|
||||
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
|
||||
|
||||
let context = mastodonController.persistentContainer.viewContext
|
||||
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) ?? TimelinePosition(timeline: timeline, account: accountInfo, context: context)
|
||||
position.statusIDs = ids
|
||||
position.centerStatusID = centerVisibleID
|
||||
mastodonController.persistentContainer.save(context: context)
|
||||
let context = mastodonController.persistentContainer.viewContext
|
||||
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) ?? TimelinePosition(timeline: timeline, account: accountInfo, context: context)
|
||||
position.statusIDs = ids
|
||||
position.centerStatusID = centerVisibleID
|
||||
mastodonController.persistentContainer.save(context: context)
|
||||
|
||||
case .mastodon:
|
||||
guard case .home = timeline else {
|
||||
return
|
||||
}
|
||||
stateRestorationLogger.debug("TimelineViewController: updating timeline marker with last read ID \(centerVisibleID)")
|
||||
Task {
|
||||
do {
|
||||
let req = TimelineMarkers.update(timeline: .home, lastReadID: centerVisibleID)
|
||||
_ = try await mastodonController.run(req)
|
||||
} catch {
|
||||
stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))")
|
||||
let event = Event(error: error)
|
||||
event.message = SentryMessage(formatted: "Failed to update timeline marker")
|
||||
SentrySDK.capture(event: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
|
@ -359,17 +380,27 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
|
||||
func restoreState() async -> Bool {
|
||||
guard persistsState,
|
||||
Preferences.shared.timelineStateRestoration,
|
||||
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
|
||||
Preferences.shared.timelineStateRestoration else {
|
||||
return false
|
||||
}
|
||||
loadViewIfNeeded()
|
||||
var loaded = false
|
||||
await controller.restoreInitial { @MainActor in
|
||||
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
||||
if hasStatusesToRestore {
|
||||
applyItemsToRestore(position: position)
|
||||
loaded = true
|
||||
switch Preferences.shared.timelineSyncMode {
|
||||
case .icloud:
|
||||
guard let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
|
||||
return
|
||||
}
|
||||
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
||||
if hasStatusesToRestore {
|
||||
applyItemsToRestore(position: position)
|
||||
loaded = true
|
||||
}
|
||||
case .mastodon:
|
||||
guard case .home = timeline else {
|
||||
return
|
||||
}
|
||||
loaded = await restoreStatusesFromMarkerPosition()
|
||||
}
|
||||
}
|
||||
return loaded
|
||||
|
@ -455,21 +486,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
SentrySDK.addBreadcrumb(crumb)
|
||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
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
|
||||
var count = 0
|
||||
while count < 5 {
|
||||
count += 1
|
||||
let origOffset = self.collectionView.contentOffset
|
||||
self.collectionView.layoutIfNeeded()
|
||||
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
|
||||
let newOffset = self.collectionView.contentOffset
|
||||
if abs(origOffset.y - newOffset.y) <= 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
let index = statusIDs.firstIndex(of: centerStatusID) {
|
||||
self.scrollToItem(item: items[index])
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
|
||||
} else {
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
||||
|
@ -477,6 +495,57 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func restoreStatusesFromMarkerPosition() async -> Bool {
|
||||
do {
|
||||
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timelines: [.home]))
|
||||
guard let home = marker.home else {
|
||||
return false
|
||||
}
|
||||
async let status = try await mastodonController.run(Client.getStatus(id: home.lastReadID)).0
|
||||
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: home.lastReadID, count: Self.pageSize))).0
|
||||
|
||||
let allStatuses = try await [status] + olderStatuses
|
||||
await mastodonController.persistentContainer.addAll(statuses: allStatuses)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.statuses])
|
||||
let items = allStatuses.map { Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown) }
|
||||
snapshot.appendItems(items)
|
||||
await dataSource.apply(snapshot, animatingDifferences: false)
|
||||
collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top)
|
||||
|
||||
stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(home.lastReadID)")
|
||||
|
||||
return true
|
||||
} catch {
|
||||
stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))")
|
||||
let event = Event(error: error)
|
||||
event.message = SentryMessage(formatted: "Failed to load from timeline marker")
|
||||
SentrySDK.capture(event: event)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToItem(item: Item) {
|
||||
guard let indexPath = dataSource.indexPath(for: item) else {
|
||||
return
|
||||
}
|
||||
// it sometimes takes multiple attempts to convert on the right scroll position
|
||||
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
|
||||
var count = 0
|
||||
while count < 5 {
|
||||
count += 1
|
||||
let origOffset = self.collectionView.contentOffset
|
||||
self.collectionView.layoutIfNeeded()
|
||||
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
|
||||
let newOffset = self.collectionView.contentOffset
|
||||
if abs(origOffset.y - newOffset.y) <= 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTimelineDescriptionCell() {
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.deleteItems([.publicTimelineDescription])
|
||||
|
@ -547,6 +616,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
}
|
||||
|
||||
func syncPositionIfNecessary(alwaysPrompt: Bool) async -> Bool {
|
||||
switch Preferences.shared.timelineSyncMode {
|
||||
case .icloud:
|
||||
return await syncPositionFromICloudIfNecessary(alwaysPrompt: alwaysPrompt)
|
||||
case .mastodon:
|
||||
return await restoreStatusesFromMarkerPosition()
|
||||
}
|
||||
}
|
||||
|
||||
private func syncPositionFromICloudIfNecessary(alwaysPrompt: Bool) async -> Bool {
|
||||
guard persistsState,
|
||||
let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
|
||||
return false
|
||||
|
|
Loading…
Reference in New Issue