From d88d9db1dc3f7f77b277c0aa007dc87216d35597 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 30 Jan 2023 21:41:42 +0100 Subject: [PATCH] Transition Timeline to List + stream post automatically + keep position + new counter UI --- .../App/Tabs/Timeline/TimelineTab.swift | 2 +- .../Sources/Account/AccountDetailView.swift | 2 +- .../Account/AccountDetailViewModel.swift | 4 + .../Status/Detail/StatusDetailView.swift | 1 - .../Sources/Status/List/StatusesFetcher.swift | 1 + .../Status/List/StatusesListView.swift | 92 ++++++++++------ .../Sources/Status/Row/StatusRowView.swift | 3 + .../Sources/Timeline/TimelineView.swift | 103 +++++++++--------- .../Sources/Timeline/TimelineViewModel.swift | 48 ++------ 9 files changed, 132 insertions(+), 124 deletions(-) diff --git a/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift index 432f9e01..0c03d54e 100644 --- a/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift @@ -144,7 +144,7 @@ struct TimelineTab: View { if UIDevice.current.userInterfaceIdiom != .pad { ToolbarItem(placement: .navigationBarLeading) { AppAccountsSelectorView(routerPath: routerPath) - .id(client.id) + .id(currentAccount.account?.id) } } statusEditorToolbarItem(routerPath: routerPath, diff --git a/Packages/Account/Sources/Account/AccountDetailView.swift b/Packages/Account/Sources/Account/AccountDetailView.swift index 2d59a47a..041ba398 100644 --- a/Packages/Account/Sources/Account/AccountDetailView.swift +++ b/Packages/Account/Sources/Account/AccountDetailView.swift @@ -69,7 +69,7 @@ public struct AccountDetailView: View { if viewModel.selectedTab == .statuses { pinnedPostsView } - StatusesListView(fetcher: viewModel) + StatusesListView(fetcher: viewModel, isEmbdedInList: false) case .followedTags: tagsListView case .lists: diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index 1141f69b..0d53e5af 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -246,4 +246,8 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher { } } } + + func statusDidAppear(status: Models.Status) async { + + } } diff --git a/Packages/Status/Sources/Status/Detail/StatusDetailView.swift b/Packages/Status/Sources/Status/Detail/StatusDetailView.swift index b07261c5..1bded040 100644 --- a/Packages/Status/Sources/Status/Detail/StatusDetailView.swift +++ b/Packages/Status/Sources/Status/Detail/StatusDetailView.swift @@ -73,7 +73,6 @@ public struct StatusDetailView: View { } } } - .frame(maxWidth: .maxColumnWidth) } .padding(.top, .layoutPadding) } diff --git a/Packages/Status/Sources/Status/List/StatusesFetcher.swift b/Packages/Status/Sources/Status/List/StatusesFetcher.swift index 9a124e50..09a0fbca 100644 --- a/Packages/Status/Sources/Status/List/StatusesFetcher.swift +++ b/Packages/Status/Sources/Status/List/StatusesFetcher.swift @@ -16,4 +16,5 @@ public protocol StatusesFetcher: ObservableObject { var statusesState: StatusesState { get } func fetchStatuses() async func fetchNextPage() async + func statusDidAppear(status: Status) async } diff --git a/Packages/Status/Sources/Status/List/StatusesListView.swift b/Packages/Status/Sources/Status/List/StatusesListView.swift index 69785575..0c018f09 100644 --- a/Packages/Status/Sources/Status/List/StatusesListView.swift +++ b/Packages/Status/Sources/Status/List/StatusesListView.swift @@ -4,62 +4,83 @@ import Shimmer import SwiftUI public struct StatusesListView: View where Fetcher: StatusesFetcher { + @EnvironmentObject private var theme: Theme + @ObservedObject private var fetcher: Fetcher private let isRemote: Bool + private let isEmbdedInList: Bool - public init(fetcher: Fetcher, isRemote: Bool = false) { + public init(fetcher: Fetcher, isRemote: Bool = false, isEmbdedInList: Bool = true) { self.fetcher = fetcher self.isRemote = isRemote + self.isEmbdedInList = isEmbdedInList } public var body: some View { - Group { - switch fetcher.statusesState { - case .loading: - ForEach(Status.placeholders()) { status in - StatusRowView(viewModel: .init(status: status, isCompact: false)) - .redacted(reason: .placeholder) - .padding(.horizontal, .layoutPadding) + switch fetcher.statusesState { + case .loading: + ForEach(Status.placeholders()) { status in + StatusRowView(viewModel: .init(status: status, isCompact: false)) + .padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding) + .redacted(reason: .placeholder) + .listRowBackground(theme.primaryBackgroundColor) + .listRowInsets(.init(top: 12, + leading: .layoutPadding, + bottom: 12, + trailing: .layoutPadding)) + if !isEmbdedInList { Divider() .padding(.vertical, .dividerPadding) } - case .error: - ErrorView(title: "status.error.title", - message: "status.error.loading.message", - buttonTitle: "action.retry") { - Task { - await fetcher.fetchStatuses() - } + } + case .error: + ErrorView(title: "status.error.title", + message: "status.error.loading.message", + buttonTitle: "action.retry") { + Task { + await fetcher.fetchStatuses() } + } + .listRowBackground(theme.primaryBackgroundColor) - case let .display(statuses, nextPageState): - ForEach(statuses, id: \.viewId) { status in - let viewModel = StatusRowViewModel(status: status, isCompact: false, isRemote: isRemote) - if viewModel.filter?.filter.filterAction != .hide { - StatusRowView(viewModel: viewModel) - .id(status.id) - .padding(.horizontal, .layoutPadding) + case let .display(statuses, nextPageState): + ForEach(statuses, id: \.viewId) { status in + let viewModel = StatusRowViewModel(status: status, isCompact: false, isRemote: isRemote) + if viewModel.filter?.filter.filterAction != .hide { + StatusRowView(viewModel: viewModel) + .padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding) + .id(status.id) + .listRowBackground(theme.primaryBackgroundColor) + .listRowInsets(.init(top: 12, + leading: .layoutPadding, + bottom: 12, + trailing: .layoutPadding)) + .onAppear { + Task { + await fetcher.statusDidAppear(status: status) + } + } + if !isEmbdedInList { Divider() .padding(.vertical, .dividerPadding) } } + } - switch nextPageState { - case .hasNextPage: - loadingRow - .onAppear { - Task { - await fetcher.fetchNextPage() - } + switch nextPageState { + case .hasNextPage: + loadingRow + .onAppear { + Task { + await fetcher.fetchNextPage() } - case .loadingNextPage: - loadingRow - case .none: - EmptyView() - } + } + case .loadingNextPage: + loadingRow + case .none: + EmptyView() } } - .frame(maxWidth: .maxColumnWidth) } private var loadingRow: some View { @@ -69,5 +90,6 @@ public struct StatusesListView: View where Fetcher: StatusesFetcher { Spacer() } .padding(.horizontal, .layoutPadding) + .listRowBackground(theme.primaryBackgroundColor) } } diff --git a/Packages/Status/Sources/Status/Row/StatusRowView.swift b/Packages/Status/Sources/Status/Row/StatusRowView.swift index 82a89899..f76f78b0 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowView.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowView.swift @@ -115,6 +115,9 @@ public struct StatusRowView: View { remoteContentLoadingView } } + .alignmentGuide(.listRowSeparatorLeading) { _ in + -100 + } } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index f08e1d96..ee10da3e 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -34,22 +34,22 @@ public struct TimelineView: View { public var body: some View { ScrollViewReader { proxy in ZStack(alignment: .top) { - ScrollView { - Rectangle() - .frame(height: 0) - .id(Constants.scrollToTop) - LazyVStack { + List { + if viewModel.tag == nil { + scrollToTopView + } else { tagHeaderView - .padding(.bottom, 16) - switch viewModel.timeline { - case .remoteLocal: - StatusesListView(fetcher: viewModel, isRemote: true) - default: - StatusesListView(fetcher: viewModel) - } } - .padding(.top, .layoutPadding + (!viewModel.pendingStatuses.isEmpty ? 28 : 0)) + switch viewModel.timeline { + case .remoteLocal: + StatusesListView(fetcher: viewModel, isRemote: true) + default: + StatusesListView(fetcher: viewModel) + } } + .environment(\.defaultMinListRowHeight, 1) + .listStyle(.plain) + .scrollContentBackground(.hidden) .background(theme.primaryBackgroundColor) if viewModel.pendingStatusesEnabled { makePendingNewPostsView(proxy: proxy) @@ -107,62 +107,65 @@ public struct TimelineView: View { private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View { if !viewModel.pendingStatuses.isEmpty { HStack(spacing: 6) { + Spacer() Button { withAnimation { - proxy.scrollTo(Constants.scrollToTop) - viewModel.displayPendingStatuses() + proxy.scrollTo(viewModel.pendingStatuses.last?.id, anchor: .bottom) } } label: { Text(viewModel.pendingStatusesButtonTitle) } - .keyboardShortcut("r", modifiers: .command) .buttonStyle(.bordered) .background(.thinMaterial) .cornerRadius(8) - if viewModel.pendingStatuses.count > 1 { - Button { - withAnimation { - viewModel.dequeuePendingStatuses() - } - } label: { - Image(systemName: "text.insert") - } - .buttonStyle(.bordered) - .background(.thinMaterial) - .cornerRadius(8) - } } - .padding(.top, 6) + .padding(12) } } @ViewBuilder private var tagHeaderView: some View { if let tag = viewModel.tag { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("#\(tag.name)") - .font(.scaledHeadline) - Text("timeline.n-recent-from-n-participants \(tag.totalUses) \(tag.totalAccounts)") - .font(.scaledFootnote) - .foregroundColor(.gray) + VStack(alignment: .leading) { + Spacer() + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("#\(tag.name)") + .font(.scaledHeadline) + Text("timeline.n-recent-from-n-participants \(tag.totalUses) \(tag.totalAccounts)") + .font(.scaledFootnote) + .foregroundColor(.gray) + } + Spacer() + Button { + Task { + if tag.following { + viewModel.tag = await account.unfollowTag(id: tag.name) + } else { + viewModel.tag = await account.followTag(id: tag.name) + } + } + } label: { + Text(tag.following ? "account.follow.following" : "account.follow.follow") + }.buttonStyle(.bordered) } Spacer() - Button { - Task { - if tag.following { - viewModel.tag = await account.unfollowTag(id: tag.name) - } else { - viewModel.tag = await account.followTag(id: tag.name) - } - } - } label: { - Text(tag.following ? "account.follow.following" : "account.follow.follow") - }.buttonStyle(.bordered) } - .padding(.horizontal, .layoutPadding) - .padding(.vertical, 8) - .background(theme.secondaryBackgroundColor) + .listRowBackground(theme.secondaryBackgroundColor) + .listRowSeparator(.hidden) + .listRowInsets(.init(top: 8, + leading: .layoutPadding, + bottom: 8, + trailing: .layoutPadding)) } } + + private var scrollToTopView: some View { + HStack{ } + .listRowBackground(theme.primaryBackgroundColor) + .listRowSeparator(.hidden) + .listRowInsets(.init()) + .frame(height: .layoutPadding) + .id(Constants.scrollToTop) + } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift index 4b53094d..181ad86c 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift @@ -38,19 +38,10 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { } @Published var tag: Tag? - - enum PendingStatusesState { - case refresh, stream - } - @Published var pendingStatuses: [Status] = [] - @Published var pendingStatusesState: PendingStatusesState = .stream var pendingStatusesButtonTitle: LocalizedStringKey { - switch pendingStatusesState { - case .stream, .refresh: - return "timeline-new-posts \(pendingStatuses.count)" - } + "\(pendingStatuses.count)" } var pendingStatusesEnabled: Bool { @@ -81,7 +72,6 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { } else if let first = pendingStatuses.first ?? statuses.first { var newStatuses: [Status] = await fetchNewPages(minId: first.id, maxPages: 20) if userIntent || !pendingStatusesEnabled { - pendingStatuses.insert(contentsOf: newStatuses, at: 0) statuses.insert(contentsOf: pendingStatuses, at: 0) pendingStatuses = [] withAnimation { @@ -92,7 +82,10 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { !pendingStatuses.contains(where: { $0.id == status.id }) } pendingStatuses.insert(contentsOf: newStatuses, at: 0) - pendingStatusesState = .refresh + statuses.insert(contentsOf: pendingStatuses, at: 0) + withAnimation { + statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) + } } } } catch { @@ -153,14 +146,10 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { !statuses.contains(where: { $0.id == event.status.id }), !pendingStatuses.contains(where: { $0.id == event.status.id }) { - if event.status.account.id == currentAccount.account?.id, pendingStatuses.isEmpty { - withAnimation { - statuses.insert(event.status, at: 0) - statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) - } - } else { - pendingStatuses.insert(event.status, at: 0) - pendingStatusesState = .stream + pendingStatuses.insert(event.status, at: 0) + statuses.insert(event.status, at: 0) + withAnimation { + statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } } else if let event = event as? StreamEventDelete { withAnimation { @@ -175,22 +164,9 @@ class TimelineViewModel: ObservableObject, StatusesFetcher { } } - func displayPendingStatuses() { - guard timeline == .home else { return } - pendingStatuses = pendingStatuses.filter { status in - !statuses.contains(where: { $0.id == status.id }) + func statusDidAppear(status: Status) async { + if let index = pendingStatuses.firstIndex(of: status) { + pendingStatuses.remove(at: index) } - statuses.insert(contentsOf: pendingStatuses, at: 0) - pendingStatuses = [] - statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) - } - - func dequeuePendingStatuses() { - guard timeline == .home else { return } - if pendingStatuses.count > 1 { - let status = pendingStatuses.removeLast() - statuses.insert(status, at: 0) - } - statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } }