Files
andros 7e1c37db7f Settings toggle to disable per-post share + configurable preview URL
The previous commit hardcoded https://preview.org-social.org/ as the
preview service for the Share button on each post. Now both the toggle
and the URL are user-configurable:

- New UserDefaults keys, registered with sensible defaults in
  OrgSocialApp.init: previewServiceEnabled (true), previewServiceURL
  ("https://preview.org-social.org/").
- SettingsViewModel exposes both as @Observable properties, persists them
  in saveIfValid, and adds previewServiceValid -> canSave so the URL
  must be a valid HTTP(S) URL when the toggle is on (when off the field
  is irrelevant and validation passes).
- SettingsView gets a new "Sharing" section between Relay and
  Migration, with a Toggle and a conditionally-shown Preview URL field.
  Field disappears entirely when the toggle is off.
- PostRowView reads previewServiceEnabled at render time, hiding the
  ShareLink when off, and reads previewServiceURL when building the
  share URL (tolerates trailing slash both ways).

Bundle behaviour: existing installs default to "on" + the public preview
service, so nothing changes for current users until they opt out.
2026-04-26 08:00:28 +02:00

444 lines
17 KiB
Swift

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