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

97 lines
3.2 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class GroupsViewModel {
struct GroupItem: Identifiable, Hashable {
let name: String
let slug: String
var id: String { slug }
}
var groups: [GroupItem] = []
var selectedGroup: GroupItem?
var posts: [OrgSocialPost] = []
var isLoadingGroups = false
var isLoadingPosts = false
var groupsError: String?
var postsError: String?
private var relayURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "relayURL"),
let url = URL(string: raw) else { return nil }
return url
}
var useRelay: Bool {
UserDefaults.standard.object(forKey: "useRelay") as? Bool ?? true
}
func loadGroups() async {
guard useRelay else {
groupsError = "Groups require a relay. Enable Use Relay in Settings."
groups = []
return
}
guard let relay = relayURL else {
groupsError = "No relay URL configured."
return
}
isLoadingGroups = true
groupsError = nil
defer { isLoadingGroups = false }
do {
let raw = try await RelayClient().fetchGroupList(from: relay)
groups = raw.map { GroupItem(name: $0.name, slug: $0.slug) }
} catch {
groupsError = error.localizedDescription
}
}
func loadPosts(for group: GroupItem) async {
guard let relay = relayURL else { return }
selectedGroup = group
isLoadingPosts = true
postsError = nil
posts = []
defer { isLoadingPosts = false }
do {
let postURLs = try await RelayClient().fetchGroupPostURLs(slug: group.slug, from: relay)
posts = try await resolvePosts(from: postURLs)
} catch {
postsError = error.localizedDescription
}
}
private func resolvePosts(from postURLs: [String]) async throws -> [OrgSocialPost] {
// Group post URLs by feed URL so we fetch each feed only once
var byFeed: [String: [String]] = [:]
for postURL in postURLs {
guard let hashIdx = postURL.lastIndex(of: "#") else { continue }
let feedStr = String(postURL[..<hashIdx])
byFeed[feedStr, default: []].append(postURL)
}
var result: [OrgSocialPost] = []
for (feedStr, urls) in byFeed {
guard let feedURL = URL(string: feedStr),
let content = try? await FeedFetcher().fetch(from: feedURL) else { continue }
var profile = OrgSocialParser().parse(content)
profile.feedURL = feedURL
let timestamps = Set(urls.compactMap { $0.components(separatedBy: "#").last })
for post in profile.posts where timestamps.contains(post.timestamp) {
var enriched = post
enriched.authorNick = profile.nick
enriched.authorURL = feedURL
enriched.authorAvatar = profile.avatar
enriched.feedURL = feedURL
result.append(enriched)
}
}
return result.sorted { $0.date > $1.date }
}
}