d2ab6ea377
New files: AppTheme (palette + env key), ThemeManager (Observable singleton, UserDefaults persistence). Themes: Default, Emacs, Dracula, One Dark, Monokai, Material. Each controls background, accent, text, code block colors and a highlight.js theme for syntax highlighting. RootView applies preferredColorScheme, tint and appTheme env to the root group. SettingsView applies the same modifiers to its NavigationStack so the modal sheet is themed immediately (sheets run in a separate UIWindow and don't inherit preferredColorScheme from the parent hierarchy). Theme selection in Settings uses onTapGesture instead of buttonStyle(.plain) inside Form, which was silently intercepting the button action. CodeBlockView, TimelineView, NotificationsView all read from appTheme env.
189 lines
7.5 KiB
Swift
189 lines
7.5 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)
|
|
.toolbarBackground(.visible, 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)
|
|
}
|
|
}
|
|
}
|
|
}
|