Files
andros 78ba61034f Fix theme colors across all screens; fix Discover title on iOS 26
- Remove .toolbarBackground(.visible) from all NavigationStack views:
  this modifier suppresses large title rendering in iOS 26.
- Discover: switch to always-present List + overlay pattern so SwiftUI
  never loses the scroll context during loading transitions.
- Discover, Notifications, Groups: use .inline title mode; the tab bar
  already identifies these screens, large titles are redundant.
- RootView: add .toolbarColorScheme propagation for nav bar foreground.
- Settings sheet: apply theme background and toolbar color to the Form.
2026-05-24 21:10:04 +02:00

188 lines
7.4 KiB
Swift

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
@State private var blockList = BlockList.shared
@AppStorage("useRelay") private var useRelay = true
@AppStorage("showAllRelayFeeds") private var showAllRelayFeeds = false
@Binding var selectedTab: RootTab
@Environment(\.appTheme) private var theme
init(selectedTab: Binding<RootTab> = .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 }
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
.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 {
// Render-time block filter (instead of load-time) so blocking from a
// profile makes the author's posts disappear from this scroll view
// immediately, as App Store guideline 1.2 mandates ("remove it from
// the user's feed instantly"). `BlockList` is @Observable so this
// view re-renders the moment the list changes.
let visiblePosts = viewModel.posts.filter { post in
guard let url = post.authorURL else { return true }
return !blockList.isBlocked(url)
}
// 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.
return ScrollView {
LazyVStack(spacing: 0) {
ForEach(visiblePosts, 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()
.background(theme.secondaryBackground)
.padding(.leading, 16)
}
}
.background(theme.background)
}
.background(theme.background)
.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)
}
}
}
}