diff --git a/IceCubesApp/App/AppRouteur.swift b/IceCubesApp/App/AppRouteur.swift index da3aea2e..48402cf1 100644 --- a/IceCubesApp/App/AppRouteur.swift +++ b/IceCubesApp/App/AppRouteur.swift @@ -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): diff --git a/IceCubesApp/App/Tabs/TimelineTab.swift b/IceCubesApp/App/Tabs/TimelineTab.swift index a518da21..cfc1da78 100644 --- a/IceCubesApp/App/Tabs/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/TimelineTab.swift @@ -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") + } + + } } diff --git a/Packages/Env/Sources/Env/StreamWatcher.swift b/Packages/Env/Sources/Env/StreamWatcher.swift index 4e8eecad..c32a06e3 100644 --- a/Packages/Env/Sources/Env/StreamWatcher.swift +++ b/Packages/Env/Sources/Env/StreamWatcher.swift @@ -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": diff --git a/Packages/Models/Sources/Models/Stream/StreamEvent.swift b/Packages/Models/Sources/Models/Stream/StreamEvent.swift index c6e49369..21367199 100644 --- a/Packages/Models/Sources/Models/Stream/StreamEvent.swift +++ b/Packages/Models/Sources/Models/Stream/StreamEvent.swift @@ -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 } diff --git a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift index c422aa3e..34a7a19d 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift @@ -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) diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index 3bb67cd8..6aeea88a 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -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) { + _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") - } - - } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift index 10fa7875..5f06ea84 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift @@ -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)