Compare commits

..

3 Commits

Author SHA1 Message Date
Shadowfacts c73784aa81 Mark notifications on Mastodon web frontend as read once displayed
Fixes #357
2024-04-01 19:51:57 -04:00
Shadowfacts 7affa09e5e Remove timeline marker Sentry reporting
I'm 99% sure these timeouts are all due to bad network conditions
2024-04-01 19:43:14 -04:00
Shadowfacts 7435d02f6e Fiddle with how the timeline markers API is organized 2024-04-01 19:22:55 -04:00
4 changed files with 126 additions and 49 deletions

View File

@ -7,26 +7,53 @@
import Foundation import Foundation
public struct TimelineMarkers: Decodable, Sendable { public struct TimelineMarkers {
public let home: Marker? private init() {}
public let notifications: Marker?
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> { public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue)) Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
} }
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> { public static func update<T: TimelineMarkerType>(timeline: T, lastReadID: String) -> Request<Empty> {
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([ Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
"\(timeline.rawValue)[last_read_id]" => lastReadID, "\(T.name)[last_read_id]" => lastReadID
])) ]))
} }
public enum Timeline: String {
case home
case notifications
} }
public struct Marker: Decodable, Sendable { public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
let payload: Payload
public var lastReadID: String {
payload.payload.lastReadID
}
public var version: Int {
payload.payload.version
}
public var updatedAt: Date {
payload.payload.updatedAt
}
public init(from decoder: any Decoder) throws {
self.payload = try Payload(from: decoder)
}
}
public protocol TimelineMarkerTypePayload: Decodable, Sendable {
var payload: MarkerPayload { get }
}
public struct HomeMarkerPayload: TimelineMarkerTypePayload {
public var home: MarkerPayload
public var payload: MarkerPayload { home }
}
public struct NotificationsMarkerPayload: TimelineMarkerTypePayload {
public var notifications: MarkerPayload
public var payload: MarkerPayload { notifications }
}
public struct MarkerPayload: Decodable, Sendable {
public let lastReadID: String public let lastReadID: String
public let version: Int public let version: Int
public let updatedAt: Date public let updatedAt: Date
@ -37,4 +64,26 @@ public struct TimelineMarkers: Decodable, Sendable {
case updatedAt = "updated_at" case updatedAt = "updated_at"
} }
} }
public protocol TimelineMarkerType {
static var name: String { get }
associatedtype Payload: TimelineMarkerTypePayload
}
extension TimelineMarkerType where Self == HomeMarker {
public static var home: Self { .init() }
}
extension TimelineMarkerType where Self == NotificationsMarker {
public static var notifications: Self { .init() }
}
public struct HomeMarker: TimelineMarkerType {
public typealias Payload = HomeMarkerPayload
public static var name: String { "home" }
}
public struct NotificationsMarker: TimelineMarkerType {
public typealias Payload = NotificationsMarkerPayload
public static var name: String { "notifications" }
} }

View File

@ -12,6 +12,9 @@ import Combine
#if canImport(Sentry) #if canImport(Sentry)
import Sentry import Sentry
#endif #endif
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationsCVC")
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController { class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
@ -32,6 +35,9 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange? private var older: RequestRange?
var updatesNotificationsMarker: Bool = false
private var newestDisplayedNotification: Item?
init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) { init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) {
self.allowedTypes = allowedTypes self.allowedTypes = allowedTypes
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -205,6 +211,12 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
} }
} }
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
updateNotificationsMarkerIfNecessary()
}
@objc func refresh() { @objc func refresh() {
Task { @MainActor in Task { @MainActor in
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
@ -311,6 +323,23 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
await apply(snapshot, animatingDifferences: true) await apply(snapshot, animatingDifferences: true)
} }
private func updateNotificationsMarkerIfNecessary() {
guard updatesNotificationsMarker,
case let .group(group, _, _) = newestDisplayedNotification,
let notification = group.notifications.first else {
return
}
logger.debug("Updating notifications marker with \(notification.id)")
Task {
let req = TimelineMarkers.update(timeline: .notifications, lastReadID: notification.id)
do {
_ = try await mastodonController.run(req)
} catch {
logger.error("Failed to update notifications marker: \(String(describing: error))")
}
}
}
} }
extension NotificationsCollectionViewController { extension NotificationsCollectionViewController {
@ -545,6 +574,21 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else { guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else {
return return
} }
if updatesNotificationsMarker {
let shouldUpdateNewestDisplayedNotification: Bool
if let newestDisplayedNotification,
let currentNewestIndexPath = dataSource.indexPath(for: newestDisplayedNotification) {
shouldUpdateNewestDisplayedNotification = indexPath < currentNewestIndexPath
} else {
shouldUpdateNewestDisplayedNotification = true
}
if shouldUpdateNewestDisplayedNotification,
let item = dataSource.itemIdentifier(for: indexPath) {
newestDisplayedNotification = item
}
}
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 { if indexPath.row == itemsInSection - 1 {
Task { Task {
@ -765,3 +809,9 @@ extension NotificationsCollectionViewController: StatusBarTappableViewController
return .stop return .stop
} }
} }
extension NotificationsCollectionViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
updateNotificationsMarkerIfNecessary()
}
}

View File

@ -24,6 +24,7 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
let vc = NotificationsCollectionViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController) let vc = NotificationsCollectionViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
vc.title = page.title vc.title = page.title
vc.userActivity = page.userActivity(accountID: mastodonController.accountInfo!.id) vc.userActivity = page.userActivity(accountID: mastodonController.accountInfo!.id)
vc.updatesNotificationsMarker = page == .all
return vc return vc
} }

View File

@ -387,16 +387,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
_ = try await mastodonController.run(req) _ = try await mastodonController.run(req)
} catch { } catch {
stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))") stateRestorationLogger.error("TimelineViewController: failed to update timeline marker: \(String(describing: error))")
#if canImport(Sentry)
if let error = error as? Client.Error,
case .networkError(_) = error.type {
return
}
let event = Event(error: error)
event.message = SentryMessage(formatted: "Failed to update timeline marker: \(String(describing: error))")
SentrySDK.capture(event: event)
#endif
} }
} }
} }
@ -573,12 +563,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@MainActor @MainActor
private func restoreStatusesFromMarkerPosition() async -> Bool { private func restoreStatusesFromMarkerPosition() async -> Bool {
do { do {
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timelines: [.home])) let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
guard let home = marker.home else { async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
return false async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0
}
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 let allStatuses = try await [status] + olderStatuses
await mastodonController.persistentContainer.addAll(statuses: allStatuses) await mastodonController.persistentContainer.addAll(statuses: allStatuses)
@ -590,21 +577,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
await apply(snapshot, animatingDifferences: false) await apply(snapshot, animatingDifferences: false)
collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top) collectionView.contentOffset = CGPoint(x: 0, y: -collectionView.adjustedContentInset.top)
stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(home.lastReadID)") stateRestorationLogger.debug("TimelineViewController: restored from timeline marker with last read ID: \(marker.lastReadID)")
return true return true
} catch { } catch {
stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))") stateRestorationLogger.error("TimelineViewController: failed to load from timeline marker: \(String(describing: error))")
#if canImport(Sentry)
if let error = error as? Client.Error,
case .networkError(_) = error.type {
return false
}
let event = Event(error: error)
event.message = SentryMessage(formatted: "Failed to load from timeline marker: \(String(describing: error))")
SentrySDK.capture(event: event)
#endif
return false return false
} }
} }