1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import SwiftUI
struct MentionSuggestion: Identifiable, Equatable {
let handle: String
let displayName: String?
let avatar: String?
var id: String {
handle
}
}
/// Detects @mention queries in text and provides autocomplete suggestions.
@Observable
@MainActor
final class MentionAutocompleteState {
var suggestions: [MentionSuggestion] = []
var isActive: Bool {
activeQuery != nil
}
private(set) var activeQuery: String?
private var searchTask: Task<Void, Never>?
/// Call on every text change to detect @mention patterns.
func update(text: String) {
guard let query = extractMentionQuery(from: text) else {
clear()
return
}
activeQuery = query
searchTask?.cancel()
let pendingQuery = query
searchTask = Task {
try? await Task.sleep(for: .milliseconds(200))
guard !Task.isCancelled else { return }
await search(query: pendingQuery)
}
}
func clear() {
activeQuery = nil
suggestions = []
searchTask?.cancel()
}
/// Replace the @query in the text with the selected handle.
func complete(handle: String, in text: inout String) {
guard let query = activeQuery else { return }
// Find the last @query and replace it
let suffix = "@\(query)"
if let range = text.range(of: suffix, options: .backwards) {
text.replaceSubrange(range, with: "@\(handle) ")
}
clear()
}
private func extractMentionQuery(from text: String) -> String? {
// Find the last @ that's either at the start or preceded by whitespace
guard let atIndex = text.lastIndex(of: "@") else { return nil }
let beforeAt = text[text.startIndex ..< atIndex]
if !beforeAt.isEmpty, !beforeAt.last!.isWhitespace {
return nil
}
let after = String(text[text.index(after: atIndex)...])
// Must not contain spaces (still typing the handle)
guard !after.contains(" ") else { return nil }
// Need at least 1 character after @
guard !after.isEmpty else { return nil }
return after
}
private func search(query: String) async {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard trimmed.count >= 1 else {
suggestions = []
return
}
var components = URLComponents(url: AuthManager.serverURL.appendingPathComponent("xrpc/social.grain.actor.searchActorsTypeahead"), resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "q", value: trimmed),
URLQueryItem(name: "limit", value: "5"),
]
guard let url = components.url else { return }
guard let (data, _) = try? await NetworkEnvironment.session.data(from: url),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let actors = json["actors"] as? [[String: Any]]
else {
return
}
guard !Task.isCancelled else { return }
suggestions = actors.compactMap { dict in
guard let handle = dict["handle"] as? String else { return nil }
return MentionSuggestion(
handle: handle,
displayName: dict["displayName"] as? String,
avatar: dict["avatar"] as? String
)
}
}
}
/// Compact horizontal suggestion strip that appears above the keyboard.
struct MentionSuggestionOverlay: View {
let state: MentionAutocompleteState
let onSelect: (MentionSuggestion) -> Void
var body: some View {
if state.isActive, !state.suggestions.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
GlassEffectContainer(spacing: 8) {
HStack(spacing: 8) {
ForEach(state.suggestions) { suggestion in
Button {
onSelect(suggestion)
} label: {
HStack(spacing: 6) {
AvatarView(url: suggestion.avatar, size: 24)
VStack(alignment: .leading, spacing: 0) {
Text(suggestion.displayName ?? suggestion.handle)
.font(.caption.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text("@\(suggestion.handle)")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.glassEffect(.regular.interactive())
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
}
}
}
#Preview {
let state = MentionAutocompleteState()
state.suggestions = [
MentionSuggestion(handle: "alice.grain.social", displayName: "Alice", avatar: nil),
MentionSuggestion(handle: "bob.grain.social", displayName: "Bob", avatar: nil),
]
return VStack {
Spacer()
MentionSuggestionOverlay(state: state) { _ in }
}
.frame(height: 200)
}