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