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): case let .statusDetail(id):
StatusDetailView(statusId: id) StatusDetailView(statusId: id)
case let .hashTag(tag, accountId): case let .hashTag(tag, accountId):
TimelineView(timeline: .hashtag(tag: tag, accountId: accountId)) TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)))
case let .following(id): case let .following(id):
AccountsListView(mode: .followers(accountId: id)) AccountsListView(mode: .followers(accountId: id))
case let .followers(id): case let .followers(id):

View file

@ -8,10 +8,11 @@ struct TimelineTab: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: IceCubesApp.Tab @Binding var popToRootTab: IceCubesApp.Tab
@State private var timeline: TimelineFilter = .home
var body: some View { var body: some View {
NavigationStack(path: $routeurPath.path) { NavigationStack(path: $routeurPath.path) {
TimelineView() TimelineView(timeline: $timeline)
.withAppRouteur() .withAppRouteur()
.withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet) .withSheetDestinations(sheetDestinations: $routeurPath.presentedSheet)
.toolbar { .toolbar {
@ -23,9 +24,17 @@ struct TimelineTab: View {
Image(systemName: "square.and.pencil") Image(systemName: "square.and.pencil")
} }
} }
ToolbarItem(placement: .navigationBarTrailing) {
timelineFilterButton
}
} }
} }
} }
.onAppear {
if !client.isAuth {
timeline = .pub
}
}
.environmentObject(routeurPath) .environmentObject(routeurPath)
.onChange(of: $popToRootTab.wrappedValue) { popToRootTab in .onChange(of: $popToRootTab.wrappedValue) { popToRootTab in
if popToRootTab == .timeline { 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": case "update":
let status = try decoder.decode(Status.self, from: payloadData) let status = try decoder.decode(Status.self, from: payloadData)
return StreamEventUpdate(status: status) return StreamEventUpdate(status: status)
case "status.update":
let status = try decoder.decode(Status.self, from: payloadData)
return StreamEventStatusUpdate(status: status)
case "delete": case "delete":
return StreamEventDelete(status: rawEvent.payload) return StreamEventDelete(status: rawEvent.payload)
case "notification": 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 struct StreamEventDelete: StreamEvent {
public let date = Date() public let date = Date()
public var id: String { status + date.description } public var id: String { status + date.description }

View file

@ -10,11 +10,11 @@ public enum TimelineFilter: Hashable, Equatable {
hasher.combine(title()) hasher.combine(title())
} }
static func availableTimeline() -> [TimelineFilter] { public static func availableTimeline() -> [TimelineFilter] {
return [.pub, .home] return [.pub, .home]
} }
func title() -> String { public func title() -> String {
switch self { switch self {
case .pub: case .pub:
return "Public" 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 { switch self {
case .pub: return Timelines.pub(sinceId: sinceId, maxId: maxId) case .pub: return Timelines.pub(sinceId: sinceId, maxId: maxId)
case .home: return Timelines.home(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 watcher: StreamWatcher
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var viewModel = TimelineViewModel() @StateObject private var viewModel = TimelineViewModel()
@Binding var timeline: TimelineFilter
private let filter: TimelineFilter? public init(timeline: Binding<TimelineFilter>) {
_timeline = timeline
public init(timeline: TimelineFilter? = nil) {
self.filter = timeline
} }
public var body: some View { public var body: some View {
@ -35,31 +34,25 @@ public struct TimelineView: View {
} }
} }
} }
.navigationTitle(filter?.title() ?? viewModel.timeline.title()) .navigationTitle(timeline.title())
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar {
if client.isAuth {
ToolbarItem(placement: .navigationBarTrailing) {
timelineFilterButton
}
}
}
.onAppear { .onAppear {
viewModel.client = client viewModel.client = client
if let filter { viewModel.timeline = timeline
viewModel.timeline = filter
} else {
viewModel.timeline = client.isAuth ? .home : .pub
}
} }
.refreshable { .refreshable {
await viewModel.fetchStatuses() Task {
await viewModel.fetchStatuses(userIntent: true)
}
} }
.onChange(of: watcher.latestEvent?.id) { id in .onChange(of: watcher.latestEvent?.id) { id in
if let latestEvent = watcher.latestEvent { if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent, currentAccount: account) viewModel.handleEvent(event: latestEvent, currentAccount: account)
} }
} }
.onChange(of: timeline) { newTimeline in
viewModel.timeline = timeline
}
} }
@ViewBuilder @ViewBuilder
@ -69,7 +62,7 @@ public struct TimelineView: View {
proxy.scrollTo("top") proxy.scrollTo("top")
viewModel.displayPendingStatuses() viewModel.displayPendingStatuses()
} label: { } label: {
Text("\(viewModel.pendingStatuses.count) new posts") Text(viewModel.pendingStatusesButtonTitle)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.background(.thinMaterial) .background(.thinMaterial)
@ -107,19 +100,4 @@ public struct TimelineView: View {
.background(.gray.opacity(0.15)) .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 { class TimelineViewModel: ObservableObject, StatusesFetcher {
var client: Client? var client: Client?
// Internal source of truth for a timeline.
private var statuses: [Status] = [] private var statuses: [Status] = []
@Published var statusesState: StatusesState = .loading @Published var statusesState: StatusesState = .loading
@ -17,7 +18,7 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
if oldValue != timeline { if oldValue != timeline {
statuses = [] statuses = []
} }
await fetchStatuses() await fetchStatuses(userIntent: false)
switch timeline { switch timeline {
case let .hashtag(tag, _): case let .hashtag(tag, _):
await fetchTag(id: tag) await fetchTag(id: tag)
@ -28,24 +29,53 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} }
} }
@Published var tag: Tag? @Published var tag: Tag?
enum PendingStatusesState {
case refresh, stream
}
@Published var pendingStatuses: [Status] = [] @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 { var serverName: String {
client?.server ?? "Error" client?.server ?? "Error"
} }
func fetchStatuses() async { func fetchStatuses() async {
await fetchStatuses(userIntent: false)
}
func fetchStatuses(userIntent: Bool) async {
guard let client else { return } guard let client else { return }
do { do {
pendingStatuses = []
if statuses.isEmpty { if statuses.isEmpty {
pendingStatuses = []
statusesState = .loading statusesState = .loading
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil)) statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil))
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else if let first = statuses.first { } else if let first = statuses.first {
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: first.id, maxId: nil)) let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: first.id, maxId: nil))
statuses.insert(contentsOf: newStatuses, at: 0) if userIntent || !pendingStatusesEnabled {
statuses.insert(contentsOf: newStatuses, at: 0)
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else {
pendingStatuses = newStatuses
pendingStatusesState = .refresh
}
} }
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} catch { } catch {
statusesState = .error(error: error) statusesState = .error(error: error)
print("timeline parse error: \(error)") print("timeline parse error: \(error)")
@ -87,22 +117,32 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
} }
func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) { func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) {
guard timeline == .home else { return }
if let event = event as? StreamEventUpdate { 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) statuses.insert(event.status, at: 0)
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} else { } else if pendingStatusesEnabled,
!statuses.contains(where: { $0.id == event.status.id }) {
pendingStatuses.insert(event.status, at: 0) pendingStatuses.insert(event.status, at: 0)
pendingStatusesState = .stream
} }
} else if let event = event as? StreamEventDelete { } else if let event = event as? StreamEventDelete {
statuses.removeAll(where: { $0.id == event.status }) statuses.removeAll(where: { $0.id == event.status })
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) 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() { func displayPendingStatuses() {
guard timeline == .home else { return } guard timeline == .home else { return }
pendingStatuses = pendingStatuses.filter { status in
!statuses.contains(where: { $0.id == status.id })
}
statuses.insert(contentsOf: pendingStatuses, at: 0) statuses.insert(contentsOf: pendingStatuses, at: 0)
pendingStatuses = [] pendingStatuses = []
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)