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: recent searches with profile avatars and text history

Persist recent profile taps and text searches to UserDefaults.
Shows recent profile avatars in horizontal scroll and text queries
below when search is empty. Case-insensitive dedup for text searches.

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

+162 -1
+82
Grain/Utilities/RecentSearchStorage.swift
··· 1 + import Foundation 2 + 3 + struct RecentProfileSearch: Codable, Identifiable, Equatable { 4 + let did: String 5 + var displayName: String? 6 + var handle: String? 7 + var avatar: String? 8 + var id: String { did } 9 + } 10 + 11 + struct RecentTextSearch: Codable, Identifiable, Equatable { 12 + let query: String 13 + var id: String { query } 14 + } 15 + 16 + @Observable 17 + @MainActor 18 + final class RecentSearchStorage { 19 + var profiles: [RecentProfileSearch] = [] 20 + var textSearches: [RecentTextSearch] = [] 21 + 22 + private static let profilesKey = "recentSearchProfiles" 23 + private static let textKey = "recentSearchText" 24 + private static let maxProfiles = 10 25 + private static let maxText = 10 26 + 27 + init() { 28 + load() 29 + } 30 + 31 + func addProfile(did: String, displayName: String?, handle: String?, avatar: String?) { 32 + profiles.removeAll { $0.did == did } 33 + profiles.insert(RecentProfileSearch(did: did, displayName: displayName, handle: handle, avatar: avatar), at: 0) 34 + if profiles.count > Self.maxProfiles { profiles = Array(profiles.prefix(Self.maxProfiles)) } 35 + save() 36 + } 37 + 38 + func addTextSearch(_ query: String) { 39 + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) 40 + guard !trimmed.isEmpty else { return } 41 + textSearches.removeAll { $0.query.lowercased() == trimmed.lowercased() } 42 + textSearches.insert(RecentTextSearch(query: trimmed), at: 0) 43 + if textSearches.count > Self.maxText { textSearches = Array(textSearches.prefix(Self.maxText)) } 44 + save() 45 + } 46 + 47 + func removeProfile(_ did: String) { 48 + profiles.removeAll { $0.did == did } 49 + save() 50 + } 51 + 52 + func removeTextSearch(_ query: String) { 53 + textSearches.removeAll { $0.query == query } 54 + save() 55 + } 56 + 57 + func clearAll() { 58 + profiles = [] 59 + textSearches = [] 60 + save() 61 + } 62 + 63 + private func load() { 64 + if let data = UserDefaults.standard.data(forKey: Self.profilesKey), 65 + let decoded = try? JSONDecoder().decode([RecentProfileSearch].self, from: data) { 66 + profiles = decoded 67 + } 68 + if let data = UserDefaults.standard.data(forKey: Self.textKey), 69 + let decoded = try? JSONDecoder().decode([RecentTextSearch].self, from: data) { 70 + textSearches = decoded 71 + } 72 + } 73 + 74 + private func save() { 75 + if let data = try? JSONEncoder().encode(profiles) { 76 + UserDefaults.standard.set(data, forKey: Self.profilesKey) 77 + } 78 + if let data = try? JSONEncoder().encode(textSearches) { 79 + UserDefaults.standard.set(data, forKey: Self.textKey) 80 + } 81 + } 82 + }
+80 -1
Grain/Views/Search/SearchView.swift
··· 11 11 @State private var selectedLocation: LocationDestination? 12 12 @State private var zoomState = ImageZoomState() 13 13 @State private var cardStoryAuthor: GrainStoryAuthor? 14 + @State private var recentSearches = RecentSearchStorage() 14 15 let client: XRPCClient 15 16 16 17 init(client: XRPCClient) { ··· 22 23 NavigationStack { 23 24 Group { 24 25 if viewModel.searchText.isEmpty { 25 - ContentUnavailableView("Search", systemImage: "magnifyingglass", description: Text("Search for galleries and profiles")) 26 + if recentSearches.profiles.isEmpty && recentSearches.textSearches.isEmpty { 27 + ContentUnavailableView("Search", systemImage: "magnifyingglass", description: Text("Search for galleries and profiles")) 28 + } else { 29 + recentSearchesView 30 + } 26 31 } else { 27 32 ScrollView { 28 33 LazyVStack(spacing: 12) { ··· 44 49 case .profiles: 45 50 ForEach(viewModel.profileResults) { profile in 46 51 Button { 52 + recentSearches.addProfile(did: profile.did, displayName: profile.displayName, handle: profile.handle, avatar: profile.avatar) 47 53 selectedProfileDid = profile.did 48 54 } label: { 49 55 HStack { ··· 81 87 } 82 88 } 83 89 .onSubmit(of: .search) { 90 + recentSearches.addTextSearch(searchText) 84 91 Task { await viewModel.search(auth: auth.authContext()) } 85 92 } 86 93 .onChange(of: searchText) { ··· 115 122 ) 116 123 .environment(auth) 117 124 } 125 + } 126 + } 127 + 128 + @ViewBuilder 129 + private var recentSearchesView: some View { 130 + ScrollView { 131 + VStack(alignment: .leading, spacing: 16) { 132 + if !recentSearches.profiles.isEmpty { 133 + Text("Recent searches") 134 + .font(.subheadline.weight(.semibold)) 135 + .padding(.horizontal) 136 + 137 + ScrollView(.horizontal, showsIndicators: false) { 138 + HStack(spacing: 16) { 139 + ForEach(recentSearches.profiles) { profile in 140 + VStack(spacing: 6) { 141 + AvatarView(url: profile.avatar, size: 76) 142 + .padding(4) 143 + .overlay(alignment: .topTrailing) { 144 + Button { 145 + recentSearches.removeProfile(profile.did) 146 + } label: { 147 + Image(systemName: "xmark") 148 + .font(.system(size: 8, weight: .bold)) 149 + .foregroundStyle(.primary) 150 + .frame(width: 18, height: 18) 151 + .background(.thickMaterial, in: .circle) 152 + } 153 + } 154 + 155 + Text(profile.displayName ?? profile.handle ?? "") 156 + .font(.caption) 157 + .lineLimit(1) 158 + .frame(width: 72) 159 + } 160 + .onTapGesture { 161 + selectedProfileDid = profile.did 162 + } 163 + } 164 + } 165 + .padding(.horizontal) 166 + } 167 + } 168 + 169 + if !recentSearches.textSearches.isEmpty { 170 + ForEach(recentSearches.textSearches) { recent in 171 + HStack { 172 + Image(systemName: "magnifyingglass") 173 + .foregroundStyle(.secondary) 174 + .font(.subheadline) 175 + Text(recent.query) 176 + .font(.subheadline) 177 + Spacer() 178 + Button { 179 + recentSearches.removeTextSearch(recent.query) 180 + } label: { 181 + Image(systemName: "xmark") 182 + .font(.caption2.weight(.bold)) 183 + .foregroundStyle(.primary) 184 + } 185 + } 186 + .padding(.horizontal) 187 + .contentShape(Rectangle()) 188 + .onTapGesture { 189 + searchText = recent.query 190 + viewModel.searchText = recent.query 191 + Task { await viewModel.search(auth: auth.authContext()) } 192 + } 193 + } 194 + } 195 + } 196 + .padding(.top) 118 197 } 119 198 } 120 199 }