iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: @mention autocomplete in descriptions, comments, and bio

Type @ followed by a name to see liquid glass suggestion pills above
the keyboard. Works in gallery description, comment/reply sheet, and
profile bio editor. Uses the same typeahead endpoint as login.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+158
+137
Grain/Views/Components/MentionAutocomplete.swift
··· 1 + import SwiftUI 2 + 3 + struct MentionSuggestion: Identifiable, Equatable { 4 + let handle: String 5 + let displayName: String? 6 + let avatar: String? 7 + var id: String { handle } 8 + } 9 + 10 + /// Detects @mention queries in text and provides autocomplete suggestions. 11 + @Observable 12 + @MainActor 13 + final class MentionAutocompleteState { 14 + var suggestions: [MentionSuggestion] = [] 15 + var isActive: Bool { activeQuery != nil } 16 + private(set) var activeQuery: String? 17 + private var searchTask: Task<Void, Never>? 18 + 19 + /// Call on every text change to detect @mention patterns. 20 + func update(text: String) { 21 + guard let query = extractMentionQuery(from: text) else { 22 + clear() 23 + return 24 + } 25 + activeQuery = query 26 + searchTask?.cancel() 27 + let q = query 28 + searchTask = Task { 29 + try? await Task.sleep(for: .milliseconds(200)) 30 + guard !Task.isCancelled else { return } 31 + await search(query: q) 32 + } 33 + } 34 + 35 + func clear() { 36 + activeQuery = nil 37 + suggestions = [] 38 + searchTask?.cancel() 39 + } 40 + 41 + /// Replace the @query in the text with the selected handle. 42 + func complete(handle: String, in text: inout String) { 43 + guard let query = activeQuery else { return } 44 + // Find the last @query and replace it 45 + let suffix = "@\(query)" 46 + if let range = text.range(of: suffix, options: .backwards) { 47 + text.replaceSubrange(range, with: "@\(handle) ") 48 + } 49 + clear() 50 + } 51 + 52 + private func extractMentionQuery(from text: String) -> String? { 53 + // Find the last @ that's either at the start or preceded by whitespace 54 + guard let atIndex = text.lastIndex(of: "@") else { return nil } 55 + let beforeAt = text[text.startIndex..<atIndex] 56 + if !beforeAt.isEmpty && !beforeAt.last!.isWhitespace { return nil } 57 + let after = String(text[text.index(after: atIndex)...]) 58 + // Must not contain spaces (still typing the handle) 59 + guard !after.contains(" ") else { return nil } 60 + // Need at least 1 character after @ 61 + guard !after.isEmpty else { return nil } 62 + return after 63 + } 64 + 65 + private func search(query: String) async { 66 + let trimmed = query.trimmingCharacters(in: .whitespaces) 67 + guard trimmed.count >= 1 else { 68 + suggestions = [] 69 + return 70 + } 71 + 72 + var components = URLComponents(url: AuthManager.serverURL.appendingPathComponent("xrpc/social.grain.unspecced.searchActorsTypeahead"), resolvingAgainstBaseURL: false)! 73 + components.queryItems = [ 74 + URLQueryItem(name: "q", value: trimmed), 75 + URLQueryItem(name: "limit", value: "5"), 76 + ] 77 + guard let url = components.url else { return } 78 + 79 + guard let (data, _) = try? await URLSession.shared.data(from: url), 80 + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], 81 + let actors = json["actors"] as? [[String: Any]] else { 82 + return 83 + } 84 + 85 + guard !Task.isCancelled else { return } 86 + suggestions = actors.compactMap { dict in 87 + guard let handle = dict["handle"] as? String else { return nil } 88 + return MentionSuggestion( 89 + handle: handle, 90 + displayName: dict["displayName"] as? String, 91 + avatar: dict["avatar"] as? String 92 + ) 93 + } 94 + } 95 + } 96 + 97 + /// Compact horizontal suggestion strip that appears above the keyboard. 98 + struct MentionSuggestionOverlay: View { 99 + let state: MentionAutocompleteState 100 + let onSelect: (MentionSuggestion) -> Void 101 + 102 + var body: some View { 103 + if state.isActive && !state.suggestions.isEmpty { 104 + ScrollView(.horizontal, showsIndicators: false) { 105 + GlassEffectContainer(spacing: 8) { 106 + HStack(spacing: 8) { 107 + ForEach(state.suggestions) { suggestion in 108 + Button { 109 + onSelect(suggestion) 110 + } label: { 111 + HStack(spacing: 6) { 112 + AvatarView(url: suggestion.avatar, size: 24) 113 + VStack(alignment: .leading, spacing: 0) { 114 + Text(suggestion.displayName ?? suggestion.handle) 115 + .font(.caption.weight(.medium)) 116 + .foregroundStyle(.primary) 117 + .lineLimit(1) 118 + Text("@\(suggestion.handle)") 119 + .font(.caption2) 120 + .foregroundStyle(.secondary) 121 + .lineLimit(1) 122 + } 123 + } 124 + .padding(.horizontal, 10) 125 + .padding(.vertical, 6) 126 + .glassEffect(.regular.interactive()) 127 + } 128 + .buttonStyle(.plain) 129 + } 130 + } 131 + } 132 + .padding(.horizontal, 12) 133 + .padding(.vertical, 6) 134 + } 135 + } 136 + } 137 + }
+7
Grain/Views/Create/CreateGalleryView.swift
··· 21 21 @State private var locationSearchTask: Task<Void, Never>? 22 22 @State private var showCamera = false 23 23 @State private var photoItems: [PhotoItem] = [] 24 + @State private var mentionState = MentionAutocompleteState() 24 25 25 26 let client: XRPCClient 26 27 var onCreated: (() -> Void)? ··· 82 83 VStack(alignment: .leading, spacing: 4) { 83 84 TextField("Add a description. Supports @mentions, #hashtags, and links.", text: $description, axis: .vertical) 84 85 .lineLimit(3...6) 86 + .onChange(of: description) { mentionState.update(text: description) } 85 87 Text("\(description.count)/\(maxDescription)") 86 88 .font(.caption2) 87 89 .foregroundStyle(description.count > maxDescription ? .red : .secondary) ··· 150 152 .foregroundStyle(.red) 151 153 .font(.caption) 152 154 } 155 + } 156 + } 157 + .safeAreaInset(edge: .bottom) { 158 + MentionSuggestionOverlay(state: mentionState) { suggestion in 159 + mentionState.complete(handle: suggestion.handle, in: &description) 153 160 } 154 161 } 155 162 .onChange(of: selectedPhotos) {
+7
Grain/Views/Gallery/GalleryDetailView.swift
··· 15 15 @State private var showCommentSheet = false 16 16 @State private var zoomState = ImageZoomState() 17 17 @State private var cardStoryAuthor: GrainStoryAuthor? 18 + @State private var mentionState = MentionAutocompleteState() 18 19 @FocusState private var commentFocused: Bool 19 20 @Environment(\.dismiss) private var dismiss 20 21 ··· 207 208 .focused($commentFocused) 208 209 .padding() 209 210 .lineLimit(5...10) 211 + .onChange(of: commentText) { mentionState.update(text: commentText) } 210 212 211 213 Spacer() 214 + } 215 + .safeAreaInset(edge: .bottom) { 216 + MentionSuggestionOverlay(state: mentionState) { suggestion in 217 + mentionState.complete(handle: suggestion.handle, in: &commentText) 218 + } 212 219 } 213 220 .navigationTitle(replyingTo != nil ? "Reply" : "Comment") 214 221 .navigationBarTitleDisplayMode(.inline)
+7
Grain/Views/Settings/EditProfileView.swift
··· 19 19 @State private var isLoading = true 20 20 @State private var isSaving = false 21 21 @State private var errorMessage: String? 22 + @State private var mentionState = MentionAutocompleteState() 22 23 23 24 private let maxDisplayName = 64 24 25 private let maxBio = 256 ··· 93 94 .foregroundStyle(.secondary) 94 95 TextEditor(text: $bio) 95 96 .frame(minHeight: 100) 97 + .onChange(of: bio) { mentionState.update(text: bio) } 96 98 Text("\(bio.count)/\(maxBio)") 97 99 .font(.caption2) 98 100 .foregroundStyle(bio.count > maxBio ? .red : .secondary) ··· 106 108 .foregroundStyle(.red) 107 109 .font(.caption) 108 110 } 111 + } 112 + } 113 + .safeAreaInset(edge: .bottom) { 114 + MentionSuggestionOverlay(state: mentionState) { suggestion in 115 + mentionState.complete(handle: suggestion.handle, in: &bio) 109 116 } 110 117 } 111 118 .navigationTitle("Edit Profile")