Refactor live timeline + handle more events
This commit is contained in:
parent
b04ccc18fa
commit
fded30bb76
|
@ -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):
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
pendingStatuses = []
|
||||
if statuses.isEmpty {
|
||||
pendingStatuses = []
|
||||
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))
|
||||
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 {
|
||||
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)
|
||||
|
|
Loading…
Reference in New Issue