Files
org-social-ios/App/Views/Timeline/TimelineView.swift
andros d2ab6ea377 Add color theme system with 6 built-in themes; fix modal sheet theming
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.
2026-05-24 09:50:16 +02:00

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)
}
}
}
}