2631b0d942
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.
172 lines
6.6 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|