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

97 lines
4.1 KiB
Swift

import Foundation
/// Creates accounts on an Org Social host (e.g. host.org-social.org).
///
/// The host exposes a free sign-up endpoint that returns a fresh vfile token URL
/// plus the public `social.org` URL for the chosen nickname.
public struct HostSignupClient: Sendable {
public enum SignupError: Error, LocalizedError {
case invalidURL
case httpError(statusCode: Int, serverMessage: String?)
case nicknameRequired
case nicknameTaken(String)
case malformedResponse
case network(underlying: String)
public var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid host URL."
case .httpError(let code, let msg): return msg ?? "HTTP \(code) from the host."
case .nicknameRequired: return "Nickname is required."
case .nicknameTaken(let nick): return "Nickname '\(nick)' is already taken."
case .malformedResponse: return "The host returned a response we could not parse."
case .network(let msg): return "Network error: \(msg)"
}
}
}
public struct SignupResult: Sendable {
public let vfileURL: URL
public let publicURL: URL
}
private let session: URLSession
public init(session: URLSession = .shared) { self.session = session }
/// Registers `nick` on `host` and returns the resulting vfile token URL and public feed URL.
///
/// - Parameters:
/// - nick: The nickname to claim. Must be non-empty; the server enforces uniqueness.
/// - host: Base URL of the host, e.g. `https://host.org-social.org`.
public func signup(nick: String, host: URL = URL(string: "https://host.org-social.org")!) async throws -> SignupResult {
let trimmed = nick.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw SignupError.nicknameRequired }
guard let url = URL(string: "\(host.absoluteString.trimmingCharacters(in: .init(charactersIn: "/")))/signup") else {
throw SignupError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 15
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["nick": trimmed])
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw SignupError.network(underlying: error.localizedDescription)
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
// Error shape: {"type": "Error", "errors": ["..."]}
if let json,
let type = json["type"] as? String,
type == "Error" {
let messages = (json["errors"] as? [String]) ?? []
let first = messages.first ?? "Unknown error."
if first.localizedCaseInsensitiveContains("already taken") {
throw SignupError.nicknameTaken(trimmed)
}
if first.localizedCaseInsensitiveContains("required") {
throw SignupError.nicknameRequired
}
throw SignupError.httpError(statusCode: statusCode, serverMessage: first)
}
if !(200..<300).contains(statusCode) {
throw SignupError.httpError(statusCode: statusCode, serverMessage: nil)
}
// Success shape: {"type": "Success", "data": {"vfile": "...", "public-url": "..."}}
guard let data = json?["data"] as? [String: Any],
let vfileStr = data["vfile"] as? String,
let publicStr = data["public-url"] as? String,
let vfileURL = URL(string: vfileStr),
let publicURL = URL(string: publicStr) else {
throw SignupError.malformedResponse
}
return SignupResult(vfileURL: vfileURL, publicURL: publicURL)
}
}