Files
andros b8364df37d In-app browser, muted words and remove donation link
App Store review for build 1.0 (2) flagged three guidelines; this
covers the two that need binary changes:

3.1.1 Donate: external donation links are forbidden outside the US
storefront's external-link API. Drop the Liberapay row from the
Settings About section entirely. The donation pointer stays on the
project README outside the bundle.

4 Design: bouncing the user out to Safari for sign-in or sign-up is
rejected. Wrap SFSafariViewController in a SwiftUI helper
(InAppBrowserView + InAppBrowserLink) and route the four external
account-related links through it: vfile signup, GitHub PAT, Codeberg
token page, and the Issue/PR link. The host and SSL chain stay
visible to the user without leaving the app.

1.2 UGC (filter requirement): add a Muted words section in Settings.
The user types one token per line; MuteFilter (in OrgSocialKit, pure
Swift, 7 unit tests) does case-insensitive substring matching against
post body + tags. Applied in TimelineViewModel.load and mergeOwnFeed
plus ProfileView's posts list, so muted content disappears everywhere
the timeline aggregates it. Persistence is immediate via onChange so
muted words don't depend on the credentials Save gate.

Lock the app to portrait via UISupportedInterfaceOrientations in
project.yml so xcodegen stops rewriting the Info.plist back to the
default landscape-too set.

Block, report and EULA controls are the next pass for the same UGC
guideline.
2026-05-02 08:31:50 +02:00

167 lines
6.8 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class SettingsViewModel {
// Common
var uploadMethod: UploadMethod
var publicFeedURL: String
var relayURL: String
var maxPostAgeDays: Int
var useRelay: Bool
var showAllRelayFeeds: Bool
// Sharing preview service used by the per-post Share button.
var previewServiceEnabled: Bool
var previewServiceURL: String
// VFile
var vfileURL: String
// GitHub
var githubToken: String
var githubOwner: String
var githubRepo: String
var githubPath: String
var githubBranch: String
// Codeberg
var codebergToken: String
var codebergOwner: String
var codebergRepo: String
var codebergPath: String
var codebergBranch: String
var codebergInstance: String
// WebDAV
var webdavURL: String
var webdavUsername: String
var webdavPassword: String
// Moderation UGC guideline 1.2 "method for filtering objectionable content".
// One token per line; matching is case-insensitive substring against post
// body + tags via `OrgSocialKit.MuteFilter`.
var mutedWords: String
private let d = UserDefaults.standard
init() {
uploadMethod = UploadMethod(rawValue: UserDefaults.standard.string(forKey: "uploadMethod") ?? "") ?? .vfile
publicFeedURL = UserDefaults.standard.string(forKey: "publicFeedURL") ?? ""
relayURL = UserDefaults.standard.string(forKey: "relayURL") ?? "https://relay.org-social.org"
maxPostAgeDays = UserDefaults.standard.integer(forKey: "maxPostAgeDays").nonZero ?? 7
useRelay = UserDefaults.standard.object(forKey: "useRelay") as? Bool ?? true
showAllRelayFeeds = UserDefaults.standard.object(forKey: "showAllRelayFeeds") as? Bool ?? false
previewServiceEnabled = UserDefaults.standard.object(forKey: "previewServiceEnabled") as? Bool ?? true
previewServiceURL = UserDefaults.standard.string(forKey: "previewServiceURL") ?? "https://preview.org-social.org/"
vfileURL = UserDefaults.standard.string(forKey: "vfileURL") ?? ""
githubToken = UserDefaults.standard.string(forKey: "githubToken") ?? ""
githubOwner = UserDefaults.standard.string(forKey: "githubOwner") ?? ""
githubRepo = UserDefaults.standard.string(forKey: "githubRepo") ?? ""
githubPath = UserDefaults.standard.string(forKey: "githubPath").nilIfEmpty ?? "social.org"
githubBranch = UserDefaults.standard.string(forKey: "githubBranch").nilIfEmpty ?? "main"
codebergToken = UserDefaults.standard.string(forKey: "codebergToken") ?? ""
codebergOwner = UserDefaults.standard.string(forKey: "codebergOwner") ?? ""
codebergRepo = UserDefaults.standard.string(forKey: "codebergRepo") ?? ""
codebergPath = UserDefaults.standard.string(forKey: "codebergPath").nilIfEmpty ?? "social.org"
codebergBranch = UserDefaults.standard.string(forKey: "codebergBranch").nilIfEmpty ?? "main"
codebergInstance = UserDefaults.standard.string(forKey: "codebergInstance").nilIfEmpty ?? "https://codeberg.org"
webdavURL = UserDefaults.standard.string(forKey: "webdavURL") ?? ""
webdavUsername = UserDefaults.standard.string(forKey: "webdavUsername") ?? ""
webdavPassword = UserDefaults.standard.string(forKey: "webdavPassword") ?? ""
mutedWords = UserDefaults.standard.string(forKey: "mutedWords") ?? ""
}
// MARK: - Validation
var publicFeedURLValid: Bool { isHTTPURL(publicFeedURL) }
var relayURLValid: Bool { isHTTPURL(relayURL) }
var uploadConfigValid: Bool {
switch uploadMethod {
case .vfile: return isHTTPURL(vfileURL)
case .github: return !githubToken.isEmpty && !githubOwner.isEmpty && !githubRepo.isEmpty
case .codeberg: return !codebergToken.isEmpty && !codebergOwner.isEmpty && !codebergRepo.isEmpty
case .webdav: return isHTTPURL(webdavURL)
}
}
/// When the preview toggle is off the URL is irrelevant (the field is
/// disabled), so we only require validity in the on state.
var previewServiceValid: Bool { !previewServiceEnabled || isHTTPURL(previewServiceURL) }
var canSave: Bool { publicFeedURLValid && relayURLValid && uploadConfigValid && previewServiceValid }
// MARK: - Derived public URL hints
var githubDerivedPublicURL: String {
guard !githubOwner.isEmpty, !githubRepo.isEmpty else { return "" }
let p = githubPath.isEmpty ? "social.org" : githubPath
let b = githubBranch.isEmpty ? "main" : githubBranch
return "https://raw.githubusercontent.com/\(githubOwner)/\(githubRepo)/\(b)/\(p)"
}
var codebergDerivedPublicURL: String {
guard !codebergOwner.isEmpty, !codebergRepo.isEmpty else { return "" }
let inst = codebergInstance.isEmpty ? "https://codeberg.org" : codebergInstance
let p = codebergPath.isEmpty ? "social.org" : codebergPath
let b = codebergBranch.isEmpty ? "main" : codebergBranch
return "\(inst)/\(codebergOwner)/\(codebergRepo)/raw/branch/\(b)/\(p)"
}
// MARK: - Save
func saveIfValid() {
guard canSave else { return }
d.set(uploadMethod.rawValue, forKey: "uploadMethod")
d.set(publicFeedURL, forKey: "publicFeedURL")
d.set(relayURL, forKey: "relayURL")
d.set(maxPostAgeDays, forKey: "maxPostAgeDays")
d.set(useRelay, forKey: "useRelay")
d.set(showAllRelayFeeds, forKey: "showAllRelayFeeds")
d.set(previewServiceEnabled, forKey: "previewServiceEnabled")
d.set(previewServiceURL, forKey: "previewServiceURL")
d.set(vfileURL, forKey: "vfileURL")
d.set(githubToken, forKey: "githubToken")
d.set(githubOwner, forKey: "githubOwner")
d.set(githubRepo, forKey: "githubRepo")
d.set(githubPath, forKey: "githubPath")
d.set(githubBranch, forKey: "githubBranch")
d.set(codebergToken, forKey: "codebergToken")
d.set(codebergOwner, forKey: "codebergOwner")
d.set(codebergRepo, forKey: "codebergRepo")
d.set(codebergPath, forKey: "codebergPath")
d.set(codebergBranch, forKey: "codebergBranch")
d.set(codebergInstance, forKey: "codebergInstance")
d.set(webdavURL, forKey: "webdavURL")
d.set(webdavUsername, forKey: "webdavUsername")
d.set(webdavPassword, forKey: "webdavPassword")
d.set(mutedWords, forKey: "mutedWords")
}
private func isHTTPURL(_ raw: String) -> Bool {
guard let url = URL(string: raw) else { return false }
return url.scheme?.hasPrefix("http") == true && url.host != nil
}
}
private extension Int {
var nonZero: Int? { self == 0 ? nil : self }
}
private extension String? {
var nilIfEmpty: String? { self?.isEmpty == false ? self : nil }
}