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.
140 lines
6.1 KiB
Swift
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)
|
|
}
|