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.
97 lines
3.2 KiB
Swift
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 }
|
|
}
|
|
}
|