mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-26 10:11:00 +00:00
Refactor live timeline + handle more events
This commit is contained in:
parent
b04ccc18fa
commit
fded30bb76
7 changed files with 101 additions and 46 deletions
|
@ -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):
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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":
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue