Files
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

90 lines
3.4 KiB
Swift

import Foundation
/// Uploads the social.org file to a GitHub repository via the Contents API.
///
/// Uses `GET /repos/{owner}/{repo}/contents/{path}` to fetch the current SHA,
/// then `PUT` to create or update the file.
/// Required token scope: `repo` (or `public_repo` for public repositories).
public struct GitHubUploader: FeedUploader {
public let token: String
public let owner: String
public let repo: String
public let path: String
public let branch: String
private let session: URLSession
private static let apiBase = "https://api.github.com"
public init(
token: String,
owner: String,
repo: String,
path: String = "social.org",
branch: String = "main",
session: URLSession = .shared
) {
self.token = token
self.owner = owner
self.repo = repo
self.path = path
self.branch = branch
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 = "PUT"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
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("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
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 = "\(Self.apiBase)/repos/\(owner)/\(repo)/contents/\(path)"
guard let url = URL(string: urlString) else {
throw UploadError.invalidConfiguration("Invalid GitHub 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 {
"https://raw.githubusercontent.com/\(owner)/\(repo)/\(branch)/\(path)"
}
}