cd0f055ffb
Type `@` in the post body and a dropdown of the user's follows appears filtered by what is typed after the `@`. Tapping a candidate inserts `@nick ` into the editor and records `nick -> feedURL` in a mentionMap. On publish / save the `@nick` tokens are rewritten to the spec's `[[org-social:URL][nick]]` form before the post is serialised. Edit sheet rounds-trips: on open the existing post body is decoded so the user sees `@nick` instead of the raw Org link, and on save it is re-encoded. Source of candidates: - Follows (always; from the already-cached FollowCoordinator feed). - All relay users (when the `showAllRelayFeeds` setting is on); nicks derived from the feed URL path since fetching every profile would be too expensive, and the exact serialised URL is always correct. Design notes: - SwiftUI TextEditor exposes no caret, so detection is a trailing-token heuristic (`@[A-Za-z0-9_.-]*$`). Mid-text mentions don't trigger the dropdown — acceptable since the dropdown is purely a convenience and manually-typed `[[org-social:URL][nick]]` still works. - Rewrite is whole-word (`(?<!\w)@nick(?![\w.-])`), so email addresses and `@foo.bar` fragments that are not mapped are left alone. Longer nicks are replaced before shorter ones to avoid partial overlap. - mentionOnlyWithoutMentions warning in Compose now accepts both the mentionMap and manually-typed `[[org-social:...]]` links as evidence of mentions.
160 lines
5.9 KiB
Swift
160 lines
5.9 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OrgSocialKit
|
|
|
|
/// Serializes all follow / unfollow operations across the app and holds the
|
|
/// authoritative copy of the user's own feed in memory.
|
|
///
|
|
/// The previous design let each `ProfileView` read the feed, mutate it, and
|
|
/// upload independently. Tapping Follow on several profiles in quick
|
|
/// succession caused last-write-wins: B's upload overwrote A's because both
|
|
/// started from the same pre-A snapshot. Host CDN staleness made it worse:
|
|
/// a read right after an upload often returned the old content.
|
|
///
|
|
/// This coordinator fixes both:
|
|
/// - `toggle(url:nick:)` awaits the previous in-flight toggle before
|
|
/// running, so concurrent taps become a strict sequence.
|
|
/// - After a successful upload, `cachedContent` is updated from the value
|
|
/// we just pushed; the next toggle never needs to re-read from CDN.
|
|
@Observable @MainActor
|
|
final class FollowCoordinator {
|
|
static let shared = FollowCoordinator()
|
|
|
|
/// Normalized `absoluteString`s of currently followed URLs, for
|
|
/// O(1) lookup from the toolbar state.
|
|
private(set) var followedURLs: Set<String> = []
|
|
|
|
/// Current follow list, each with an optional display nick. Source of
|
|
/// truth for any UI that needs to name a followed account (e.g. the
|
|
/// mention autocomplete).
|
|
private(set) var follows: [OrgSocialFollow] = []
|
|
|
|
/// Monotonic counter bumped on every known mutation to the own feed
|
|
/// (follow toggle, compose, edit, delete, profile edit, pin). Views
|
|
/// observe this to trigger a refetch, since SwiftUI's TabView keeps
|
|
/// a tab's .task from firing a second time on subsequent visits.
|
|
private(set) var feedVersion: UInt = 0
|
|
|
|
@ObservationIgnored private var cachedContent: String?
|
|
@ObservationIgnored private var cachedFeedURL: URL?
|
|
@ObservationIgnored private var didRefresh = false
|
|
@ObservationIgnored private var inflightTask: Task<Void, Never>?
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Refresh
|
|
|
|
/// Fetches the own feed once per session and populates the cache.
|
|
/// Callers can invoke this repeatedly; subsequent calls no-op.
|
|
func refreshIfNeeded() async {
|
|
guard let ownURL = ownFeedURL else {
|
|
reset()
|
|
return
|
|
}
|
|
if cachedFeedURL == ownURL, didRefresh { return }
|
|
await forceRefresh()
|
|
}
|
|
|
|
func forceRefresh() async {
|
|
guard let ownURL = ownFeedURL else { reset(); return }
|
|
guard let content = try? await FeedFetcher().fetch(from: ownURL, bypassCache: true) else { return }
|
|
apply(content: content, feedURL: ownURL)
|
|
didRefresh = true
|
|
}
|
|
|
|
// MARK: - Query
|
|
|
|
func isFollowing(_ url: URL) -> Bool {
|
|
followedURLs.contains(Self.normalize(url))
|
|
}
|
|
|
|
// MARK: - Mutation
|
|
|
|
/// Toggles follow state for `url`. Operations are strictly serialized:
|
|
/// a second call awaits the first, preventing lost writes.
|
|
func toggle(url: URL, nick: String?) async {
|
|
let previous = inflightTask
|
|
let task = Task { [weak self] in
|
|
await previous?.value
|
|
await self?.performToggle(url: url, nick: nick)
|
|
}
|
|
inflightTask = task
|
|
await task.value
|
|
}
|
|
|
|
private func performToggle(url: URL, nick: String?) async {
|
|
guard let ownURL = ownFeedURL, let uploader = UploaderFactory.makeUploader() else { return }
|
|
|
|
if cachedContent == nil || cachedFeedURL != ownURL {
|
|
guard let initial = try? await FeedFetcher().fetch(from: ownURL, bypassCache: true) else { return }
|
|
apply(content: initial, feedURL: ownURL)
|
|
didRefresh = true
|
|
}
|
|
guard var content = cachedContent else { return }
|
|
|
|
let normalized = Self.normalize(url)
|
|
let currentlyFollowing = followedURLs.contains(normalized)
|
|
let pw = ProfileWriter()
|
|
content = currentlyFollowing
|
|
? pw.removeFollow(url: url, from: content)
|
|
: pw.addFollow(url: url, nick: nick, to: content)
|
|
|
|
do {
|
|
try await uploader.upload(content: content)
|
|
apply(content: content, feedURL: ownURL)
|
|
} catch {
|
|
// Upload failed; keep the previous cached content untouched.
|
|
}
|
|
}
|
|
|
|
// MARK: - Internal state sync
|
|
|
|
/// Call after any external mutation to the own feed (e.g. a post
|
|
/// published via ComposeView) to keep the cached copy authoritative.
|
|
func updateCachedContent(_ content: String) {
|
|
guard let ownURL = ownFeedURL else { return }
|
|
apply(content: content, feedURL: ownURL)
|
|
}
|
|
|
|
private func apply(content: String, feedURL: URL) {
|
|
cachedContent = content
|
|
cachedFeedURL = feedURL
|
|
var parsed = OrgSocialParser().parse(content)
|
|
parsed.feedURL = feedURL
|
|
followedURLs = Set(parsed.follows.map { Self.normalize($0.url) })
|
|
follows = parsed.follows
|
|
feedVersion &+= 1
|
|
}
|
|
|
|
private func reset() {
|
|
cachedContent = nil
|
|
cachedFeedURL = nil
|
|
followedURLs = []
|
|
follows = []
|
|
didRefresh = false
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var ownFeedURL: URL? {
|
|
guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"),
|
|
let url = URL(string: raw) else { return nil }
|
|
return url
|
|
}
|
|
|
|
/// Normalizes a URL for comparison: lowercases scheme/host (DNS is
|
|
/// case-insensitive) and strips a trailing slash on non-root paths.
|
|
/// Keeps path case intact because HTTP paths are case-sensitive.
|
|
static func normalize(_ url: URL) -> String {
|
|
guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
return url.absoluteString
|
|
}
|
|
comps.scheme = comps.scheme?.lowercased()
|
|
comps.host = comps.host?.lowercased()
|
|
var path = comps.path
|
|
if path.count > 1, path.hasSuffix("/") { path.removeLast() }
|
|
comps.path = path
|
|
return comps.url?.absoluteString ?? url.absoluteString
|
|
}
|
|
}
|