Add preference to use timeline marker API

Closes #40
This commit is contained in:
Shadowfacts 2023-02-14 21:37:43 -05:00
parent 60921cb95f
commit 9d2324b587
4 changed files with 168 additions and 30 deletions

View File

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

View File

@ -75,6 +75,7 @@ class Preferences: Codable, ObservableObject {
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? [] self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true 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.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? 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(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog) try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration) try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines) try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines) try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
@ -188,6 +190,7 @@ class Preferences: Codable, ObservableObject {
@Published var oppositeCollapseKeywords: [String] = [] @Published var oppositeCollapseKeywords: [String] = []
@Published var confirmBeforeReblog = false @Published var confirmBeforeReblog = false
@Published var timelineStateRestoration = true @Published var timelineStateRestoration = true
@Published var timelineSyncMode = TimelineSyncMode.icloud
@Published var hideReblogsInTimelines = false @Published var hideReblogsInTimelines = false
@Published var hideRepliesInTimelines = false @Published var hideRepliesInTimelines = false
@ -242,6 +245,7 @@ class Preferences: Codable, ObservableObject {
case oppositeCollapseKeywords case oppositeCollapseKeywords
case confirmBeforeReblog case confirmBeforeReblog
case timelineStateRestoration case timelineStateRestoration
case timelineSyncMode
case hideReblogsInTimelines case hideReblogsInTimelines
case hideRepliesInTimelines case hideRepliesInTimelines
@ -395,3 +399,10 @@ extension Preferences {
} }
} }
} }
extension Preferences {
enum TimelineSyncMode: String, Codable {
case mastodon
case icloud
}
}

View File

@ -37,8 +37,17 @@ struct BehaviorPrefsView: View {
Toggle(isOn: $preferences.timelineStateRestoration) { Toggle(isOn: $preferences.timelineStateRestoration) {
Text("Maintain Position Across App Launches") 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: { } header: {
Text("Timeline") 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() .appGroupedListRowBackground()
} }

View File

@ -151,7 +151,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
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) NotificationCenter.default.publisher(for: .timelinePositionChanged)
.filter { [unowned self] in .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.accountID == self.mastodonController.accountInfo?.id,
timelinePosition.timeline == self.timeline { timelinePosition.timeline == self.timeline {
return true return true
@ -162,7 +163,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [unowned self] _ in .sink { [unowned self] _ in
Task { Task {
_ = await syncPositionIfNecessary(alwaysPrompt: true) _ = await syncPositionFromICloudIfNecessary(alwaysPrompt: true)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -338,6 +339,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} else { } else {
fatalError() fatalError()
} }
switch Preferences.shared.timelineSyncMode {
case .icloud:
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)") stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
let context = mastodonController.persistentContainer.viewContext let context = mastodonController.persistentContainer.viewContext
@ -345,6 +348,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
position.statusIDs = ids position.statusIDs = ids
position.centerStatusID = centerVisibleID position.centerStatusID = centerVisibleID
mastodonController.persistentContainer.save(context: context) 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? { func stateRestorationActivity() -> NSUserActivity? {
@ -359,18 +380,28 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
func restoreState() async -> Bool { func restoreState() async -> Bool {
guard persistsState, guard persistsState,
Preferences.shared.timelineStateRestoration, Preferences.shared.timelineStateRestoration else {
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
return false return false
} }
loadViewIfNeeded() loadViewIfNeeded()
var loaded = false var loaded = false
await controller.restoreInitial { @MainActor in await controller.restoreInitial { @MainActor in
switch Preferences.shared.timelineSyncMode {
case .icloud:
guard let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
return
}
let hasStatusesToRestore = await loadStatusesToRestore(position: position) let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore { if hasStatusesToRestore {
applyItemsToRestore(position: position) applyItemsToRestore(position: position)
loaded = true loaded = true
} }
case .mastodon:
guard case .home = timeline else {
return
}
loaded = await restoreStatusesFromMarkerPosition()
}
} }
return loaded return loaded
} }
@ -455,8 +486,51 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
SentrySDK.addBreadcrumb(crumb) SentrySDK.addBreadcrumb(crumb)
dataSource.apply(snapshot, animatingDifferences: false) { dataSource.apply(snapshot, animatingDifferences: false) {
if let centerStatusID, if let centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID), let index = statusIDs.firstIndex(of: centerStatusID) {
let indexPath = self.dataSource.indexPath(for: items[index]) { 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")
}
}
}
@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 // 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
var count = 0 var count = 0
@ -470,11 +544,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
break break
} }
} }
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
}
} }
private func removeTimelineDescriptionCell() { private func removeTimelineDescriptionCell() {
@ -547,6 +616,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
func syncPositionIfNecessary(alwaysPrompt: Bool) async -> Bool { 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, guard persistsState,
let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else { let timelinePosition = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
return false return false