9e126297ac
- 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.
186 lines
6.7 KiB
Swift
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 }
|
|
}
|
|
}
|