The last place still calling the operator app a console. The Xcode project uses a synchronized root group pointed at FTStoreAdmin itself, so it never named the subfolder and the rename is a git mv and nothing else. App/ rather than Screens/, because the folder holds the transport, the session and the wire types as well as the views, and because the distinction that actually matters here is against Support/: this app's own code, versus helpers inlined from elsewhere with a note saying where they came from. Both AGENTS.md files follow, including the note recording that the history runs back through manager/FTStore/Admin/ — it now says the middle stretch was Console/, since that is the part a git log --follow has to cross.
103 lines
3.8 KiB
Swift
103 lines
3.8 KiB
Swift
//
|
|
// 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 = "(?<![A-Za-z0-9_@])@([A-Za-z0-9_]{2,32})(?![A-Za-z0-9_])"
|
|
|
|
private static let _regex = try? NSRegularExpression(pattern: _pattern)
|
|
|
|
/// The body with every mention tinted. Falls back to the plain string if the
|
|
/// expression could not be built, so a body always renders.
|
|
static func attributed(_ text: String, tint: Color) -> AttributedString {
|
|
var result = AttributedString(text)
|
|
guard let regex = _regex else { return result }
|
|
|
|
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
for match in regex.matches(in: text, range: range) {
|
|
guard
|
|
let matched = Range(match.range, in: text),
|
|
let lower = AttributedString.Index(matched.lowerBound, within: result),
|
|
let upper = AttributedString.Index(matched.upperBound, within: result)
|
|
else { continue }
|
|
|
|
result[lower..<upper].foregroundColor = tint
|
|
result[lower..<upper].font = .subheadline.weight(.semibold)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: Completing
|
|
|
|
/// The half-typed handle under the cursor, if the caret is inside one.
|
|
///
|
|
/// Zero characters counts: an `@` on its own should offer the thread rather
|
|
/// than wait for a letter that narrows it.
|
|
static func draftHandle(in text: String) -> 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..<text.endIndex, in: text)
|
|
),
|
|
let range = Range(match.range(at: 1), in: text)
|
|
else { return nil }
|
|
|
|
return String(text[range])
|
|
}
|
|
|
|
/// The handles worth offering for what has been typed so far, deduplicated
|
|
/// and in the order they were given.
|
|
static func suggestions(for draft: String, among names: [String]) -> [String] {
|
|
let prefix = draft.lowercased()
|
|
var seen = Set<String>()
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|