import SwiftUI import OrgSocialKit struct SettingsView: View { @State private var viewModel = SettingsViewModel() @Environment(\.dismiss) private var dismiss @State private var testStatus: TestConnectionStatus = .idle enum TestConnectionStatus: Equatable { case idle, testing, success(String), failure(String) var label: String { switch self { case .idle: return "Test connection" case .testing: return "Testing…" case .success: return "Connected" case .failure: return "Failed" } } } @State private var showMigrationSheet = false @State private var exportFileURL: URL? @State private var isExporting = false @State private var exportError: String? // True when Publishing/Feed fields changed since the last successful test. // While dirty, Save is disabled and the user must re-run Test connection. @State private var needsValidation = false var body: some View { NavigationStack { Form { uploadMethodSection uploadConfigSection publicFeedSection relaySection sharingSection migrationSection exportSection aboutSection } .sheet(isPresented: $showMigrationSheet) { MigrationSheet() } .sheet(isPresented: Binding( get: { exportFileURL != nil }, set: { if !$0 { exportFileURL = nil } } )) { if let url = exportFileURL { ShareSheet(items: [url]) } } .alert("Export failed", isPresented: Binding( get: { exportError != nil }, set: { if !$0 { exportError = nil } } )) { Button("OK") { exportError = nil } } message: { Text(exportError ?? "") } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { viewModel.saveIfValid() dismiss() } .disabled(!viewModel.canSave || needsValidation) } } // Any change to a Publishing/Feed field marks the config dirty // and forces a fresh Test connection before Save is re-enabled. .onChange(of: publishingFingerprint) { _, _ in markDirty() } } } private func markDirty() { needsValidation = true testStatus = .idle } /// Concatenates every Publishing/Feed field so a single `.onChange` reacts to any edit. private var publishingFingerprint: String { [ viewModel.uploadMethod.rawValue, viewModel.publicFeedURL, viewModel.vfileURL, viewModel.githubToken, viewModel.githubOwner, viewModel.githubRepo, viewModel.githubPath, viewModel.githubBranch, viewModel.codebergToken, viewModel.codebergInstance, viewModel.codebergOwner, viewModel.codebergRepo, viewModel.codebergPath, viewModel.codebergBranch, viewModel.webdavURL, viewModel.webdavUsername, viewModel.webdavPassword ].joined(separator: "|") } // MARK: - Upload method picker private var uploadMethodSection: some View { Section { Picker("Upload method", selection: $viewModel.uploadMethod) { ForEach(UploadMethod.allCases, id: \.self) { method in Text(method.displayName).tag(method) } } .pickerStyle(.menu) if viewModel.uploadMethod == .vfile { Link("Sign up for a free vhost account →", destination: URL(string: "https://host.org-social.org/signup")!) .font(.footnote) } } header: { Text("Publishing") } footer: { Text(viewModel.uploadMethod.description) } } // MARK: - Method-specific config @ViewBuilder private var uploadConfigSection: some View { switch viewModel.uploadMethod { case .vfile: Section("VFile Token") { labeledField("Token URL", placeholder: "https://host.org-social.org/vfile?token=…", text: $viewModel.vfileURL, valid: SettingsViewModel.isHTTPURL(viewModel.vfileURL)) testConnectionRow } case .github: Section("GitHub") { secureField("Personal Access Token", text: $viewModel.githubToken) VStack(alignment: .leading, spacing: 6) { Text("Repo must be **public** so other clients can read your feed from raw.githubusercontent.com.") Text("Fine-grained token → only this repo → Contents: Read and write.") Link("Generate a token →", destination: URL(string: "https://github.com/settings/personal-access-tokens/new")!) } .font(.footnote) .foregroundStyle(.secondary) .padding(.vertical, 2) labeledField("Owner", placeholder: "username", text: $viewModel.githubOwner, valid: !viewModel.githubOwner.isEmpty) labeledField("Repository", placeholder: "my-site", text: $viewModel.githubRepo, valid: !viewModel.githubRepo.isEmpty) labeledField("File path", placeholder: "social.org", text: $viewModel.githubPath, valid: true) labeledField("Branch", placeholder: "main", text: $viewModel.githubBranch, valid: true) if !viewModel.githubDerivedPublicURL.isEmpty { derivedURLRow(viewModel.githubDerivedPublicURL) } testConnectionRow } case .codeberg: Section("Codeberg") { secureField("API Token", text: $viewModel.codebergToken) VStack(alignment: .leading, spacing: 6) { Text("Repo must be **public** so other clients can read it via the raw URL.") Text("On Settings → Applications, generate a token with **write:repository** scope.") Link("Open Codeberg token page →", destination: URL(string: "https://codeberg.org/user/settings/applications")!) } .font(.footnote) .foregroundStyle(.secondary) .padding(.vertical, 2) labeledField("Instance URL", placeholder: "https://codeberg.org", text: $viewModel.codebergInstance, valid: SettingsViewModel.isHTTPURL(viewModel.codebergInstance)) labeledField("Owner", placeholder: "username", text: $viewModel.codebergOwner, valid: !viewModel.codebergOwner.isEmpty) labeledField("Repository", placeholder: "my-site", text: $viewModel.codebergRepo, valid: !viewModel.codebergRepo.isEmpty) labeledField("File path", placeholder: "social.org", text: $viewModel.codebergPath, valid: true) labeledField("Branch", placeholder: "main", text: $viewModel.codebergBranch, valid: true) if !viewModel.codebergDerivedPublicURL.isEmpty { derivedURLRow(viewModel.codebergDerivedPublicURL) } testConnectionRow } case .webdav: Section("WebDAV") { labeledField("WebDAV URL", placeholder: "https://dav.example.com/social.org", text: $viewModel.webdavURL, valid: SettingsViewModel.isHTTPURL(viewModel.webdavURL)) labeledField("Username", placeholder: "optional", text: $viewModel.webdavUsername, valid: true) secureField("Password", text: $viewModel.webdavPassword) testConnectionRow } } } // MARK: - Test connection private var testConnectionRow: some View { VStack(alignment: .leading, spacing: 4) { HStack { Button(testStatus.label) { Task { await runConnectionTest() } } .disabled(testStatus == .testing) Spacer() switch testStatus { case .idle: EmptyView() case .testing: ProgressView() case .success: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) case .failure: Image(systemName: "xmark.circle.fill").foregroundStyle(.red) } } switch testStatus { case .success(let msg), .failure(let msg): Text(msg).font(.caption).foregroundStyle(.secondary) default: EmptyView() } } } private func runConnectionTest() async { testStatus = .testing // Test by GETting the public feed URL: if it returns parsable Org Social headers the // setup is coherent enough to both read (timeline) and write (uploader shares the host). guard let urlString = viewModel.publicFeedURL.isEmpty ? nil : viewModel.publicFeedURL, let url = URL(string: urlString) else { testStatus = .failure("Set a Public Feed URL first.") return } do { let content = try await FeedFetcher().fetch(from: url, bypassCache: true) let profile = OrgSocialParser().parse(content) if profile.nick != nil || profile.title != nil { testStatus = .success("Feed reachable (nick: \(profile.nick ?? "-"))") needsValidation = false } else { testStatus = .failure("Feed reachable but not a valid social.org (no NICK/TITLE).") } } catch { testStatus = .failure(error.localizedDescription) } } // MARK: - Export / backup private var exportSection: some View { Section { Button { Task { await exportFeed() } } label: { HStack { Label("Export feed (social.org)", systemImage: "square.and.arrow.down") Spacer() if isExporting { ProgressView() } } } .disabled(!viewModel.publicFeedURLValid || isExporting) } header: { Text("Backup") } footer: { Text("Download a copy of your current social.org from your public feed URL. You can save it to Files, AirDrop it, or email it.") } } private func exportFeed() async { isExporting = true defer { isExporting = false } guard let url = URL(string: viewModel.publicFeedURL) else { exportError = "Invalid Public Feed URL." return } do { let content = try await FeedFetcher().fetch(from: url, bypassCache: true) let tempDir = FileManager.default.temporaryDirectory let filename = (viewModel.publicFeedURL as NSString).lastPathComponent let filenameFallback = filename.isEmpty ? "social.org" : filename let fileURL = tempDir.appendingPathComponent(filenameFallback) try content.data(using: .utf8)?.write(to: fileURL, options: .atomic) exportFileURL = fileURL } catch { exportError = error.localizedDescription } } // MARK: - Migration private var migrationSection: some View { Section { Button { showMigrationSheet = true } label: { Label("Announce account migration", systemImage: "arrow.right.circle") } .disabled(!viewModel.publicFeedURLValid) } header: { Text("Account") } footer: { Text("Post a :MIGRATION: entry that tells followers your feed has moved to a new URL. Their clients can then stop polling the old feed.") } } // MARK: - Public feed URL (common to all methods) private var publicFeedSection: some View { Section { labeledField("Public Feed URL", placeholder: "https://example.com/social.org", text: $viewModel.publicFeedURL, valid: viewModel.publicFeedURLValid) } header: { Text("Feed") } footer: { Text("The publicly accessible URL of your social.org. Used for your profile, notifications, and building post URLs.") } } // MARK: - Relay private var relaySection: some View { Section { Toggle("Use Relay", isOn: $viewModel.useRelay) if viewModel.useRelay { labeledField("Relay URL", placeholder: "https://relay.org-social.org", text: $viewModel.relayURL, valid: viewModel.relayURLValid) Stepper("Max post age: \(viewModel.maxPostAgeDays) days", value: $viewModel.maxPostAgeDays, in: 1...365) Toggle("Show all relay feeds", isOn: $viewModel.showAllRelayFeeds) } } header: { Text("Relay") } footer: { if !viewModel.useRelay { Text("Timeline will load only from feeds you follow directly.") } else if !viewModel.showAllRelayFeeds { Text("Timeline will only show posts from feeds you follow. Notifications and discovery still use the relay.") } else { Text("Posts older than the max age will not appear in your timeline.") } } } // MARK: - Sharing private var sharingSection: some View { Section { Toggle("Show share button on posts", isOn: $viewModel.previewServiceEnabled) if viewModel.previewServiceEnabled { labeledField("Preview URL", placeholder: "https://preview.org-social.org/", text: $viewModel.previewServiceURL, valid: viewModel.previewServiceValid) } } header: { Text("Sharing") } footer: { Text("Tapping Share on a post opens iOS share sheet with a link to the preview service. When off, the Share button is hidden.") } } // MARK: - About private var aboutSection: some View { Section("About") { Link(destination: URL(string: "https://liberapay.com/org-social/")!) { Label("Donate", systemImage: "heart.fill") .foregroundStyle(.pink) } Link(destination: URL(string: "https://git.andros.dev/andros/contribute")!) { Label("Issue / Pull Request", systemImage: "arrow.triangle.pull") .foregroundStyle(.primary) } } } // MARK: - Helpers private func labeledField( _ label: String, placeholder: String, text: Binding, valid: Bool ) -> some View { HStack { Text(label) Spacer() TextField(placeholder, text: text) .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.URL) .multilineTextAlignment(.trailing) .foregroundStyle(valid ? Color.secondary : Color.red) .frame(maxWidth: 220) } } private func secureField(_ label: String, text: Binding) -> some View { HStack { Text(label) Spacer() SecureField("••••••••", text: text) .autocorrectionDisabled() .textInputAutocapitalization(.never) .multilineTextAlignment(.trailing) .frame(maxWidth: 220) } } private func derivedURLRow(_ url: String) -> some View { VStack(alignment: .leading, spacing: 2) { Text("Public feed URL").font(.caption).foregroundStyle(.secondary) Text(url).font(.caption2).foregroundStyle(.secondary).lineLimit(2) } } } // MARK: - UploadMethod display extension UploadMethod { var displayName: String { switch self { case .vfile: return "VFile Token (Recommended)" case .github: return "GitHub" case .codeberg: return "Codeberg" case .webdav: return "WebDAV" } } var description: String { switch self { case .vfile: return "Hosted social.org managed by an Org Social host. Easiest setup, sign up at host.org-social.org." case .github: return "Store your social.org in a GitHub repository. Requires a personal access token with repo scope." case .codeberg: return "Store your social.org in a Codeberg (or any Gitea) repository. Requires an API token." case .webdav: return "Upload via WebDAV (HTTP PUT). Compatible with Nextcloud, ownCloud, and standard WebDAV servers." } } } extension SettingsViewModel { static func isHTTPURL(_ raw: String) -> Bool { guard let url = URL(string: raw) else { return false } return url.scheme?.hasPrefix("http") == true && url.host != nil } }