78ba61034f
- 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.
188 lines
7.4 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|