d2ab6ea377
New files: AppTheme (palette + env key), ThemeManager (Observable singleton, UserDefaults persistence). Themes: Default, Emacs, Dracula, One Dark, Monokai, Material. Each controls background, accent, text, code block colors and a highlight.js theme for syntax highlighting. RootView applies preferredColorScheme, tint and appTheme env to the root group. SettingsView applies the same modifiers to its NavigationStack so the modal sheet is themed immediately (sheets run in a separate UIWindow and don't inherit preferredColorScheme from the parent hierarchy). Theme selection in Settings uses onTapGesture instead of buttonStyle(.plain) inside Form, which was silently intercepting the button action. CodeBlockView, TimelineView, NotificationsView all read from appTheme env.
669 lines
26 KiB
Swift
669 lines
26 KiB
Swift
import SwiftUI
|
|
import OrgSocialKit
|
|
|
|
struct SettingsView: View {
|
|
@State private var viewModel = SettingsViewModel()
|
|
@State private var themeManager = ThemeManager.shared
|
|
@Environment(\.dismiss) private var dismiss
|
|
@AppStorage("postTruncationLimit") private var postTruncationLimit = 500
|
|
@State private var testStatus: TestConnectionStatus = .idle
|
|
@State private var blockList = BlockList.shared
|
|
@State private var showEULA = false
|
|
@State private var showDeleteConfirm = false
|
|
@State private var isDeleting = false
|
|
@State private var deleteError: String?
|
|
|
|
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
|
|
timelineSection
|
|
appearanceSection
|
|
sharingSection
|
|
moderationSection
|
|
blockedAccountsSection
|
|
migrationSection
|
|
exportSection
|
|
aboutSection
|
|
dangerZoneSection
|
|
versionSection
|
|
}
|
|
.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 ?? "")
|
|
}
|
|
.alert("Delete account?", isPresented: $showDeleteConfirm) {
|
|
Button("Cancel", role: .cancel) {}
|
|
Button("Delete", role: .destructive) { Task { await runAccountDeletion() } }
|
|
} message: {
|
|
Text(deleteAlertMessage)
|
|
}
|
|
.alert("Delete failed", isPresented: Binding(
|
|
get: { deleteError != nil },
|
|
set: { if !$0 { deleteError = nil } }
|
|
)) {
|
|
Button("OK") { deleteError = nil }
|
|
} message: {
|
|
Text(deleteError ?? "")
|
|
}
|
|
.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() }
|
|
}
|
|
// Modal sheets run in a separate UIWindow and do not inherit
|
|
// preferredColorScheme from the parent view hierarchy.
|
|
// Apply theme overrides here so Settings is always in sync.
|
|
.preferredColorScheme(themeManager.current.colorScheme)
|
|
.tint(themeManager.current.accent)
|
|
.environment(\.appTheme, themeManager.current)
|
|
}
|
|
|
|
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 {
|
|
InAppBrowserLink(url: URL(string: "https://host.org-social.org/signup")!) {
|
|
Text("Sign up for a free vhost account →")
|
|
}
|
|
.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.")
|
|
InAppBrowserLink(url: URL(string: "https://github.com/settings/personal-access-tokens/new")!) {
|
|
Text("Generate a token →")
|
|
}
|
|
}
|
|
.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.")
|
|
InAppBrowserLink(url: URL(string: "https://codeberg.org/user/settings/applications")!) {
|
|
Text("Open Codeberg token page →")
|
|
}
|
|
}
|
|
.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)
|
|
.onChange(of: viewModel.useRelay) { _, enabled in
|
|
if !enabled { Task { await PushRegistration.shared.unsubscribe() } }
|
|
else { Task { await PushRegistration.shared.requestPermissionAndRegister() } }
|
|
}
|
|
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: - Timeline
|
|
|
|
private var timelineSection: some View {
|
|
Section {
|
|
Stepper(
|
|
"Truncate posts at \(postTruncationLimit) characters",
|
|
value: $postTruncationLimit,
|
|
in: 100...2000,
|
|
step: 100
|
|
)
|
|
} header: {
|
|
Text("Timeline")
|
|
} footer: {
|
|
Text("Posts longer than this limit show a Read more button. Tap it to expand the full text inline.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Appearance
|
|
|
|
private var appearanceSection: some View {
|
|
Section {
|
|
ForEach(AppTheme.all) { theme in
|
|
HStack(spacing: 12) {
|
|
themeSwatches(theme)
|
|
Text(theme.name)
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
if themeManager.current.id == theme.id {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(Color.accentColor)
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
themeManager.select(theme)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Appearance")
|
|
} footer: {
|
|
Text("Changes the color scheme across the whole app. The theme is applied immediately.")
|
|
}
|
|
}
|
|
|
|
private func themeSwatches(_ theme: AppTheme) -> some View {
|
|
HStack(spacing: 2) {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(theme.background)
|
|
.frame(width: 18, height: 28)
|
|
.overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(Color.secondary.opacity(0.2), lineWidth: 0.5))
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(theme.secondaryBackground)
|
|
.frame(width: 18, height: 28)
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(theme.accent)
|
|
.frame(width: 18, height: 28)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.secondary.opacity(0.15), lineWidth: 0.5))
|
|
}
|
|
|
|
// 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: - Moderation (UGC 1.2: filter)
|
|
|
|
private var moderationSection: some View {
|
|
Section {
|
|
TextEditor(text: $viewModel.mutedWords)
|
|
.frame(minHeight: 100)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
.onChange(of: viewModel.mutedWords) { _, newValue in
|
|
// Persist immediately, independently of the global Save:
|
|
// muted words have no validation and shouldn't be gated
|
|
// behind the credentials/feed-URL gate.
|
|
UserDefaults.standard.set(newValue, forKey: "mutedWords")
|
|
}
|
|
} header: {
|
|
Text("Muted words")
|
|
} footer: {
|
|
Text("One word or phrase per line. Posts whose body or tags contain any of these (case-insensitive) are hidden everywhere in the app.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Blocked accounts (UGC 1.2: block + unblock list)
|
|
|
|
@ViewBuilder
|
|
private var blockedAccountsSection: some View {
|
|
Section {
|
|
if blockList.blocked.isEmpty {
|
|
Text("No blocked accounts.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(blockList.blocked, id: \.self) { feedURL in
|
|
HStack {
|
|
Image(systemName: "hand.raised.slash")
|
|
.foregroundStyle(.secondary)
|
|
Text(feedURL)
|
|
.font(.footnote)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Spacer()
|
|
Button("Unblock") { blockList.unblock(feedURL) }
|
|
.buttonStyle(.borderless)
|
|
.font(.footnote.weight(.medium))
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Blocked accounts")
|
|
} footer: {
|
|
Text("Blocked feeds are hidden from the timeline, notifications and threads. Block from any profile via the Block button.")
|
|
}
|
|
}
|
|
|
|
// MARK: - About
|
|
|
|
private var aboutSection: some View {
|
|
Section("About") {
|
|
Button {
|
|
showEULA = true
|
|
} label: {
|
|
Label("Terms of Use", systemImage: "doc.text")
|
|
.foregroundStyle(.primary)
|
|
}
|
|
InAppBrowserLink(url: URL(string: "https://git.andros.dev/andros/contribute")!) {
|
|
Label("Issue / Pull Request", systemImage: "arrow.triangle.pull")
|
|
.foregroundStyle(.primary)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showEULA) {
|
|
EULAView(onDismiss: { showEULA = false })
|
|
}
|
|
}
|
|
|
|
// MARK: - Version
|
|
|
|
private var versionSection: some View {
|
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "-"
|
|
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "-"
|
|
return Section {
|
|
Text("Version \(version) (\(build))")
|
|
.frame(maxWidth: .infinity)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundStyle(.secondary)
|
|
.font(.footnote)
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
|
|
// MARK: - Danger zone (Guideline 5.1.1(v): account deletion)
|
|
|
|
private var dangerZoneSection: some View {
|
|
Section {
|
|
Button(role: .destructive) {
|
|
showDeleteConfirm = true
|
|
} label: {
|
|
HStack {
|
|
Label("Delete account", systemImage: "trash")
|
|
Spacer()
|
|
if isDeleting { ProgressView() }
|
|
}
|
|
}
|
|
.disabled(isDeleting)
|
|
} header: {
|
|
Text("Danger zone")
|
|
} footer: {
|
|
Text(deleteFooterText)
|
|
}
|
|
}
|
|
|
|
private var deleteFooterText: String {
|
|
switch viewModel.uploadMethod {
|
|
case .vfile:
|
|
return "Permanently deletes your social.org from the host and removes all credentials, follows, drafts, blocked accounts and muted words from this device. This cannot be undone."
|
|
case .github, .codeberg, .webdav:
|
|
return "Removes all credentials, follows, drafts, blocked accounts and muted words from this device. Your social.org file is hosted by you and is not modified — manage it on your hosting provider."
|
|
}
|
|
}
|
|
|
|
private var deleteAlertMessage: String {
|
|
switch viewModel.uploadMethod {
|
|
case .vfile:
|
|
return "Your social.org file will be permanently deleted from the host and all data on this device will be erased. This cannot be undone."
|
|
case .github, .codeberg, .webdav:
|
|
return "All credentials and data on this device will be erased. The file on your own server is not modified."
|
|
}
|
|
}
|
|
|
|
private func runAccountDeletion() async {
|
|
isDeleting = true
|
|
defer { isDeleting = false }
|
|
do {
|
|
try await AccountDeletion.deleteCurrentAccount()
|
|
dismiss()
|
|
} catch {
|
|
deleteError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|