import DesignSystem import Env import Models import Network import Shimmer import SwiftUI @MainActor public struct ConversationsListView: View { @Environment(UserPreferences.self) private var preferences @Environment(RouterPath.self) private var routerPath @Environment(StreamWatcher.self) private var watcher @Environment(Client.self) private var client @Environment(Theme.self) private var theme @State private var viewModel = ConversationsListViewModel() @Binding var scrollToTopSignal: Int public init(scrollToTopSignal: Binding) { _scrollToTopSignal = scrollToTopSignal } private var conversations: Binding<[Conversation]> { if viewModel.isLoadingFirstPage { Binding.constant(Conversation.placeholders()) } else { $viewModel.conversations } } public var body: some View { ScrollViewReader { proxy in ScrollView { scrollToTopView LazyVStack { Group { if !conversations.isEmpty || viewModel.isLoadingFirstPage { ForEach(conversations) { $conversation in if viewModel.isLoadingFirstPage { ConversationsListRow(conversation: $conversation, viewModel: viewModel) .padding(.horizontal, .layoutPadding) .redacted(reason: .placeholder) .allowsHitTesting(false) } else { ConversationsListRow(conversation: $conversation, viewModel: viewModel) .padding(.horizontal, .layoutPadding) } Divider() } } else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError { EmptyView(iconName: "tray", title: "conversations.empty.title", message: "conversations.empty.message") } else if viewModel.isError { ErrorView(title: "conversations.error.title", message: "conversations.error.message", buttonTitle: "conversations.error.button") { Task { await viewModel.fetchConversations() } } } if viewModel.nextPage != nil { HStack { Spacer() ProgressView() Spacer() } .onAppear { if !viewModel.isLoadingNextPage { Task { await viewModel.fetchNextPage() } } } } } } .padding(.top, .layoutPadding) } #if !os(visionOS) .scrollContentBackground(.hidden) .background(theme.primaryBackgroundColor) #endif .navigationTitle("conversations.navigation-title") .navigationBarTitleDisplayMode(.inline) .toolbar { StatusEditorToolbarItem(visibility: .direct) if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn { SecondaryColumnToolbarItem() } } .onChange(of: watcher.latestEvent?.id) { if let latestEvent = watcher.latestEvent { viewModel.handleEvent(event: latestEvent) } } .onChange(of: scrollToTopSignal) { withAnimation { proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top) } } .refreshable { // note: this Task wrapper should not be necessary, but it reportedly crashes without it // when refreshing on an empty list Task { SoundEffectManager.shared.playSound(.pull) HapticManager.shared.fireHaptic(.dataRefresh(intensity: 0.3)) await viewModel.fetchConversations() HapticManager.shared.fireHaptic(.dataRefresh(intensity: 0.7)) SoundEffectManager.shared.playSound(.refresh) } } .onAppear { viewModel.client = client if client.isAuth { Task { await viewModel.fetchConversations() } } } } } private var scrollToTopView: some View { ScrollToView() .frame(height: .scrollToViewHeight) .onAppear { viewModel.scrollToTopVisible = true } .onDisappear { viewModel.scrollToTopVisible = false } } }