Files
org-social-ios/App/ViewModels/EditProfileViewModel.swift
andros 2631b0d942 Auto-refresh own profile and timeline after any feed mutation
Own Profile and Timeline kept stale views between tab switches because
SwiftUI's TabView preserves state, so .task runs only once. Now every
write path (compose, edit, delete, react, boost, vote, follow/unfollow,
profile edit, migration, pin/unpin) hands the new feed content to
FollowCoordinator, which bumps a feedVersion counter. Profile and
Timeline observe that counter and re-fetch.

Also align GitHub and Codeberg commit messages with the shortened
"via iOS" client tag.
2026-04-21 11:09:03 +02:00

129 lines
4.8 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class EditProfileViewModel {
var nick = ""
var title = ""
var description = ""
var avatar = ""
var location = ""
var birthday = ""
var language = ""
var linksText = ""
var contactsText = ""
var isSaving = false
var errorMessage: String?
var saved = false
private let pw = ProfileWriter()
var isLoading = false
init() { loadFromUserDefaults() }
func load() async {
guard let url = ownFeedURL else { return }
isLoading = true
defer { isLoading = false }
// bypassCache so the edit form reflects the real feed, not a stale CDN copy.
guard let content = try? await FeedFetcher().fetch(from: url, bypassCache: true) else { return }
loadFromContent(content)
}
func save() async {
guard let feedURL = ownFeedURL, let uploader = makeUploader() else {
errorMessage = "Configure your feed in Settings before editing your profile."
return
}
isSaving = true
errorMessage = nil
defer { isSaving = false }
do {
// bypassCache so we patch the latest authoritative content, not a stale cache.
var content = try await FeedFetcher().fetch(from: feedURL, bypassCache: true)
content = pw.setKeyword("NICK", value: nick, in: content)
content = pw.setKeyword("TITLE", value: title, in: content)
if description.isEmpty {
content = pw.removeKeyword("DESCRIPTION", from: content)
} else {
content = pw.setKeyword("DESCRIPTION", value: description, in: content)
}
if avatar.isEmpty {
content = pw.removeKeyword("AVATAR", from: content)
} else {
content = pw.setKeyword("AVATAR", value: avatar, in: content)
}
if location.isEmpty {
content = pw.removeKeyword("LOCATION", from: content)
} else {
content = pw.setKeyword("LOCATION", value: location, in: content)
}
if birthday.isEmpty {
content = pw.removeKeyword("BIRTHDAY", from: content)
} else {
content = pw.setKeyword("BIRTHDAY", value: birthday, in: content)
}
if language.isEmpty {
content = pw.removeKeyword("LANGUAGE", from: content)
} else {
content = pw.setKeyword("LANGUAGE", value: language, in: content)
}
let links = linksText.components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
content = pw.setMultiKeyword("LINK", values: links, in: content)
let contacts = contactsText.components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
content = pw.setMultiKeyword("CONTACT", values: contacts, in: content)
try await uploader.upload(content: content)
FollowCoordinator.shared.updateCachedContent(content)
saved = true
} catch {
errorMessage = error.localizedDescription
}
}
// MARK: - Private helpers
private func loadFromUserDefaults() {
nick = UserDefaults.standard.string(forKey: "nick") ?? ""
}
private func loadFromContent(_ content: String) {
// Quick parse: read header keyword values
for line in content.components(separatedBy: "\n") {
let t = line.trimmingCharacters(in: .whitespaces)
if t == "* Posts" { break }
guard t.hasPrefix("#+"), let colonIdx = t.firstIndex(of: ":") else { continue }
let kw = String(t[t.index(t.startIndex, offsetBy: 2)..<colonIdx]).uppercased()
let val = String(t[t.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
switch kw {
case "NICK": nick = val
case "TITLE": title = val
case "DESCRIPTION": description = val
case "AVATAR": avatar = val
case "LOCATION": location = val
case "BIRTHDAY": birthday = val
case "LANGUAGE": language = val
case "LINK": linksText += (linksText.isEmpty ? "" : "\n") + val
case "CONTACT": contactsText += (contactsText.isEmpty ? "" : "\n") + val
default: break
}
}
}
private var ownFeedURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"),
let url = URL(string: raw), url.scheme?.hasPrefix("http") == true else { return nil }
return url
}
private func makeUploader() -> (any FeedUploader)? { UploaderFactory.makeUploader() }
}