bc29cda05a
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.
237 lines
10 KiB
Swift
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) }
|
|
}
|
|
}
|