Files
andros 9e126297ac Groups follow/unfollow: bump to 1.2 and fix App Store version vars
- Groups tab: Follow/Following button per row, driven by #+GROUP: entries
  in the user's social.org. Orphaned groups (followed but not on current
  relay) appear at bottom with a distinct icon and "Not on current relay"
  caption.
- ProfileWriter: addGroup and removeGroup pure string helpers for managing
  #+GROUP: header lines.
- GroupsViewModel: load relay groups + own feed in parallel, merge into
  GroupDisplayItem list with isFollowed state; toggleFollow uses
  UploaderFactory so authentication is handled by the correct uploader.
- GroupsView: Follow button only shown when canFollow (publicFeedURL set);
  uses Button + navigationDestination(item:) instead of NavigationLink to
  avoid the iOS list auto-fire bug.
- Info.plist: CFBundleShortVersionString and CFBundleVersion now use build
  setting variables instead of hardcoded values, fixing App Store upload
  version mismatch.
- Bump MARKETING_VERSION to 1.2, CURRENT_PROJECT_VERSION to 10, and app
  category to Social Networking.
2026-05-16 11:31:30 +02:00

186 lines
6.7 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class GroupsViewModel {
struct GroupDisplayItem: Identifiable, Hashable {
let name: String
let slug: String? // nil = followed but not on the current relay
let relayURL: URL
var isFollowed: Bool
var id: String { "\(name)|\(relayURL.absoluteString)" }
// Hash and equality are stable (exclude isFollowed) so navigation works correctly.
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
var items: [GroupDisplayItem] = []
var selectedItem: GroupDisplayItem?
var posts: [OrgSocialPost] = []
var isLoadingGroups = false
var isLoadingPosts = false
var groupsError: String?
var postsError: String?
var followError: String?
private var togglingIDs: Set<String> = []
// MARK: - Derived config
private var relayURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "relayURL"),
let url = URL(string: raw) else { return nil }
return url
}
private var ownFeedURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"),
let url = URL(string: raw), url.scheme?.hasPrefix("http") == true else { return nil }
return url
}
var useRelay: Bool {
UserDefaults.standard.object(forKey: "useRelay") as? Bool ?? true
}
var canFollow: Bool { ownFeedURL != nil }
func isToggling(_ item: GroupDisplayItem) -> Bool {
togglingIDs.contains(item.id)
}
// MARK: - Load
func loadGroups() async {
guard useRelay else {
groupsError = "Groups require a relay. Enable Use Relay in Settings."
items = []
return
}
guard let relay = relayURL else {
groupsError = "No relay URL configured."
return
}
isLoadingGroups = true
groupsError = nil
defer { isLoadingGroups = false }
do {
let relayGroups = try await RelayClient().fetchGroupList(from: relay)
// Load user's currently followed groups from their own social.org
var followedGroups: [OrgSocialGroup] = []
if let ownURL = ownFeedURL,
let content = try? await FeedFetcher().fetch(from: ownURL, bypassCache: true) {
var profile = OrgSocialParser().parse(content)
profile.feedURL = ownURL
followedGroups = profile.groups
}
let relayBase = relay.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
// Map each relay group, flagging whether the user already follows it
var result: [GroupDisplayItem] = relayGroups.map { g in
let followed = followedGroups.contains {
$0.name == g.name &&
$0.relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == relayBase
}
return GroupDisplayItem(name: g.name, slug: g.slug, relayURL: relay, isFollowed: followed)
}
// Orphaned: followed in social.org but not on the current relay
let relayNames = Set(relayGroups.map(\.name))
let orphaned: [GroupDisplayItem] = followedGroups
.filter { !relayNames.contains($0.name) }
.map { GroupDisplayItem(name: $0.name, slug: nil, relayURL: $0.relayURL, isFollowed: true) }
result.append(contentsOf: orphaned)
items = result
} catch {
groupsError = error.localizedDescription
}
}
// MARK: - Follow / Unfollow
func toggleFollow(_ item: GroupDisplayItem) async {
guard !togglingIDs.contains(item.id),
let ownURL = ownFeedURL,
let uploader = UploaderFactory.makeUploader() else { return }
togglingIDs.insert(item.id)
followError = nil
defer { togglingIDs.remove(item.id) }
do {
let content = try await FeedFetcher().fetch(from: ownURL, bypassCache: true)
let pw = ProfileWriter()
let updated: String
if item.isFollowed {
updated = pw.removeGroup(name: item.name, relayURL: item.relayURL, from: content)
} else {
updated = pw.addGroup(name: item.name, relayURL: item.relayURL, to: content)
}
try await uploader.upload(content: updated)
if let idx = items.firstIndex(where: { $0.id == item.id }) {
items[idx].isFollowed.toggle()
// Remove orphaned group once unfollowed (no reason to keep it in the list)
if items[idx].slug == nil && !items[idx].isFollowed {
items.remove(at: idx)
}
}
} catch {
followError = error.localizedDescription
}
}
// MARK: - Posts
func loadPosts(for item: GroupDisplayItem) async {
guard let relay = relayURL else { return }
selectedItem = item
isLoadingPosts = true
postsError = nil
posts = []
defer { isLoadingPosts = false }
let slug = item.slug ?? item.name.lowercased().replacingOccurrences(of: " ", with: "-")
do {
let postURLs = try await RelayClient().fetchGroupPostURLs(slug: slug, from: relay)
posts = try await resolvePosts(from: postURLs)
} catch {
postsError = error.localizedDescription
}
}
private func resolvePosts(from postURLs: [String]) async throws -> [OrgSocialPost] {
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 }
}
}