Files
org-social-ios/App/Views/Settings/SettingsView.swift
andros d2ab6ea377 Add color theme system with 6 built-in themes; fix modal sheet theming
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.
2026-05-24 09:50:16 +02:00

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
}
}