073d3346ef
- MentionAutocompleteField: use \z instead of $ so dropdown closes after Enter - MentionDirectory: handle path-less feed URLs (e.g. social.orgro.org → orgro) via penultimate host segment heuristic - Extract derivedNick logic to public inferredNick(from:) in OrgSocialKit/Util - Add URLNickTests covering 16 real relay URL patterns (vhost, root, path-less, deep GitHub/Codeberg, tilde, custom filename) - Bump build to 1.3 (16)
131 lines
5.6 KiB
Swift
131 lines
5.6 KiB
Swift
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
|
|
}
|
|
|
|
private static func derivedNick(from url: URL) -> String {
|
|
inferredNick(from: url)
|
|
}
|
|
}
|
|
|
|
/// 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 = #"(?<![\w])@"# + escaped + #"(?![\w.-])"#
|
|
let replacement = "[[org-social:\(url)][\(nick)]]"
|
|
result = result.replacingOccurrences(
|
|
of: pattern,
|
|
with: replacement,
|
|
options: .regularExpression
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Inverse of `encodeMentions`: converts `[[org-social:URL][nick]]` links in
|
|
/// `text` into `@nick` short form, and returns the resulting text plus the
|
|
/// `nick -> 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)
|
|
}
|