Timeline: Basic timeline sync using the marker API

This commit is contained in:
Thomas Ricouard 2023-12-27 13:26:30 +01:00
parent 590299d102
commit 962c7c0295
6 changed files with 231 additions and 24 deletions

View File

@ -125,6 +125,16 @@ struct TimelineTab: View {
} label: { } label: {
Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "") 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() Divider()
} }
ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in

View File

@ -10983,7 +10983,7 @@
}, },
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "Konto hinzufügen" "value" : "Konto hinzufügen"
} }
}, },
@ -11013,13 +11013,13 @@
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "Ajouter un compte" "value" : "Ajouter un compte"
} }
}, },
"it" : { "it" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "Aggiungi account" "value" : "Aggiungi account"
} }
}, },
@ -11073,13 +11073,13 @@
}, },
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "添加账户" "value" : "添加账户"
} }
}, },
"zh-Hant" : { "zh-Hant" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "新增帳戶" "value" : "新增帳戶"
} }
} }
@ -38368,7 +38368,7 @@
}, },
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "Konto hinzufügen" "value" : "Konto hinzufügen"
} }
}, },
@ -38398,13 +38398,13 @@
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "Ajouter un compte" "value" : "Ajouter un compte"
} }
}, },
"it" : { "it" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "Aggiungi un account" "value" : "Aggiungi un account"
} }
}, },
@ -38458,13 +38458,13 @@
}, },
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "添加账户" "value" : "添加账户"
} }
}, },
"zh-Hant" : { "zh-Hant" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "新增帳戶" "value" : "新增帳戶"
} }
} }
@ -44190,8 +44190,8 @@
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "needs_review", "state" : "translated",
"value" : "Show Account on Hover" "value" : "Afficher le popover du compte"
} }
}, },
"it" : { "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" : { "timeline.trending" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {

View File

@ -11,12 +11,13 @@ public enum Markers: Endpoint {
public func queryItems() -> [URLQueryItem]? { public func queryItems() -> [URLQueryItem]? {
switch self { switch self {
case .markers:
[URLQueryItem(name: "timeline[]", value: "home"),
URLQueryItem(name: "timeline[]", value: "notifications")]
case let .markNotifications(lastReadId): case let .markNotifications(lastReadId):
[URLQueryItem(name: "notifications[last_read_id]", value: lastReadId)] [URLQueryItem(name: "notifications[last_read_id]", value: lastReadId)]
case let .markHome(lastReadId): case let .markHome(lastReadId):
[URLQueryItem(name: "home[last_read_id]", value: lastReadId)] [URLQueryItem(name: "home[last_read_id]", value: lastReadId)]
default:
nil
} }
} }
} }

View File

@ -36,6 +36,7 @@ public enum TimelineFilter: Hashable, Equatable {
case list(list: Models.List) case list(list: Models.List)
case remoteLocal(server: String, filter: RemoteTimelineFilter) case remoteLocal(server: String, filter: RemoteTimelineFilter)
case latest case latest
case resume
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(title) hasher.combine(title)
@ -63,6 +64,8 @@ public enum TimelineFilter: Hashable, Equatable {
switch self { switch self {
case .latest: case .latest:
"Latest" "Latest"
case .resume:
"Resume"
case .federated: case .federated:
"Federated" "Federated"
case .local: case .local:
@ -86,6 +89,8 @@ public enum TimelineFilter: Hashable, Equatable {
switch self { switch self {
case .latest: case .latest:
"timeline.latest" "timeline.latest"
case .resume:
"timeline.resume"
case .federated: case .federated:
"timeline.federated" "timeline.federated"
case .local: case .local:
@ -109,6 +114,8 @@ public enum TimelineFilter: Hashable, Equatable {
switch self { switch self {
case .latest: case .latest:
"arrow.counterclockwise" "arrow.counterclockwise"
case .resume:
"clock.arrow.2.circlepath"
case .federated: case .federated:
"globe.americas" "globe.americas"
case .local: case .local:
@ -140,6 +147,7 @@ public enum TimelineFilter: Hashable, Equatable {
return Trends.statuses(offset: offset) return Trends.statuses(offset: offset)
} }
case .latest: return Timelines.home(sinceId: nil, maxId: nil, minId: nil) 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 .home: return Timelines.home(sinceId: sinceId, maxId: maxId, minId: minId)
case .trending: return Trends.statuses(offset: offset) case .trending: return Trends.statuses(offset: offset)
case let .list(list): return Timelines.list(listId: list.id, sinceId: sinceId, maxId: maxId, minId: minId) 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 list
case remoteLocal case remoteLocal
case latest case latest
case resume
} }
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
@ -255,6 +264,8 @@ extension TimelineFilter: Codable {
try nestedContainer.encode(filter) try nestedContainer.encode(filter)
case .latest: case .latest:
try container.encode(CodingKeys.latest.rawValue, forKey: .latest) try container.encode(CodingKeys.latest.rawValue, forKey: .latest)
case .resume:
try container.encode(CodingKeys.resume.rawValue, forKey: .latest)
} }
} }
} }

View File

@ -34,7 +34,8 @@ public struct TimelineView: View {
public init(timeline: Binding<TimelineFilter>, public init(timeline: Binding<TimelineFilter>,
selectedTagGroup: Binding<TagGroup?>, selectedTagGroup: Binding<TagGroup?>,
scrollToTopSignal: Binding<Int>, canFilterTimeline: Bool) scrollToTopSignal: Binding<Int>,
canFilterTimeline: Bool)
{ {
_timeline = timeline _timeline = timeline
_selectedTagGroup = selectedTagGroup _selectedTagGroup = selectedTagGroup
@ -111,6 +112,7 @@ public struct TimelineView: View {
} }
.onDisappear { .onDisappear {
viewModel.isTimelineVisible = false viewModel.isTimelineVisible = false
viewModel.saveMarker()
} }
.refreshable { .refreshable {
SoundEffectManager.shared.playSound(.pull) SoundEffectManager.shared.playSound(.pull)
@ -145,7 +147,8 @@ public struct TimelineView: View {
} }
case .background: case .background:
wasBackgrounded = true wasBackgrounded = true
viewModel.saveMarker()
default: default:
break break
} }
@ -243,6 +246,9 @@ public struct TimelineView: View {
Text(timeline.localizedTitle()) Text(timeline.localizedTitle())
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
case .home:
Text(timeline.localizedTitle())
.font(.headline)
default: default:
Text(timeline.localizedTitle()) Text(timeline.localizedTitle())
.font(.headline) .font(.headline)

View File

@ -10,23 +10,34 @@ import SwiftUI
var scrollToIndex: Int? var scrollToIndex: Int?
var statusesState: StatusesState = .loading var statusesState: StatusesState = .loading
var timeline: TimelineFilter = .federated { var timeline: TimelineFilter = .federated {
willSet {
if timeline == .home && newValue != .resume {
saveMarker()
}
}
didSet { didSet {
timelineTask?.cancel() timelineTask?.cancel()
timelineTask = Task { timelineTask = Task {
if timeline == .latest { if timeline == .latest || timeline == .resume {
if oldValue == .home { if oldValue == .home {
await clearHomeCache() await clearHomeCache()
} }
if timeline == .resume, let marker = await fetchMarker() {
self.marker = marker
}
timeline = oldValue timeline = oldValue
} }
if oldValue != timeline { if oldValue != timeline {
await reset() await reset()
pendingStatusesObserver.pendingStatuses = [] pendingStatusesObserver.pendingStatuses = []
tag = nil tag = nil
} }
guard !Task.isCancelled else { guard !Task.isCancelled else {
return return
} }
await fetchNewestStatuses() await fetchNewestStatuses()
switch timeline { switch timeline {
case let .hashtag(tag, _): case let .hashtag(tag, _):
@ -77,7 +88,8 @@ import SwiftUI
var isTimelineVisible: Bool = false var isTimelineVisible: Bool = false
let pendingStatusesObserver: PendingStatusesObserver = .init() let pendingStatusesObserver: PendingStatusesObserver = .init()
var scrollToIndexAnimated: Bool = false var scrollToIndexAnimated: Bool = false
var marker: Marker.Content?
init() { init() {
pendingStatusesObserver.scrollToIndex = { [weak self] index in pendingStatusesObserver.scrollToIndex = { [weak self] index in
self?.scrollToIndexAnimated = true self?.scrollToIndexAnimated = true
@ -174,19 +186,42 @@ extension TimelineViewModel: StatusesFetcher {
await fetchNewestStatuses() 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 { func fetchNewestStatuses() async {
guard let client else { return } guard let client else { return }
do { do {
if await datasource.isEmpty { if let marker {
try await fetchStatuses(from: marker)
} else if await datasource.isEmpty {
try await fetchFirstPage(client: client) try await fetchFirstPage(client: client)
} else if let latest = await datasource.get().first, timeline.supportNewestPagination { } 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 { } catch {
statusesState = .error(error: error) statusesState = .error(error: error)
canStreamEvents = true canStreamEvents = true
print("timeline parse error: \(error)")
} }
} }
@ -241,10 +276,10 @@ extension TimelineViewModel: StatusesFetcher {
} }
// Fetch pages from the top most status of the tomeline. // 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 canStreamEvents = false
let initialTimeline = timeline 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. // Dedup statuses, a status with the same id could have been streamed in.
let ids = await datasource.get().map(\.id) let ids = await datasource.get().map(\.id)
@ -321,7 +356,7 @@ extension TimelineViewModel: StatusesFetcher {
!Task.isCancelled, !Task.isCancelled,
let latest = await datasource.get().first 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) 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 { }
}
}
}