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.
125 lines
5.0 KiB
Swift
125 lines
5.0 KiB
Swift
import SwiftUI
|
|
import OrgSocialKit
|
|
|
|
/// Route identifiers used by `TabView(selection:)`. Kept as an enum so other views
|
|
/// (e.g. TimelineView's empty state) can programmatically switch tabs via a binding.
|
|
enum RootTab: Hashable {
|
|
case timeline, notifications, discover, groups, profile
|
|
}
|
|
|
|
struct RootView: View {
|
|
@AppStorage("publicFeedURL") private var publicFeedURL = ""
|
|
@AppStorage("vfileURL") private var vfileURL = ""
|
|
@AppStorage("githubToken") private var githubToken = ""
|
|
@AppStorage("codebergToken") private var codebergToken = ""
|
|
@AppStorage("webdavURL") private var webdavURL = ""
|
|
@AppStorage("eulaAcceptedVersion") private var eulaAcceptedVersion: Int = 0
|
|
|
|
@State private var selectedTab: RootTab = .timeline
|
|
@State private var notificationsViewModel = NotificationsViewModel()
|
|
@State private var themeManager = ThemeManager.shared
|
|
private var pushRouter = PushRouter.shared
|
|
|
|
// User is "configured" once they have both a public feed URL and some kind of credential
|
|
// for publishing. This gate avoids landing on an empty timeline before first-time setup.
|
|
private var isConfigured: Bool {
|
|
guard !publicFeedURL.isEmpty, URL(string: publicFeedURL) != nil else { return false }
|
|
return !vfileURL.isEmpty || !githubToken.isEmpty || !codebergToken.isEmpty || !webdavURL.isEmpty
|
|
}
|
|
|
|
private var hasAcceptedEULA: Bool { eulaAcceptedVersion >= EULAView.currentVersion }
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isConfigured {
|
|
tabs
|
|
} else {
|
|
WelcomeView()
|
|
}
|
|
}
|
|
.tint(themeManager.current.accent)
|
|
.preferredColorScheme(themeManager.current.colorScheme)
|
|
.environment(\.appTheme, themeManager.current)
|
|
// EULA gate is rendered as a full-screen sheet over whatever the
|
|
// root would have shown. Cannot be dismissed without "I Agree";
|
|
// tapping I Agree updates `eulaAcceptedVersion` which flips the
|
|
// binding here and dismisses the cover.
|
|
.fullScreenCover(isPresented: Binding(
|
|
get: { !hasAcceptedEULA },
|
|
set: { _ in /* dismiss is driven by the agree button writing eulaAcceptedVersion */ }
|
|
)) {
|
|
EULAView()
|
|
}
|
|
}
|
|
|
|
private var tabs: some View {
|
|
TabView(selection: $selectedTab) {
|
|
TimelineView(selectedTab: $selectedTab)
|
|
.tabItem {
|
|
Label("Timeline", systemImage: "house")
|
|
}
|
|
.tag(RootTab.timeline)
|
|
|
|
NotificationsView(viewModel: notificationsViewModel)
|
|
.badge(notificationsViewModel.unreadCount)
|
|
.tabItem {
|
|
Label("Notifications", systemImage: "bell")
|
|
}
|
|
.tag(RootTab.notifications)
|
|
|
|
DiscoverView()
|
|
.tabItem {
|
|
Label("Discover", systemImage: "globe")
|
|
}
|
|
.tag(RootTab.discover)
|
|
|
|
GroupsView()
|
|
.tabItem {
|
|
Label("Groups", systemImage: "person.3")
|
|
}
|
|
.tag(RootTab.groups)
|
|
|
|
OwnProfileView()
|
|
.tabItem {
|
|
Label("Profile", systemImage: "person.circle")
|
|
}
|
|
.tag(RootTab.profile)
|
|
}
|
|
.toolbarColorScheme(themeManager.current.colorScheme, for: .navigationBar)
|
|
.task {
|
|
await registerWithRelayIfNeeded()
|
|
await PushRegistration.shared.requestPermissionAndRegister()
|
|
// Background fetch so the badge is populated before the user
|
|
// opens the notifications tab.
|
|
await notificationsViewModel.load()
|
|
}
|
|
.onChange(of: pushRouter.pendingRoute) { _, route in
|
|
if route != nil { selectedTab = .notifications }
|
|
}
|
|
}
|
|
|
|
/// Proactively registers the user's feed URL with the relay so it starts
|
|
/// getting crawled (~every minute) instead of waiting for someone to
|
|
/// follow the feed and for the relay's daily follow-crawl to pick it up.
|
|
/// A brand-new account otherwise wouldn't appear anywhere on the relay
|
|
/// for up to 24 hours. The registration is best-effort: a UserDefaults
|
|
/// flag avoids repeating it every launch for the same (feed, relay) pair.
|
|
private func registerWithRelayIfNeeded() async {
|
|
let relayRaw = UserDefaults.standard.string(forKey: "relayURL") ?? ""
|
|
guard !publicFeedURL.isEmpty,
|
|
let feedURL = URL(string: publicFeedURL),
|
|
let relayURL = URL(string: relayRaw) else { return }
|
|
|
|
let registeredKey = "relayRegistration.\(relayURL.absoluteString).\(feedURL.absoluteString)"
|
|
if UserDefaults.standard.bool(forKey: registeredKey) { return }
|
|
|
|
do {
|
|
try await RelayClient().registerFeed(feedURL, with: relayURL)
|
|
UserDefaults.standard.set(true, forKey: registeredKey)
|
|
} catch {
|
|
// Silent: the next launch will retry, and notifications/timeline
|
|
// code already re-registers reactively on 404.
|
|
}
|
|
}
|
|
}
|