Files
andros 78ba61034f Fix theme colors across all screens; fix Discover title on iOS 26
- 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.
2026-05-24 21:10:04 +02:00

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