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

140 lines
6.1 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
}
/// 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 = #"(?<![\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)
}