import Foundation import Observation import OrgSocialKit /// One entry that can be inserted as an Org Social mention. struct MentionCandidate: Identifiable, Hashable { let feedURL: URL let nick: String let avatarURL: URL? var id: String { feedURL.absoluteString } } /// Supplies the list of mentionable accounts for Compose / Edit autocomplete. /// /// The baseline source is always the user's current follow list (parsed by /// FollowCoordinator from the user's own feed — cheap, already in memory). /// When the `showAllRelayFeeds` setting is on, we also append every feed the /// relay knows about, profile-less for now so we don't fan out N profile /// fetches — the nick is inferred from the URL path until the user taps it /// and we know which one they meant. @Observable @MainActor final class MentionDirectory { static let shared = MentionDirectory() private(set) var candidates: [MentionCandidate] = [] private(set) var isLoading = false private init() {} /// Rebuilds the candidate list. Runs on every Compose / Edit sheet open /// so the autocomplete reflects the latest follow state and (optionally) /// the latest relay user list. func refresh() async { isLoading = true defer { isLoading = false } // Follows: always included, always first (most relevant). await FollowCoordinator.shared.refreshIfNeeded() let follows = FollowCoordinator.shared.follows var result: [MentionCandidate] = follows.compactMap { f in guard let nick = f.name, !nick.isEmpty else { return nil } return MentionCandidate(feedURL: f.url, nick: nick, avatarURL: nil) } // Relay users: only if the "show all relay feeds" setting is on. // We don't fetch profiles here — the nick is derived from the last // path segment of the feed URL (`/foo/social.org` -> `foo`), which // is a reasonable placeholder. The worst case is the user sees // `@foo` in the picker and the feed has a different NICK header; // the rewrite still serialises the right URL. let showRelay = UserDefaults.standard.object(forKey: "showAllRelayFeeds") as? Bool ?? false if showRelay { let relayRaw = UserDefaults.standard.string(forKey: "relayURL") ?? "" if let relayURL = URL(string: relayRaw), let feeds = try? await RelayClient().fetchFeeds(from: relayURL) { let followURLs = Set(follows.map { $0.url.absoluteString }) let relayCandidates = feeds.compactMap { url -> MentionCandidate? in if followURLs.contains(url.absoluteString) { return nil } let nick = Self.derivedNick(from: url) guard !nick.isEmpty else { return nil } return MentionCandidate(feedURL: url, nick: nick, avatarURL: nil) } result.append(contentsOf: relayCandidates) } } candidates = result } /// Last non-"social.org" path component, used as a placeholder nick for /// relay-only candidates where we don't have the profile yet. private static func derivedNick(from url: URL) -> String { let parts = url.path.split(separator: "/").filter { !$0.isEmpty } if let last = parts.last, last.lowercased().hasSuffix(".org") { // e.g. `/foo/social.org` -> `foo` if parts.count >= 2 { return String(parts[parts.count - 2]) } // e.g. `/social.org` -> host return url.host ?? "" } return parts.last.map(String.init) ?? url.host ?? "" } } /// Replaces `@nick` tokens in `text` with Org Social mention links using the /// provided `map` (nick -> feed URL). Serialised form: `[[org-social:URL][nick]]`. /// /// - Only whole-word `@nick` matches are replaced (so `email@host` is left /// alone; the `@` must be at a word boundary). /// - Nicks not present in `map` are left as plain text. /// - Already-serialised `[[org-social:...][...]]` links in the input are /// untouched — the regex only matches the `@nick` short form. func encodeMentions(in text: String, using map: [String: String]) -> String { guard !map.isEmpty else { return text } var result = text // Sort by descending nick length so `@foo-bar` is replaced before `@foo` // when both happen to be mapped (avoids partial overwrite). for nick in map.keys.sorted(by: { $0.count > $1.count }) { guard let url = map[nick] else { continue } let escaped = NSRegularExpression.escapedPattern(for: nick) let pattern = #"(? URL` map so the rewrite can be applied again on save. /// /// Used when the Edit sheet opens on a post that already contains mentions, /// so the user edits them as `@nick` just like in Compose. func decodeMentions(in text: String) -> (body: String, map: [String: String]) { let pattern = #"\[\[org-social:([^\]]+)\]\[([^\]]+)\]\]"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return (text, [:]) } var map: [String: String] = [:] var result = text let nsrange = NSRange(result.startIndex..., in: result) // Collect matches first, rewrite back-to-front so earlier ranges stay valid. let matches = regex.matches(in: result, range: nsrange).reversed() for match in matches { guard let urlRange = Range(match.range(at: 1), in: result), let nickRange = Range(match.range(at: 2), in: result), let fullRange = Range(match.range, in: result) else { continue } let url = String(result[urlRange]) let nick = String(result[nickRange]) map[nick] = url result.replaceSubrange(fullRange, with: "@\(nick)") } return (result, map) }