Files
org-social-ios/App/Views/Common/MentionAutocompleteField.swift
andros cd0f055ffb Mention autocomplete in Compose and Edit
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.
2026-04-24 10:46:26 +02:00

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 = ""
}
}