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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,13 +339,33 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
} else {
|
} else {
|
||||||
fatalError()
|
fatalError()
|
||||||
}
|
}
|
||||||
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
|
switch Preferences.shared.timelineSyncMode {
|
||||||
|
case .icloud:
|
||||||
let context = mastodonController.persistentContainer.viewContext
|
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
|
||||||
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) ?? TimelinePosition(timeline: timeline, account: accountInfo, context: context)
|
|
||||||
position.statusIDs = ids
|
let context = mastodonController.persistentContainer.viewContext
|
||||||
position.centerStatusID = centerVisibleID
|
let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) ?? TimelinePosition(timeline: timeline, account: accountInfo, context: context)
|
||||||
mastodonController.persistentContainer.save(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? {
|
func stateRestorationActivity() -> NSUserActivity? {
|
||||||
|
@ -359,17 +380,27 @@ 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
|
||||||
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
|
switch Preferences.shared.timelineSyncMode {
|
||||||
if hasStatusesToRestore {
|
case .icloud:
|
||||||
applyItemsToRestore(position: position)
|
guard let position = mastodonController.persistentContainer.getTimelinePosition(timeline: timeline) else {
|
||||||
loaded = true
|
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
|
return loaded
|
||||||
|
@ -455,21 +486,8 @@ 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])
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
|
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
|
||||||
} else {
|
} else {
|
||||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
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() {
|
private func removeTimelineDescriptionCell() {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.deleteItems([.publicTimelineDescription])
|
snapshot.deleteItems([.publicTimelineDescription])
|
||||||
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue