Files
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
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.
2026-08-10 21:16:57 +02:00

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
)
}
}