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.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
}
}

View File

@ -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()
}

View File

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