From 9d2324b587d9c8395a4e16c30c7950fa5915506d Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 14 Feb 2023 21:37:43 -0500 Subject: [PATCH] Add preference to use timeline marker API Closes #40 --- .../Pachyderm/Model/TimelineMarkers.swift | 40 +++++ Tusker/Preferences/Preferences.swift | 11 ++ .../Preferences/BehaviorPrefsView.swift | 9 ++ .../Timeline/TimelineViewController.swift | 138 ++++++++++++++---- 4 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/TimelineMarkers.swift diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/TimelineMarkers.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/TimelineMarkers.swift new file mode 100644 index 00000000..f673e2b7 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/TimelineMarkers.swift @@ -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 { + return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue)) + } + + public static func update(timeline: Timeline, lastReadID: String) -> Request { + 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" + } + } +} diff --git a/Tusker/Preferences/Preferences.swift b/Tusker/Preferences/Preferences.swift index feeaff38..1cf03c4e 100644 --- a/Tusker/Preferences/Preferences.swift +++ b/Tusker/Preferences/Preferences.swift @@ -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 + } +} diff --git a/Tusker/Screens/Preferences/BehaviorPrefsView.swift b/Tusker/Screens/Preferences/BehaviorPrefsView.swift index 71dafd47..016688c6 100644 --- a/Tusker/Screens/Preferences/BehaviorPrefsView.swift +++ b/Tusker/Screens/Preferences/BehaviorPrefsView.swift @@ -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() } diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 12a5944c..724c006a 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -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() + 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