Files
andros 2631b0d942 Auto-refresh own profile and timeline after any feed mutation
Own Profile and Timeline kept stale views between tab switches because
SwiftUI's TabView preserves state, so .task runs only once. Now every
write path (compose, edit, delete, react, boost, vote, follow/unfollow,
profile edit, migration, pin/unpin) hands the new feed content to
FollowCoordinator, which bumps a feedVersion counter. Profile and
Timeline observe that counter and re-fetch.

Also align GitHub and Codeberg commit messages with the shortened
"via iOS" client tag.
2026-04-21 11:09:03 +02:00

172 lines
6.6 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
@AppStorage("useRelay") private var useRelay = true
@AppStorage("showAllRelayFeeds") private var showAllRelayFeeds = false
@Binding var selectedTab: RootTab
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 }
.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)
}
}
}
}