Refactor live timeline + handle more events

This commit is contained in:
Thomas Ricouard 2022-12-26 07:36:54 +01:00
parent b04ccc18fa
commit fded30bb76
7 changed files with 101 additions and 46 deletions

View file

@ -16,7 +16,7 @@ extension View {
case let .statusDetail(id):
StatusDetailView(statusId: id)
case let .hashTag(tag, accountId):
TimelineView(timeline: .hashtag(tag: tag, accountId: accountId))
TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)))
case let .following(id):
AccountsListView(mode: .followers(accountId: id))
case let .followers(id):

View file

@ -8,10 +8,11 @@ struct TimelineTab: View {
@EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: IceCubesApp.Tab
@State private var timeline: TimelineFilter = .home
var body: some View {
NavigationStack(path: $routeurPath.path) {
TimelineView()
TimelineView(timeline: $timeline)
.withAppRouteur()
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
.toolbar {
@ -23,9 +24,17 @@ struct TimelineTab: View {
Image(systemName: "square.and.pencil")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
timelineFilterButton
}
}
}
}
.onAppear {
if !client.isAuth {
timeline = .pub
}
}
.environmentObject(routeurPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .timeline {
@ -33,4 +42,20 @@ struct TimelineTab: View {
}
}
}
private var timelineFilterButton: some View {
Menu {
ForEach(TimelineFilter.availableTimeline(), id: \.self) { timeline in
Button {
self.timeline = timeline
} label: {
Text(timeline.title())
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
}

View file

@ -108,6 +108,9 @@ public class StreamWatcher: ObservableObject {
case "update":
let status = try decoder.decode(Status.self, from: payloadData)
return StreamEventUpdate(status: status)
case "status.update":
let status = try decoder.decode(Status.self, from: payloadData)
return StreamEventStatusUpdate(status: status)
case "delete":
return StreamEventDelete(status: rawEvent.payload)
case "notification":

View file

@ -20,6 +20,15 @@ public struct StreamEventUpdate: StreamEvent {
}
}
public struct StreamEventStatusUpdate: StreamEvent {
public let date = Date()
public var id: String { status.id }
public let status: Status
public init(status: Status) {
self.status = status
}
}
public struct StreamEventDelete: StreamEvent {
public let date = Date()
public var id: String { status + date.description }

View file

@ -10,11 +10,11 @@ public enum TimelineFilter: Hashable, Equatable {
hasher.combine(title())
}
static func availableTimeline() -> [TimelineFilter] {
public static func availableTimeline() -> [TimelineFilter] {
return [.pub, .home]
}
func title() -> String {
public func title() -> String {
switch self {
case .pub:
return "Public"
@ -25,7 +25,7 @@ public enum TimelineFilter: Hashable, Equatable {
}
}
func endpoint(sinceId: String?, maxId: String?) -> Endpoint {
public func endpoint(sinceId: String?, maxId: String?) -> Endpoint {
switch self {
case .pub: return Timelines.pub(sinceId: sinceId, maxId: maxId)
case .home: return Timelines.home(sinceId: sinceId, maxId: maxId)

View file

@ -11,11 +11,10 @@ public struct TimelineView: View {
@EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var client: Client
@StateObject private var viewModel = TimelineViewModel()
@Binding var timeline: TimelineFilter
private let filter: TimelineFilter?
public init(timeline: TimelineFilter? = nil) {
self.filter = timeline
public init(timeline: Binding<TimelineFilter>) {
_timeline = timeline
}
public var body: some View {
@ -35,31 +34,25 @@ public struct TimelineView: View {
}
}
}
.navigationTitle(filter?.title() ?? viewModel.timeline.title())
.navigationTitle(timeline.title())
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if client.isAuth {
ToolbarItem(placement: .navigationBarTrailing) {
timelineFilterButton
}
}
}
.onAppear {
viewModel.client = client
if let filter {
viewModel.timeline = filter
} else {
viewModel.timeline = client.isAuth ? .home : .pub
}
viewModel.timeline = timeline
}
.refreshable {
await viewModel.fetchStatuses()
Task {
await viewModel.fetchStatuses(userIntent: true)
}
}
.onChange(of: watcher.latestEvent?.id) { id in
if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent, currentAccount: account)
}
}
.onChange(of: timeline) { newTimeline in
viewModel.timeline = timeline
}
}
@ViewBuilder
@ -69,7 +62,7 @@ public struct TimelineView: View {
proxy.scrollTo("top")
viewModel.displayPendingStatuses()
} label: {
Text("\(viewModel.pendingStatuses.count) new posts")
Text(viewModel.pendingStatusesButtonTitle)
}
.buttonStyle(.bordered)
.background(.thinMaterial)
@ -107,19 +100,4 @@ public struct TimelineView: View {
.background(.gray.opacity(0.15))
}
}
private var timelineFilterButton: some View {
Menu {
ForEach(TimelineFilter.availableTimeline(), id: \.self) { filter in
Button {
viewModel.timeline = filter
} label: {
Text(filter.title())
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
}

View file

@ -8,6 +8,7 @@ import Env
class TimelineViewModel: ObservableObject, StatusesFetcher {
var client: Client?
// Internal source of truth for a timeline.
private var statuses: [Status] = []
@Published var statusesState: StatusesState = .loading
@ -17,7 +18,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
if oldValue != timeline {
statuses = []
}
await fetchStatuses()
await fetchStatuses(userIntent: false)
switch timeline {
case let .hashtag(tag, _):
await fetchTag(id: tag)
@ -28,24 +29,53 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
}
}
@Published var tag: Tag?
enum PendingStatusesState {
case refresh, stream
}
@Published var pendingStatuses: [Status] = []
@Published var pendingStatusesState: PendingStatusesState = .stream
var pendingStatusesButtonTitle: String {
switch pendingStatusesState {
case .stream:
return "\(pendingStatuses.count) new posts"
case .refresh:
return "See new posts"
}
}
var pendingStatusesEnabled: Bool {
timeline == .home
}
var serverName: String {
client?.server ?? "Error"
}
func fetchStatuses() async {
await fetchStatuses(userIntent: false)
}
func fetchStatuses(userIntent: Bool) async {
guard let client else { return }
do {
if statuses.isEmpty {
pendingStatuses = []
if statuses.isEmpty {
statusesState = .loading
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil))
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else if let first = statuses.first {
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: first.id, maxId: nil))
if userIntent || !pendingStatusesEnabled {
statuses.insert(contentsOf: newStatuses, at: 0)
}
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else {
pendingStatuses = newStatuses
pendingStatusesState = .refresh
}
}
} catch {
statusesState = .error(error: error)
print("timeline parse error: \(error)")
@ -87,22 +117,32 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
}
func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) {
guard timeline == .home else { return }
if let event = event as? StreamEventUpdate {
if event.status.account.id == currentAccount.account?.id {
if event.status.account.id == currentAccount.account?.id,
timeline == .home {
statuses.insert(event.status, at: 0)
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else {
} else if pendingStatusesEnabled,
!statuses.contains(where: { $0.id == event.status.id }) {
pendingStatuses.insert(event.status, at: 0)
pendingStatusesState = .stream
}
} else if let event = event as? StreamEventDelete {
statuses.removeAll(where: { $0.id == event.status })
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else if let event = event as? StreamEventStatusUpdate {
if let originalIndex = statuses.firstIndex(where: { $0.id == event.status.id }) {
statuses[originalIndex] = event.status
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
}
}
func displayPendingStatuses() {
guard timeline == .home else { return }
pendingStatuses = pendingStatuses.filter { status in
!statuses.contains(where: { $0.id == status.id })
}
statuses.insert(contentsOf: pendingStatuses, at: 0)
pendingStatuses = []
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)