Files
andros cd0f055ffb Mention autocomplete in Compose and Edit
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.
2026-04-24 10:46:26 +02:00

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
}
}