From 962c7c0295e21abc983bd7acd0b0f32e0d7d7246 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Wed, 27 Dec 2023 13:26:30 +0100 Subject: [PATCH] Timeline: Basic timeline sync using the marker API --- .../App/Tabs/Timeline/TimelineTab.swift | 10 ++ .../Localization/Localizable.xcstrings | 143 ++++++++++++++++-- .../Sources/Network/Endpoint/Markers.swift | 5 +- .../Sources/Timeline/TimelineFilter.swift | 11 ++ .../Sources/Timeline/TimelineView.swift | 10 +- .../Sources/Timeline/TimelineViewModel.swift | 76 +++++++++- 6 files changed, 231 insertions(+), 24 deletions(-) diff --git a/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift index 05379a85..c9a34a84 100644 --- a/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift @@ -125,6 +125,16 @@ struct TimelineTab: View { } label: { Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "") } + if timeline == .home { + Button { + timeline = .resume + } label: { + VStack { + Label(TimelineFilter.resume.localizedTitle(), + systemImage: TimelineFilter.resume.iconName() ?? "") + } + } + } Divider() } ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in diff --git a/IceCubesApp/Resources/Localization/Localizable.xcstrings b/IceCubesApp/Resources/Localization/Localizable.xcstrings index bd78a407..dd93b650 100644 --- a/IceCubesApp/Resources/Localization/Localizable.xcstrings +++ b/IceCubesApp/Resources/Localization/Localizable.xcstrings @@ -10983,7 +10983,7 @@ }, "de" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Konto hinzufügen" } }, @@ -11013,13 +11013,13 @@ }, "fr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Ajouter un compte" } }, "it" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Aggiungi account" } }, @@ -11073,13 +11073,13 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "添加账户" } }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "新增帳戶" } } @@ -38368,7 +38368,7 @@ }, "de" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Konto hinzufügen" } }, @@ -38398,13 +38398,13 @@ }, "fr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Ajouter un compte" } }, "it" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Aggiungi un account" } }, @@ -38458,13 +38458,13 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "添加账户" } }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "新增帳戶" } } @@ -44190,8 +44190,8 @@ }, "fr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Show Account on Hover" + "state" : "translated", + "value" : "Afficher le popover du compte" } }, "it" : { @@ -72324,6 +72324,125 @@ } } }, + "timeline.resume" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resime" + } + }, + "ca" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "eu" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reprendre" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "nb" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Resume" + } + } + } + }, "timeline.trending" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/Network/Sources/Network/Endpoint/Markers.swift b/Packages/Network/Sources/Network/Endpoint/Markers.swift index c2e6661b..fb4a6fe8 100644 --- a/Packages/Network/Sources/Network/Endpoint/Markers.swift +++ b/Packages/Network/Sources/Network/Endpoint/Markers.swift @@ -11,12 +11,13 @@ public enum Markers: Endpoint { public func queryItems() -> [URLQueryItem]? { switch self { + case .markers: + [URLQueryItem(name: "timeline[]", value: "home"), + URLQueryItem(name: "timeline[]", value: "notifications")] case let .markNotifications(lastReadId): [URLQueryItem(name: "notifications[last_read_id]", value: lastReadId)] case let .markHome(lastReadId): [URLQueryItem(name: "home[last_read_id]", value: lastReadId)] - default: - nil } } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift index d6cb2abe..e89067fb 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift @@ -36,6 +36,7 @@ public enum TimelineFilter: Hashable, Equatable { case list(list: Models.List) case remoteLocal(server: String, filter: RemoteTimelineFilter) case latest + case resume public func hash(into hasher: inout Hasher) { hasher.combine(title) @@ -63,6 +64,8 @@ public enum TimelineFilter: Hashable, Equatable { switch self { case .latest: "Latest" + case .resume: + "Resume" case .federated: "Federated" case .local: @@ -86,6 +89,8 @@ public enum TimelineFilter: Hashable, Equatable { switch self { case .latest: "timeline.latest" + case .resume: + "timeline.resume" case .federated: "timeline.federated" case .local: @@ -109,6 +114,8 @@ public enum TimelineFilter: Hashable, Equatable { switch self { case .latest: "arrow.counterclockwise" + case .resume: + "clock.arrow.2.circlepath" case .federated: "globe.americas" case .local: @@ -140,6 +147,7 @@ public enum TimelineFilter: Hashable, Equatable { return Trends.statuses(offset: offset) } case .latest: return Timelines.home(sinceId: nil, maxId: nil, minId: nil) + case .resume: return Timelines.home(sinceId: nil, maxId: nil, minId: nil) case .home: return Timelines.home(sinceId: sinceId, maxId: maxId, minId: minId) case .trending: return Trends.statuses(offset: offset) case let .list(list): return Timelines.list(listId: list.id, sinceId: sinceId, maxId: maxId, minId: minId) @@ -172,6 +180,7 @@ extension TimelineFilter: Codable { case list case remoteLocal case latest + case resume } public init(from decoder: Decoder) throws { @@ -255,6 +264,8 @@ extension TimelineFilter: Codable { try nestedContainer.encode(filter) case .latest: try container.encode(CodingKeys.latest.rawValue, forKey: .latest) + case .resume: + try container.encode(CodingKeys.resume.rawValue, forKey: .latest) } } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index 836678eb..85f75aa5 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -34,7 +34,8 @@ public struct TimelineView: View { public init(timeline: Binding, selectedTagGroup: Binding, - scrollToTopSignal: Binding, canFilterTimeline: Bool) + scrollToTopSignal: Binding, + canFilterTimeline: Bool) { _timeline = timeline _selectedTagGroup = selectedTagGroup @@ -111,6 +112,7 @@ public struct TimelineView: View { } .onDisappear { viewModel.isTimelineVisible = false + viewModel.saveMarker() } .refreshable { SoundEffectManager.shared.playSound(.pull) @@ -145,7 +147,8 @@ public struct TimelineView: View { } case .background: wasBackgrounded = true - + viewModel.saveMarker() + default: break } @@ -243,6 +246,9 @@ public struct TimelineView: View { Text(timeline.localizedTitle()) .font(.caption) .foregroundStyle(.secondary) + case .home: + Text(timeline.localizedTitle()) + .font(.headline) default: Text(timeline.localizedTitle()) .font(.headline) diff --git a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift index 02975522..8481a381 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift @@ -10,23 +10,34 @@ import SwiftUI var scrollToIndex: Int? var statusesState: StatusesState = .loading var timeline: TimelineFilter = .federated { + willSet { + if timeline == .home && newValue != .resume { + saveMarker() + } + } didSet { timelineTask?.cancel() timelineTask = Task { - if timeline == .latest { + if timeline == .latest || timeline == .resume { if oldValue == .home { await clearHomeCache() } + if timeline == .resume, let marker = await fetchMarker() { + self.marker = marker + } timeline = oldValue } + if oldValue != timeline { await reset() pendingStatusesObserver.pendingStatuses = [] tag = nil } + guard !Task.isCancelled else { return } + await fetchNewestStatuses() switch timeline { case let .hashtag(tag, _): @@ -77,7 +88,8 @@ import SwiftUI var isTimelineVisible: Bool = false let pendingStatusesObserver: PendingStatusesObserver = .init() var scrollToIndexAnimated: Bool = false - + var marker: Marker.Content? + init() { pendingStatusesObserver.scrollToIndex = { [weak self] index in self?.scrollToIndexAnimated = true @@ -174,19 +186,42 @@ extension TimelineViewModel: StatusesFetcher { await fetchNewestStatuses() } } + + func fetchStatuses(from: Marker.Content) async throws { + guard let client else { return } + statusesState = .loading + var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil, + maxId: from.lastReadId, + minId: nil, + offset: 0)) + + ReblogCache.shared.removeDuplicateReblogs(&statuses) + StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client) + + await datasource.set(statuses) + await cacheHome() + marker = nil + + withAnimation { + statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) + } + + await fetchNewestStatuses() + } func fetchNewestStatuses() async { guard let client else { return } do { - if await datasource.isEmpty { + if let marker { + try await fetchStatuses(from: marker) + } else if await datasource.isEmpty { try await fetchFirstPage(client: client) } else if let latest = await datasource.get().first, timeline.supportNewestPagination { - try await fetchNewPagesFrom(latestStatus: latest, client: client) + try await fetchNewPagesFrom(latestStatus: latest.id, client: client) } } catch { statusesState = .error(error: error) canStreamEvents = true - print("timeline parse error: \(error)") } } @@ -241,10 +276,10 @@ extension TimelineViewModel: StatusesFetcher { } // Fetch pages from the top most status of the tomeline. - private func fetchNewPagesFrom(latestStatus: Status, client: Client) async throws { + private func fetchNewPagesFrom(latestStatus: String, client: Client) async throws { canStreamEvents = false let initialTimeline = timeline - var newStatuses: [Status] = await fetchNewPages(minId: latestStatus.id, maxPages: 10) + var newStatuses: [Status] = await fetchNewPages(minId: latestStatus, maxPages: 10) // Dedup statuses, a status with the same id could have been streamed in. let ids = await datasource.get().map(\.id) @@ -321,7 +356,7 @@ extension TimelineViewModel: StatusesFetcher { !Task.isCancelled, let latest = await datasource.get().first { - try await fetchNewPagesFrom(latestStatus: latest, client: client) + try await fetchNewPagesFrom(latestStatus: latest.id, client: client) } } @@ -392,3 +427,28 @@ extension TimelineViewModel: StatusesFetcher { visibileStatusesIds.remove(status.id) } } + +// MARK: - MARKER +extension TimelineViewModel { + func fetchMarker() async -> Marker.Content? { + guard let client else { + return nil + } + do { + let data: Marker = try await client.get(endpoint: Markers.markers) + return data.home + } catch { + return nil + } + } + + func saveMarker() { + guard timeline == .home, let client else { return } + Task { + guard let id = await cache.getLatestSeenStatus(for: client)?.first else { return } + do { + let _: Marker = try await client.post(endpoint: Markers.markHome(lastReadId: id)) + } catch { } + } + } +}