073d3346ef
- MentionAutocompleteField: use \z instead of $ so dropdown closes after Enter - MentionDirectory: handle path-less feed URLs (e.g. social.orgro.org → orgro) via penultimate host segment heuristic - Extract derivedNick logic to public inferredNick(from:) in OrgSocialKit/Util - Add URLNickTests covering 16 real relay URL patterns (vhost, root, path-less, deep GitHub/Codeberg, tilde, custom filename) - Bump build to 1.3 (16)
149 lines
5.7 KiB
Swift
149 lines
5.7 KiB
Swift
import SwiftUI
|
|
|
|
/// TextEditor wrapped with a `@`-triggered autocomplete dropdown.
|
|
///
|
|
/// Detection heuristic: after every keystroke we scan the text for an
|
|
/// `@[\w.-]*` token that reaches the very end of the string. If one is
|
|
/// found, the dropdown opens filtered by the characters after the `@`.
|
|
/// SwiftUI's TextEditor does not expose the caret, so mid-text insertion
|
|
/// won't trigger the picker — users need to type the mention at the
|
|
/// current end of the body. This matches how most mobile clients work
|
|
/// in practice and avoids a UIKit bridge.
|
|
///
|
|
/// On selection the `@query` token is replaced with `@nick ` (trailing
|
|
/// space closes the mention), and the caller-owned `mentionMap` gets
|
|
/// `nick -> feedURL` so the rewrite on save can produce
|
|
/// `[[org-social:URL][nick]]`.
|
|
struct MentionAutocompleteField<Focus: Hashable & Sendable>: View {
|
|
@Binding var text: String
|
|
@Binding var mentionMap: [String: String]
|
|
let candidates: [MentionCandidate]
|
|
let placeholder: String
|
|
var minHeight: CGFloat = 140
|
|
|
|
// Callers that want to drive focus from outside (Compose does,
|
|
// Edit no longer auto-focuses) pass a @FocusState binding. When
|
|
// not needed, pass nil.
|
|
var focusBinding: FocusState<Focus>.Binding?
|
|
var focusedValue: Focus?
|
|
|
|
@State private var showPicker = false
|
|
@State private var filterQuery = ""
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ZStack(alignment: .topLeading) {
|
|
editor
|
|
|
|
if text.isEmpty {
|
|
Text(placeholder)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.top, 12)
|
|
.padding(.leading, 8)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
|
|
if showPicker, !filteredCandidates.isEmpty {
|
|
mentionDropdown
|
|
}
|
|
}
|
|
.onChange(of: text) { _, newValue in
|
|
updatePicker(for: newValue)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var editor: some View {
|
|
if let binding = focusBinding, let value = focusedValue {
|
|
TextEditor(text: $text)
|
|
.frame(minHeight: minHeight)
|
|
.focused(binding, equals: value)
|
|
.padding(4)
|
|
} else {
|
|
TextEditor(text: $text)
|
|
.frame(minHeight: minHeight)
|
|
.padding(4)
|
|
}
|
|
}
|
|
|
|
private var filteredCandidates: [MentionCandidate] {
|
|
let q = filterQuery.lowercased()
|
|
let base = q.isEmpty
|
|
? candidates
|
|
: candidates.filter { $0.nick.lowercased().hasPrefix(q) }
|
|
// Cap the visible dropdown so a big follow list stays usable.
|
|
return Array(base.prefix(8))
|
|
}
|
|
|
|
private var mentionDropdown: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(filteredCandidates) { candidate in
|
|
Button {
|
|
insert(candidate)
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
AvatarView(url: candidate.avatarURL, nick: candidate.nick, size: 28)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text("@\(candidate.nick)").font(.subheadline.weight(.medium))
|
|
Text(candidate.feedURL.host ?? candidate.feedURL.absoluteString)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
if candidate.id != filteredCandidates.last?.id {
|
|
Divider().padding(.leading, 50)
|
|
}
|
|
}
|
|
}
|
|
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.secondary.opacity(0.2)))
|
|
.padding(.top, 6)
|
|
.shadow(color: Color.black.opacity(0.08), radius: 4, y: 2)
|
|
}
|
|
|
|
private func updatePicker(for text: String) {
|
|
// Match a trailing @word. `\w` in Swift regex already includes
|
|
// letters / digits / underscore; we widen with `.-` for common
|
|
// nick punctuation. Use \z (absolute end of string) instead of $
|
|
// so a trailing newline after @token does not keep the picker open.
|
|
let pattern = #"@([A-Za-z0-9_.-]*)\z"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else {
|
|
showPicker = false
|
|
return
|
|
}
|
|
let nsrange = NSRange(text.startIndex..., in: text)
|
|
if let match = regex.firstMatch(in: text, range: nsrange),
|
|
let qr = Range(match.range(at: 1), in: text) {
|
|
filterQuery = String(text[qr])
|
|
showPicker = true
|
|
} else {
|
|
filterQuery = ""
|
|
showPicker = false
|
|
}
|
|
}
|
|
|
|
private func insert(_ candidate: MentionCandidate) {
|
|
let pattern = #"@[A-Za-z0-9_.-]*\z"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return }
|
|
let nsrange = NSRange(text.startIndex..., in: text)
|
|
let replacement = "@\(candidate.nick) "
|
|
if let match = regex.firstMatch(in: text, range: nsrange),
|
|
let fr = Range(match.range, in: text) {
|
|
text.replaceSubrange(fr, with: replacement)
|
|
} else {
|
|
text.append(replacement)
|
|
}
|
|
mentionMap[candidate.nick] = candidate.feedURL.absoluteString
|
|
showPicker = false
|
|
filterQuery = ""
|
|
}
|
|
}
|