Files
andros aa0a5fb356 Add Welcome flow, relay-feed toggle, production-ready defaults
- New WelcomeView gates first-run users with existing-account or signup paths
- HostSignupClient posts to host.org-social.org /signup and auto-configures Settings
- Show all relay feeds toggle (default off) restricts timeline to #+FOLLOW entries
- Contextual empty-state in timeline routes to Discover or toggles relay-wide view
- RootTab enum enables programmatic tab switching from child views
- App icon background switched to cream for better contrast in iOS home screen
- Remove pre-registered test account; fresh installs land on Welcome
2026-04-21 09:09:25 +02:00

146 lines
5.1 KiB
Swift

import SwiftUI
import OrgSocialKit
/// First-run welcome gate. Shown when the user hasn't configured a publishing method yet.
/// Offers two paths: jump straight into Settings with an existing account, or create one
/// on host.org-social.org and auto-fill the config.
struct WelcomeView: View {
@State private var showSettings = false
@State private var showCreate = false
var onConfigured: () -> Void = {}
var body: some View {
VStack(spacing: 32) {
Spacer()
LogoView(size: 120)
.allowsHitTesting(false)
.accessibilityHidden(true)
VStack(spacing: 8) {
Text("Welcome to Org Social")
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
Text("A decentralized social network based on plain text `social.org` files.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
Spacer()
VStack(spacing: 12) {
Button {
showCreate = true
} label: {
Label("Create my social.org", systemImage: "sparkles")
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
showSettings = true
} label: {
Label("I already have a social.org", systemImage: "gearshape")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.horizontal, 32)
.padding(.bottom, 40)
}
.sheet(isPresented: $showSettings, onDismiss: onConfigured) {
SettingsView()
}
.sheet(isPresented: $showCreate, onDismiss: onConfigured) {
CreateAccountSheet()
}
}
}
/// Sign-up form that creates an account on host.org-social.org and writes the
/// returned vfile token URL + public feed URL into UserDefaults.
struct CreateAccountSheet: View {
@Environment(\.dismiss) private var dismiss
@AppStorage("vfileURL") private var vfileURL = ""
@AppStorage("publicFeedURL") private var publicFeedURL = ""
@AppStorage("uploadMethod") private var uploadMethod = "vfile"
@State private var nickname = ""
@State private var isCreating = false
@State private var errorMessage: String?
@State private var success = false
private let client = HostSignupClient()
var body: some View {
NavigationStack {
Form {
Section {
TextField("Nickname", text: $nickname)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
} header: {
Text("Choose a nickname")
} footer: {
Text("Your feed will be hosted at https://host.org-social.org/<nickname>/social.org. Pick something unique—no spaces.")
}
if let errorMessage {
Section {
Label(errorMessage, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
}
}
if success {
Section {
Label("Account created", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(publicFeedURL)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Create account")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
if isCreating {
ProgressView()
} else {
Button("Create") { Task { await create() } }
.disabled(nickname.trimmingCharacters(in: .whitespaces).isEmpty || success)
}
}
}
}
}
private func create() async {
isCreating = true
errorMessage = nil
defer { isCreating = false }
do {
let result = try await client.signup(nick: nickname)
vfileURL = result.vfileURL.absoluteString
publicFeedURL = result.publicURL.absoluteString
uploadMethod = "vfile"
success = true
try? await Task.sleep(for: .seconds(1))
dismiss()
} catch {
errorMessage = error.localizedDescription
}
}
}