Files
andros bc29cda05a Gate relay-only features, add pin/migrate/test-connection actions, fix profile refresh
Relay=false degradation:
- Notifications, Discover, Groups and Thread ViewModels now bail with a "X requires a relay. Enable Use Relay in Settings." message instead of firing requests that would fail.
- PostRowView.fetchInteractionData skips the /replies/ and /interactions/ calls when the relay is disabled so timeline rows stop emitting background noise.
- SearchView.onSubmit also respects useRelay. TimelineView already fell back to the follow list via TimelineFetcher.

Profile edit UX:
- ProfileView reloads via a new ProfileViewModel.reload() (bypassCache=true) on the Edit Profile sheet's onDismiss, so header/description edits show up instantly instead of waiting for a tab switch.
- EditProfileViewModel now also bypassCache on both load and save, so the form reflects the authoritative feed and save patches fresh content rather than a stale CDN copy.
- ProfileFetcher.fetch gains a bypassCache parameter forwarded to FeedFetcher.

Pin / Unpin own post:
- Context menu on an own post exposes "Pin to profile" and "Unpin" actions.
- PostActionsViewModel.pinPost/unpinPost rewrite the feed's #+PINNED keyword through ProfileWriter.setKeyword/removeKeyword and upload.

Migration announcement:
- New Account section in Settings with "Announce account migration" button.
- MigrationSheet lets the user enter the new feed URL and posts a :MIGRATION: <old> <new> entry through NewPostOptions.migration and PostWriter.appendPost.

Settings test-connection:
- Each upload method section (VFile / GitHub / Codeberg / WebDAV) shows a "Test connection" button that GETs the Public Feed URL with cache bypassed and parses it. Shows the parsed nick on success; inline error on failure. This catches the common misconfiguration of pasting a wrong URL without exercising a destructive upload.

Miscellaneous:
- Thread and Relay clients get a 10s request timeout so slow or unreachable hosts can't pin the UI on spinner for the 60s URLSession default.
- TimelineView switched to ScrollView + LazyVStack (from List) to avoid SwiftUI List rows auto-firing the first NavigationLink on any tap — the underlying cause of "back from a thread lands on a profile" reports.
- Removed the opacity(0.7) on thread ancestors so they no longer look washed out.
- PostRowView action-bar buttons gained explicit accessibilityLabel ("Reply", "React", "Boost", "Boosted") in place of raw SF Symbol names.
- PostRowView body text is now a NavigationLink into the thread when the post has replies or is itself a reply.
- TODO.org captures the full QA round.
2026-04-20 21:59:52 +02:00

237 lines
10 KiB
Swift

import Foundation
/// Errors from relay API calls.
public enum RelayError: Error, Sendable {
case httpError(statusCode: Int)
case invalidResponse
case networkError(underlying: String)
}
extension RelayError: LocalizedError {
public var errorDescription: String? {
switch self {
case .httpError(let code): return "Relay returned HTTP \(code)."
case .invalidResponse: return "Relay response could not be parsed."
case .networkError(let m): return "Network error: \(m)"
}
}
}
/// Interacts with an Org Social relay server.
///
/// The relay exposes a `GET /feeds/` endpoint that returns the list of all
/// registered feed URLs in the network.
public struct RelayClient: Sendable {
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
/// Fetches the list of all feed URLs known to the relay.
///
/// - Parameter relayURL: Base URL of the relay (e.g. `https://relay.org-social.org`).
/// - Returns: Array of feed URLs.
/// - Throws: `RelayError` on failure.
public func fetchFeeds(from relayURL: URL) async throws -> [URL] {
let endpoint = relayURL
.absoluteString
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: endpoint + "/feeds/") else {
throw RelayError.invalidResponse
}
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(from: url)
} catch {
throw RelayError.networkError(underlying: error.localizedDescription)
}
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw RelayError.httpError(statusCode: http.statusCode)
}
// Expected: {"data": ["https://...", "https://...", ...]}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let rawFeeds = json["data"] as? [String] else {
throw RelayError.invalidResponse
}
return rawFeeds.compactMap { URL(string: $0) }
}
/// Fetches vote counts for each option of a poll post.
///
/// - Parameters:
/// - postURL: Canonical URL of the poll post (`feed#timestamp`).
/// - relayURL: Base URL of the relay.
public func fetchPollVotes(for postURL: String, from relayURL: URL) async throws -> [OrgSocialPollVote] {
let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: "+")
let encoded = postURL.addingPercentEncoding(withAllowedCharacters: allowed) ?? postURL
guard let url = URL(string: "\(base)/polls/votes/?post=\(encoded)") else {
throw RelayError.invalidResponse
}
let (data, response) = try await fetchData(from: url)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw RelayError.httpError(statusCode: http.statusCode)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["data"] as? [[String: Any]] else {
throw RelayError.invalidResponse
}
return items.compactMap { item in
guard let option = item["option"] as? String else { return nil }
let votes = (item["votes"] as? [String]) ?? []
return OrgSocialPollVote(option: option, voterURLs: votes)
}
}
/// Fetches the list of groups known to the relay.
///
/// - Returns: Array of `(name, slug)` pairs.
public func fetchGroupList(from relayURL: URL) async throws -> [(name: String, slug: String)] {
let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: "\(base)/groups/") else { throw RelayError.invalidResponse }
let (data, response) = try await fetchData(from: url)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw RelayError.httpError(statusCode: http.statusCode)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let links = json["_links"] as? [String: Any],
let groupLinks = links["groups"] as? [[String: Any]] else {
throw RelayError.invalidResponse
}
return groupLinks.compactMap { g in
guard let name = g["name"] as? String,
let href = g["href"] as? String,
let slug = href.split(separator: "/").last.map(String.init) else { return nil }
return (name: name, slug: slug)
}
}
/// Fetches post URLs in a group (raw, unresolved).
///
/// - Parameters:
/// - slug: Group slug (e.g. `"emacs"`, `"org-social"`).
/// - relayURL: Base URL of the relay.
public func fetchGroupPostURLs(slug: String, from relayURL: URL) async throws -> [String] {
let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: "\(base)/groups/\(slug)/") else { throw RelayError.invalidResponse }
let (data, response) = try await fetchData(from: url)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw RelayError.httpError(statusCode: http.statusCode)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["data"] as? [[String: Any]] else {
throw RelayError.invalidResponse
}
return items.compactMap { $0["post"] as? String }
}
/// Searches for post URLs matching `query` (text or tag search).
///
/// - Parameters:
/// - query: Search term or tag name.
/// - isTag: If `true`, uses `?tag=` parameter; otherwise `?q=`.
/// - relayURL: Base URL of the relay.
/// - Returns: Array of matching post URLs.
public func searchPostURLs(query: String, isTag: Bool, from relayURL: URL) async throws -> [String] {
let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let param = isTag ? "tag" : "q"
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
guard let url = URL(string: "\(base)/search/?\(param)=\(encoded)") else {
throw RelayError.invalidResponse
}
let (data, response) = try await fetchData(from: url)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw RelayError.httpError(statusCode: http.statusCode)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return []
}
// The relay returns `data` as a flat array of post URL strings.
if let urls = json["data"] as? [String] {
return urls
}
// Accept the older object form in case the relay changes its schema.
if let items = json["data"] as? [[String: Any]] {
return items.compactMap { $0["post"] as? String }
}
return []
}
/// Registers a feed URL with the relay so it gets indexed.
///
/// - Parameters:
/// - feedURL: The public feed URL to register.
/// - relayURL: Base URL of the relay.
/// - Throws: `RelayError` on failure (ignores 4xx as feed may already be registered).
public func registerFeed(_ feedURL: URL, with relayURL: URL) async throws {
let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: "\(base)/feeds/") else { throw RelayError.invalidResponse }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: ["feed": feedURL.absoluteString])
let (_, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, http.statusCode >= 500 {
throw RelayError.httpError(statusCode: http.statusCode)
}
}
/// Fetches notifications (mentions, reactions, replies, boosts) for `feedURL`.
///
/// - Parameters:
/// - feedURL: The public feed URL of the user (e.g. `https://andros.dev/social.org`).
/// - relayURL: Base URL of the relay.
/// - Returns: Array of notifications, most recent first.
/// - Throws: `RelayError` on failure.
public func fetchNotifications(for feedURL: URL, from relayURL: URL) async throws -> [OrgSocialNotification] {
let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let encodedFeed = feedURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? feedURL.absoluteString
guard let url = URL(string: "\(base)/notifications/?feed=\(encodedFeed)") else {
throw RelayError.invalidResponse
}
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(from: url)
} catch {
throw RelayError.networkError(underlying: error.localizedDescription)
}
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
throw RelayError.httpError(statusCode: http.statusCode)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["data"] as? [[String: Any]] else {
throw RelayError.invalidResponse
}
return items.compactMap { item in
guard let post = item["post"] as? String,
let typeStr = item["type"] as? String else { return nil }
let kind = OrgSocialNotification.Kind(rawValue: typeStr) ?? .unknown
let emoji = item["emoji"] as? String
let parent = item["parent"] as? String
return OrgSocialNotification(kind: kind, post: post, emoji: emoji, parent: parent)
}
}
// MARK: - Private helpers
private func fetchData(from url: URL) async throws -> (Data, URLResponse) {
var request = URLRequest(url: url)
request.timeoutInterval = 10
do { return try await session.data(for: request) }
catch { throw RelayError.networkError(underlying: error.localizedDescription) }
}
}