78ba61034f
- Remove .toolbarBackground(.visible) from all NavigationStack views: this modifier suppresses large title rendering in iOS 26. - Discover: switch to always-present List + overlay pattern so SwiftUI never loses the scroll context during loading transitions. - Discover, Notifications, Groups: use .inline title mode; the tab bar already identifies these screens, large titles are redundant. - RootView: add .toolbarColorScheme propagation for nav bar foreground. - Settings sheet: apply theme background and toolbar color to the Form.
673 lines
26 KiB
Swift
673 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
|
|
@Environment(\.appTheme) private var theme
|
|
|
|
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 ?? "")
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.background(theme.background)
|
|
.navigationTitle("Settings")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
|
|
.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
|
|
}
|
|
}
|