import SwiftUI import OrgSocialKit struct TimelineView: View { @State private var viewModel = TimelineViewModel() @State private var showCompose = false @State private var showSearch = false @State private var followCoordinator = FollowCoordinator.shared @AppStorage("useRelay") private var useRelay = true @AppStorage("showAllRelayFeeds") private var showAllRelayFeeds = false @Binding var selectedTab: RootTab init(selectedTab: Binding = .constant(.timeline)) { self._selectedTab = selectedTab } var body: some View { NavigationStack { Group { if viewModel.isLoading && viewModel.posts.isEmpty { loadingView } else if let error = viewModel.errorMessage, viewModel.posts.isEmpty { errorView(error) } else if viewModel.posts.isEmpty { emptyStateView } else { postList } } .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .navigationDestination(for: URL.self) { ProfileView(feedURL: $0) } .navigationDestination(for: ThreadRoute.self) { ThreadView(postURL: $0.postURL, relayURL: $0.relayURL) } .sheet(isPresented: $showCompose) { ComposeView(onPosted: { Task { await viewModel.mergeOwnFeed() } }) } .sheet(isPresented: $showSearch) { SearchView() } .task { await viewModel.load() } .onChange(of: followCoordinator.feedVersion) { _, _ in // Any own-feed mutation elsewhere (follow toggle, post edit/delete, // profile edit) while this tab stayed mounted; reload. Task { await viewModel.load() } } } } // MARK: - Subviews private var loadingView: some View { VStack(spacing: 16) { ProgressView() Text("Loading timeline from relay…") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private func errorView(_ message: String) -> some View { ContentUnavailableView { Label("No Posts", systemImage: "antenna.radiowaves.left.and.right.slash") } description: { Text(message) } actions: { Button("Retry") { Task { await viewModel.load() } } .buttonStyle(.bordered) } } @ViewBuilder private var emptyStateView: some View { // Wrap in a ScrollView so `.refreshable` works while the timeline is empty. ScrollView { ContentUnavailableView { Label("No posts yet", systemImage: "tray") } description: { if useRelay && !showAllRelayFeeds { Text("Your follow list is empty or the feeds you follow haven't posted recently. Find people in Discover, or turn on Show all relay feeds in Settings to see everyone.") } else if !useRelay { Text("Timeline is running in offline mode. Add #+FOLLOW: entries to your profile to see posts.") } else { Text("The relay hasn't returned any posts. Pull to refresh or try again later.") } } actions: { VStack(spacing: 10) { if useRelay && !showAllRelayFeeds { Button { selectedTab = .discover } label: { Label("Find people in Discover", systemImage: "globe") } .buttonStyle(.borderedProminent) Button { showAllRelayFeeds = true Task { await viewModel.load() } } label: { Label("Show all relay feeds", systemImage: "dot.radiowaves.left.and.right") } .buttonStyle(.bordered) } else { Button("Retry") { Task { await viewModel.load() } } .buttonStyle(.bordered) } } } .frame(minHeight: 500) } .refreshable { await viewModel.load() } } private var postList: some View { // ScrollView + LazyVStack instead of List: SwiftUI's List auto-fires the first // NavigationLink inside a row on any tap, which pushed a stray Profile/Thread // onto the stack every time an action button was tapped. ScrollView { LazyVStack(spacing: 0) { ForEach(viewModel.posts, id: \.stableID) { post in PostRowView(post: post, onDelete: { viewModel.removePost(timestamp: post.timestamp) }, onEdit: { result in viewModel.applyEdit(timestamp: post.timestamp, result: result) }) .padding(.horizontal, 16) .padding(.vertical, 12) Divider().padding(.leading, 16).background(Color.secondary.opacity(0.2)) } } } .refreshable { await viewModel.load() } .overlay(alignment: .bottom) { if viewModel.isLoading && !viewModel.posts.isEmpty { HStack(spacing: 8) { ProgressView().scaleEffect(0.8) Text("Refreshing…").font(.caption).foregroundStyle(.secondary) } .padding(.horizontal, 16).padding(.vertical, 8) .background(.thinMaterial, in: Capsule()) .padding(.bottom, 12) } } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .principal) { LogoView(size: 30) .allowsHitTesting(false) .accessibilityHidden(true) } ToolbarItem(placement: .navigationBarTrailing) { Button { showSearch = true } label: { Image(systemName: "magnifyingglass") } .disabled(!useRelay) } ToolbarItem(placement: .navigationBarTrailing) { Button { showCompose = true } label: { Image(systemName: "square.and.pencil") .fontWeight(.semibold) } } } }