// // AdminMentions.swift // FTStore Admin // // The `@handle` grammar on FTStore Admin's side: drawing the mentions in a body // somebody else wrote, and completing one while a reply is being typed. // // The grammar mirrors `MENTION_RE` in `web/src/lib/feedback.ts`, and it is // copied rather than derived because there is nothing to derive it from: the // server sends the text, not a list of ranges. The two have to agree, though. A // handle highlighted here that the server did not resolve is a ping that never // arrived, so change both or neither. // // Highlighting says nothing about delivery. Whether a handle reached anybody is // the server's answer, and an `@` nobody answers to still reads as the // characters the person typed, which is the honest rendering of a typo. // // Suggestions come from the thread on screen and nowhere else, exactly as they // do in the app and the dashboard. Everyone in a conversation is already // visible, so offering them gives nothing away, while a lookup across the board // would be a list of the people using FTStore. Typing a handle in full still // reaches whoever owns it. // import SwiftUI enum AdminMentions { /// Letters, digits and underscores, 2 to 32 of them, not preceded by another /// word character or a second `@`. The trailing boundary is what keeps /// `@ann` from firing on `@anna`. private static let _pattern = "(? AttributedString { var result = AttributedString(text) guard let regex = _regex else { return result } let range = NSRange(text.startIndex.. String? { guard let regex = try? NSRegularExpression( pattern: "(?:^|[^A-Za-z0-9_@])@([A-Za-z0-9_]{0,32})$" ), let match = regex.firstMatch( in: text, range: NSRange(text.startIndex.. [String] { let prefix = draft.lowercased() var seen = Set() return names.filter { name in let key = name.lowercased() guard !seen.contains(key) else { return false } guard name.range(of: "^[A-Za-z0-9_]{2,32}$", options: .regularExpression) != nil else { return false } guard prefix.isEmpty || key.hasPrefix(prefix) else { return false } seen.insert(key) return true } } /// Replace the half-typed handle with the chosen one, and leave a space so /// the next word is not swallowed into it. static func complete(_ text: String, with handle: String) -> String { text.replacingOccurrences( of: "@[A-Za-z0-9_]{0,32}$", with: "@\(handle) ", options: .regularExpression ) } }