2631b0d942
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.
89 lines
3.3 KiB
Swift
89 lines
3.3 KiB
Swift
import Foundation
|
|
|
|
/// Uploads the social.org file to a Codeberg (Gitea) repository via the Contents API.
|
|
///
|
|
/// Compatible with any Gitea instance. Defaults to `https://codeberg.org`.
|
|
/// Uses `GET /api/v1/repos/{owner}/{repo}/contents/{path}` for the SHA,
|
|
/// then `PUT` to create or update the file.
|
|
public struct CodebergUploader: FeedUploader {
|
|
|
|
public let token: String
|
|
public let owner: String
|
|
public let repo: String
|
|
public let path: String
|
|
public let branch: String
|
|
public let instance: String
|
|
private let session: URLSession
|
|
|
|
public init(
|
|
token: String,
|
|
owner: String,
|
|
repo: String,
|
|
path: String = "social.org",
|
|
branch: String = "main",
|
|
instance: String = "https://codeberg.org",
|
|
session: URLSession = .shared
|
|
) {
|
|
self.token = token
|
|
self.owner = owner
|
|
self.repo = repo
|
|
self.path = path
|
|
self.branch = branch
|
|
self.instance = instance.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
self.session = session
|
|
}
|
|
|
|
public func upload(content: String) async throws {
|
|
let sha = try? await fetchCurrentSHA()
|
|
let encoded = Data(content.utf8).base64EncodedString()
|
|
|
|
var body: [String: Any] = [
|
|
"message": "Update social.org via iOS",
|
|
"content": encoded,
|
|
"branch": branch
|
|
]
|
|
if let sha { body["sha"] = sha }
|
|
|
|
let url = try contentsURL()
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = sha != nil ? "PUT" : "POST"
|
|
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
|
|
|
let (_, response) = try await perform(request)
|
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
|
throw UploadError.uploadFailed(statusCode: http.statusCode)
|
|
}
|
|
}
|
|
|
|
private func fetchCurrentSHA() async throws -> String {
|
|
let url = try contentsURL()
|
|
var request = URLRequest(url: url)
|
|
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
|
|
let (data, response) = try await perform(request)
|
|
if let http = response as? HTTPURLResponse, http.statusCode == 404 { throw UploadError.fileNotFound }
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let sha = json["sha"] as? String else { throw UploadError.fileNotFound }
|
|
return sha
|
|
}
|
|
|
|
private func contentsURL() throws -> URL {
|
|
let urlString = "\(instance)/api/v1/repos/\(owner)/\(repo)/contents/\(path)"
|
|
guard let url = URL(string: urlString) else {
|
|
throw UploadError.invalidConfiguration("Invalid Codeberg API URL: \(urlString)")
|
|
}
|
|
return url
|
|
}
|
|
|
|
private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) {
|
|
do { return try await session.data(for: request) }
|
|
catch { throw UploadError.networkError(underlying: error.localizedDescription) }
|
|
}
|
|
|
|
/// Derives the public raw URL for the file (usable as `publicFeedURL`).
|
|
public var derivedPublicURL: String {
|
|
"\(instance)/\(owner)/\(repo)/raw/branch/\(branch)/\(path)"
|
|
}
|
|
}
|