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.
148 lines
5.6 KiB
Swift
148 lines
5.6 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.
|
|
let pattern = #"@([A-Za-z0-9_.-]*)$"#
|
|
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_.-]*$"#
|
|
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 = ""
|
|
}
|
|
}
|