diff --git a/IceCubesApp/IceCubesAppApp.swift b/IceCubesApp/IceCubesAppApp.swift index b868cd8b..38692671 100644 --- a/IceCubesApp/IceCubesAppApp.swift +++ b/IceCubesApp/IceCubesAppApp.swift @@ -13,8 +13,7 @@ struct IceCubesAppApp: App { TabView { ForEach(tabs, id: \.self) { tab in NavigationStack { - TimelineView(kind: .pub) - .environmentObject(Client(server: tab)) + TimelineView(client: .init(server: tab)) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { diff --git a/Packages/Network/Sources/Network/Client.swift b/Packages/Network/Sources/Network/Client.swift index 1796c9d5..832867d9 100644 --- a/Packages/Network/Sources/Network/Client.swift +++ b/Packages/Network/Sources/Network/Client.swift @@ -23,6 +23,7 @@ public class Client: ObservableObject { components.scheme = "https" components.host = server components.path += "/api/\(version.rawValue)/\(endpoint.path())" + components.queryItems = endpoint.queryItems() return components.url! } diff --git a/Packages/Network/Sources/Network/Endpoint/Endpoint.swift b/Packages/Network/Sources/Network/Endpoint/Endpoint.swift index 96b2bf16..3a875fc8 100644 --- a/Packages/Network/Sources/Network/Endpoint/Endpoint.swift +++ b/Packages/Network/Sources/Network/Endpoint/Endpoint.swift @@ -2,4 +2,5 @@ import Foundation public protocol Endpoint { func path() -> String + func queryItems() -> [URLQueryItem]? } diff --git a/Packages/Network/Sources/Network/Endpoint/Timeline.swift b/Packages/Network/Sources/Network/Endpoint/Timeline.swift index d3fa225f..9eec17be 100644 --- a/Packages/Network/Sources/Network/Endpoint/Timeline.swift +++ b/Packages/Network/Sources/Network/Endpoint/Timeline.swift @@ -1,7 +1,7 @@ import Foundation public enum Timeline: Endpoint { - case pub + case pub(sinceId: String?) public func path() -> String { switch self { @@ -9,4 +9,11 @@ public enum Timeline: Endpoint { return "timelines/public" } } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case .pub(let sinceId): + return [.init(name: "max_id", value: sinceId)] + } + } } diff --git a/Packages/Network/Sources/Network/Models/Status.swift b/Packages/Network/Sources/Network/Models/Status.swift index 834dad97..34bd33ad 100644 --- a/Packages/Network/Sources/Network/Models/Status.swift +++ b/Packages/Network/Sources/Network/Models/Status.swift @@ -4,4 +4,5 @@ public struct Status: Codable, Identifiable { public let id: String public let content: String public let account: Account + public let createdAt: String } diff --git a/Packages/Network/Sources/Network/ModelsExt/StatusExt.swift b/Packages/Network/Sources/Network/ModelsExt/StatusExt.swift index 94b78877..494ede6b 100644 --- a/Packages/Network/Sources/Network/ModelsExt/StatusExt.swift +++ b/Packages/Network/Sources/Network/ModelsExt/StatusExt.swift @@ -1,5 +1,5 @@ -import SwiftSoup import HTML2Markdown +import Foundation extension Status { public var contentAsMarkdown: String { @@ -10,4 +10,18 @@ extension Status { return content } } + + public var createdAtDate: Date { + let dateFormatter = DateFormatter() + dateFormatter.calendar = .init(identifier: .iso8601) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" + dateFormatter.timeZone = .init(abbreviation: "UTC") + return dateFormatter.date(from: createdAt)! + } + + public var createdAtFormatted: String { + let dateFormatter = RelativeDateTimeFormatter() + dateFormatter.unitsStyle = .abbreviated + return dateFormatter.localizedString(for: createdAtDate, relativeTo: Date()) + } } diff --git a/Packages/Timeline/Sources/Timeline/Status/StatusRowView.swift b/Packages/Timeline/Sources/Timeline/Status/StatusRowView.swift index 4fc46113..712606b7 100644 --- a/Packages/Timeline/Sources/Timeline/Status/StatusRowView.swift +++ b/Packages/Timeline/Sources/Timeline/Status/StatusRowView.swift @@ -6,7 +6,7 @@ struct StatusRowView: View { var body: some View { VStack(alignment: .leading) { - HStack { + HStack(alignment: .top) { AsyncImage( url: status.account.avatar, content: { image in @@ -27,6 +27,10 @@ struct StatusRowView: View { .font(.footnote) .foregroundColor(.gray) } + Spacer() + Text(status.createdAtFormatted) + .font(.footnote) + .foregroundColor(.gray) } Text(try! AttributedString(markdown: status.contentAsMarkdown)) } diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index 424568d2..44a642df 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -2,40 +2,52 @@ import SwiftUI import Network public struct TimelineView: View { - public enum Kind { - case pub, hastah, home, list - } + @StateObject private var viewModel: TimelineViewModel - @EnvironmentObject private var client: Client - - @State private var statuses: [Status] = [] - - private let kind: Kind - - public init(kind: Kind) { - self.kind = kind + public init(client: Client) { + _viewModel = StateObject(wrappedValue: TimelineViewModel(client: client)) } public var body: some View { - List(statuses) { status in - StatusRowView(status: status) + List { + switch viewModel.state { + case .loading: + loadingRow + case .error: + Text("An error occurred, please try to refresh") + case let .display(statuses, nextPageState): + ForEach(statuses) { status in + StatusRowView(status: status) + } + switch nextPageState { + case .hasNextPage: + loadingRow + .onAppear { + Task { + await viewModel.loadNextPage() + } + } + case .loadingNextPage: + loadingRow + } + } } .listStyle(.plain) - .navigationTitle("Public Timeline: \(client.server)") + .navigationTitle("Public Timeline: \(viewModel.serverName)") .navigationBarTitleDisplayMode(.inline) .task { - await refreshTimeline() + await viewModel.refreshTimeline() } .refreshable { - await refreshTimeline() + await viewModel.refreshTimeline() } } - private func refreshTimeline() async { - do { - self.statuses = try await client.fetchArray(endpoint: Timeline.pub) - } catch { - print(error.localizedDescription) + private var loadingRow: some View { + HStack { + Spacer() + ProgressView() + Spacer() } } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift new file mode 100644 index 00000000..2195954f --- /dev/null +++ b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift @@ -0,0 +1,48 @@ +import SwiftUI +import Network + +@MainActor +class TimelineViewModel: ObservableObject { + enum State { + enum PadingState { + case hasNextPage, loadingNextPage + } + case loading + case display(statuses: [Status], nextPageState: State.PadingState) + case error + } + + private let client: Client + private var statuses: [Status] = [] + + @Published var state: State = .loading + + var serverName: String { + client.server + } + + init(client: Client) { + self.client = client + } + + func refreshTimeline() async { + do { + statuses = try await client.fetchArray(endpoint: Timeline.pub(sinceId: nil)) + state = .display(statuses: statuses, nextPageState: .hasNextPage) + } catch { + print(error.localizedDescription) + } + } + + func loadNextPage() async { + do { + guard let lastId = statuses.last?.id else { return } + state = .display(statuses: statuses, nextPageState: .loadingNextPage) + let newStatuses: [Status] = try await client.fetch(endpoint: Timeline.pub(sinceId: lastId)) + statuses.append(contentsOf: newStatuses) + state = .display(statuses: statuses, nextPageState: .hasNextPage) + } catch { + print(error.localizedDescription) + } + } +}