7e1c37db7f
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.
444 lines
17 KiB
Swift
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
|
|
}
|
|
}
|