diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index fbcfbead2..98d09e07a 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -184,6 +184,9 @@ 6D8F08FF2A4029AE003EB4FD /* Community List View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D8F08FE2A4029AE003EB4FD /* Community List View.swift */; }; 6D91D4552A415994006B8F9A /* CommunityListSidebarEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D91D4542A415994006B8F9A /* CommunityListSidebarEntry.swift */; }; 6D91D4582A4159D8006B8F9A /* CommunityListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D91D4572A4159D8006B8F9A /* CommunityListRowViews.swift */; }; + 6DA61F812A55B83F001EA633 /* Search View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA61F802A55B83F001EA633 /* Search View.swift */; }; + 6DA61F852A568F99001EA633 /* Int.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA61F842A568F99001EA633 /* Int.swift */; }; + 6DA61F872A5720EA001EA633 /* RecentSearchesTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA61F862A5720EA001EA633 /* RecentSearchesTracker.swift */; }; 6DA61F892A575DF1001EA633 /* URL - Lemmy Image Parameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA61F882A575DF1001EA633 /* URL - Lemmy Image Parameters.swift */; }; 6DA7E9A22A50764E0095AB68 /* UserViewTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA7E9A12A50764E0095AB68 /* UserViewTab.swift */; }; 6DCE71292A53C26600CFEB5E /* ServerInstanceLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */; }; @@ -473,6 +476,9 @@ 6D8F08FE2A4029AE003EB4FD /* Community List View.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Community List View.swift"; sourceTree = ""; }; 6D91D4542A415994006B8F9A /* CommunityListSidebarEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListSidebarEntry.swift; sourceTree = ""; }; 6D91D4572A4159D8006B8F9A /* CommunityListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityListRowViews.swift; sourceTree = ""; }; + 6DA61F802A55B83F001EA633 /* Search View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Search View.swift"; sourceTree = ""; }; + 6DA61F842A568F99001EA633 /* Int.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Int.swift; sourceTree = ""; }; + 6DA61F862A5720EA001EA633 /* RecentSearchesTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearchesTracker.swift; sourceTree = ""; }; 6DA61F882A575DF1001EA633 /* URL - Lemmy Image Parameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL - Lemmy Image Parameters.swift"; sourceTree = ""; }; 6DA7E9A12A50764E0095AB68 /* UserViewTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserViewTab.swift; sourceTree = ""; }; 6DCE71282A53C26600CFEB5E /* ServerInstanceLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInstanceLocation.swift; sourceTree = ""; }; @@ -762,6 +768,7 @@ B1A26FE02A44AAB200B91A32 /* Navigation getter.swift */, B1A26FE22A45B11800B91A32 /* View - Handle Lemmy Links.swift */, B1CB6E742A4C729D00DA9675 /* Bundle - Current App Icon.swift */, + 6DA61F842A568F99001EA633 /* Int.swift */, 6DA61F882A575DF1001EA633 /* URL - Lemmy Image Parameters.swift */, ); path = Extensions; @@ -944,6 +951,7 @@ 6363D5F427EE1BAE00E34822 /* Tabs */ = { isa = PBXGroup; children = ( + 6DA61F7F2A55B831001EA633 /* Search */, 6DE1183A2A4A215F00810C7E /* Profile */, 6DFF50412A48DEC0001E648D /* Inbox */, 6363D5F627EE1BBC00E34822 /* Settings */, @@ -1194,6 +1202,7 @@ 63F0C7A72A0522FC00A18C5D /* Saved Account Tracker.swift */, 631711562A27C70E00FE08C4 /* Comment Reply Tracker.swift */, 63AF452C2A2F37EB00E0266F /* Own Account Tracker.swift */, + 6DA61F862A5720EA001EA633 /* RecentSearchesTracker.swift */, ); path = Trackers; sourceTree = ""; @@ -1295,6 +1304,14 @@ path = Components; sourceTree = ""; }; + 6DA61F7F2A55B831001EA633 /* Search */ = { + isa = PBXGroup; + children = ( + 6DA61F802A55B83F001EA633 /* Search View.swift */, + ); + path = Search; + sourceTree = ""; + }; 6DA7E9A02A50763B0095AB68 /* User */ = { isa = PBXGroup; children = ( @@ -1790,6 +1807,7 @@ 637218692A3A2AAD008C4816 /* CreateCommentLike.swift in Sources */, B157E0C42A507B8000B02C8B /* Window.swift in Sources */, 6372185A2A3A2AAD008C4816 /* APISubscribedStatus.swift in Sources */, + 6DA61F872A5720EA001EA633 /* RecentSearchesTracker.swift in Sources */, CD3FBCD32A4A4B8B00B2063F /* Messages Tracker.swift in Sources */, 637218762A3A2AAD008C4816 /* BlockCommunity.swift in Sources */, CD69F5752A42479A0028D4F7 /* Comment Item Logic.swift in Sources */, @@ -1874,6 +1892,7 @@ 6D479FD32A4F1F2D00125C2F /* Block Community Button.swift in Sources */, CD04D5E72A3636FB008EF95B /* Compact Post.swift in Sources */, 6386E0342A042C44006B3C1D /* Contributors View.swift in Sources */, + 6DA61F852A568F99001EA633 /* Int.swift in Sources */, 6363D5C527EE196700E34822 /* MlemApp.swift in Sources */, 637218542A3A2AAD008C4816 /* APILanguage.swift in Sources */, 6372186E2A3A2AAD008C4816 /* DeleteComment.swift in Sources */, @@ -1890,6 +1909,7 @@ 63F0C7B92A0533C700A18C5D /* Add Account View.swift in Sources */, 63E5D3922A13CF2300EC1FBD /* Favorite Community Tracker.swift in Sources */, B1B78D642A51D53900F72485 /* AppDelegate.swift in Sources */, + 6DA61F812A55B83F001EA633 /* Search View.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mlem/API/Models/Community/APICommunityView.swift b/Mlem/API/Models/Community/APICommunityView.swift index 4ead56f91..ba6680078 100644 --- a/Mlem/API/Models/Community/APICommunityView.swift +++ b/Mlem/API/Models/Community/APICommunityView.swift @@ -15,7 +15,11 @@ struct APICommunityView: Decodable { let counts: APICommunityAggregates } -extension APICommunityView: Hashable, Equatable { +extension APICommunityView: Hashable, Equatable, Identifiable { + var id: Int { + return self.community.id + } + static func == (lhs: APICommunityView, rhs: APICommunityView) -> Bool { return lhs.community.id == rhs.community.id } diff --git a/Mlem/App Constants.swift b/Mlem/App Constants.swift index 8085ec1f2..66e89f307 100644 --- a/Mlem/App Constants.swift +++ b/Mlem/App Constants.swift @@ -47,6 +47,10 @@ struct AppConstants { static let favoriteCommunitiesFilePath = { applicationSupportDirectoryPath .appendingPathComponent("Favorite Communities", conformingTo: .json) }() + + static let recentSearchesFilePath = { applicationSupportDirectoryPath + .appendingPathComponent("Recent Searches", conformingTo: .json) + }() // MARK: - Haptics static let hapticManager: UINotificationFeedbackGenerator = UINotificationFeedbackGenerator() diff --git a/Mlem/ContentView.swift b/Mlem/ContentView.swift index df8858ffe..9d6b6ee38 100644 --- a/Mlem/ContentView.swift +++ b/Mlem/ContentView.swift @@ -38,13 +38,19 @@ struct ContentView: View { Label(computeUsername(account: currentActiveAccount), systemImage: "person.circle") .environment(\.symbolVariants, tabSelection == 3 ? .fill : .none) }.tag(3) + + NavigationView { + SearchView(account: currentActiveAccount) + } .tabItem { + Label("Search", systemImage: tabSelection == 4 ? "text.magnifyingglass" : "magnifyingglass") + }.tag(4) } SettingsView() .tabItem { Label("Settings", systemImage: "gear") .environment(\.symbolVariants, tabSelection == 4 ? .fill : .none) - }.tag(4) + }.tag(5) } .onAppear { if appState.currentActiveAccount == nil, diff --git a/Mlem/Extensions/Int.swift b/Mlem/Extensions/Int.swift new file mode 100644 index 000000000..a7e792614 --- /dev/null +++ b/Mlem/Extensions/Int.swift @@ -0,0 +1,23 @@ +// +// Int.swift +// Mlem +// +// Created by Jake Shirley on 7/5/23. +// + +import Foundation + +extension Int { + var roundedWithAbbreviations: String { + let number = Double(self) + let thousand = number / 1000 + let million = number / 1000000 + if million >= 1.0 { + return "\(round(million*10)/10)m" + } else if thousand >= 1.0 { + return "\(round(thousand*10)/10)k" + } else { + return self.description + } + } +} diff --git a/Mlem/Logic/File Management/Decode Data from File.swift b/Mlem/Logic/File Management/Decode Data from File.swift index 478463c0a..600aea1d4 100644 --- a/Mlem/Logic/File Management/Decode Data from File.swift +++ b/Mlem/Logic/File Management/Decode Data from File.swift @@ -12,7 +12,7 @@ internal enum DecodingError: Error { } internal enum WhatToDecode { - case accounts, filteredKeywords, favoriteCommunities + case accounts, filteredKeywords, favoriteCommunities, recentSearches } func decodeFromFile(fromURL: URL, whatToDecode: WhatToDecode) throws -> any Codable { @@ -27,6 +27,8 @@ func decodeFromFile(fromURL: URL, whatToDecode: WhatToDecode) throws -> any Coda return try JSONDecoder().decode([String].self, from: rawData) case .favoriteCommunities: return try JSONDecoder().decode([FavoriteCommunity].self, from: rawData) + case .recentSearches: + return try JSONDecoder().decode([String].self, from: rawData) } } catch let decodingError { print("Failed to decode loaded data: \(decodingError.localizedDescription)") diff --git a/Mlem/Models/Trackers/Community Search Result Tracker.swift b/Mlem/Models/Trackers/Community Search Result Tracker.swift index 4f020946c..27ee28f1c 100644 --- a/Mlem/Models/Trackers/Community Search Result Tracker.swift +++ b/Mlem/Models/Trackers/Community Search Result Tracker.swift @@ -8,6 +8,6 @@ import Foundation class CommunitySearchResultsTracker: ObservableObject { - @Published var foundCommunities: [APICommunity] = .init() + @Published var foundCommunities: [APICommunityView] = .init() @Published var isLoading: Bool = false } diff --git a/Mlem/Models/Trackers/RecentSearchesTracker.swift b/Mlem/Models/Trackers/RecentSearchesTracker.swift new file mode 100644 index 000000000..122000169 --- /dev/null +++ b/Mlem/Models/Trackers/RecentSearchesTracker.swift @@ -0,0 +1,77 @@ +// +// RecentSearchesTracker.swift +// Mlem +// +// Created by Jake Shirley on 7/6/23. +// + +import Foundation + +class RecentSearchesTracker: ObservableObject { + @Published var recentSearches: [String] = .init() + + init() { + loadFromDisk() + } + + func loadFromDisk() { + if FileManager.default.fileExists(atPath: AppConstants.recentSearchesFilePath.path) { + print("Favorite communities file exists, will attempt to load favorite communities") + do { + recentSearches = try decodeFromFile( + fromURL: AppConstants.recentSearchesFilePath, + whatToDecode: .recentSearches + ) as? [String] ?? [] + } catch let decodingError { + print("Failed while decoding recent searches, erasing file: \(decodingError)") + } + } else { + print("Recent searches file does not exist, will try to create it") + + do { + try createEmptyFile(at: AppConstants.recentSearchesFilePath) + } catch let emptyFileCreationError { + print("Failed while creating empty file: \(emptyFileCreationError)") + } + } + } + + // Lazy save in the background + func saveToDisk() { + Task(priority: .background) { [recentSearches] in + do { + let encodedSearches: Data = try encodeForSaving(object: recentSearches) + + do { + try writeDataToFile(data: encodedSearches, fileURL: AppConstants.recentSearchesFilePath) + } catch let writingError { + print("Failed while saving data to file: \(writingError)") + clearRecentSearches() + } + } catch let encodingError { + print("Failed while encoding recent searches to data: \(encodingError)") + } + } + } + + func addRecentSearch(_ searchText: String) { + // don't insert duplicates + guard !recentSearches.contains(searchText) else { + return + } + + recentSearches.insert(searchText, at: 0) + + // Limit results to 5 + while recentSearches.count > 5 { + recentSearches.remove(at: 5) + } + + saveToDisk() + } + + func clearRecentSearches() { + recentSearches = [] + saveToDisk() + } +} diff --git a/Mlem/Views/Shared/Links/Community Link View.swift b/Mlem/Views/Shared/Links/Community Link View.swift index 95fa536fe..6871ece2d 100644 --- a/Mlem/Views/Shared/Links/Community Link View.swift +++ b/Mlem/Views/Shared/Links/Community Link View.swift @@ -30,22 +30,32 @@ func shouldClipAvatar(url: URL?) -> Bool { struct CommunityLinkView: View { let community: APICommunity let serverInstanceLocation: ServerInstanceLocation + let extraText: String? let overrideShowAvatar: Bool? // if present, shows or hides avatar according to value; otherwise uses system setting init(community: APICommunity, serverInstanceLocation: ServerInstanceLocation = .bottom, - overrideShowAvatar: Bool? = nil) { + overrideShowAvatar: Bool? = nil, + extraText: String? = nil + ) { self.community = community self.serverInstanceLocation = serverInstanceLocation + self.extraText = extraText self.overrideShowAvatar = overrideShowAvatar } var body: some View { NavigationLink(value: community) { - CommunityLabel(community: community, - serverInstanceLocation: serverInstanceLocation, - overrideShowAvatar: overrideShowAvatar - ) + HStack { + CommunityLabel(community: community, + serverInstanceLocation: serverInstanceLocation, + overrideShowAvatar: overrideShowAvatar + ) + Spacer() + if let text = extraText { + Text(text) + } + } } } } diff --git a/Mlem/Views/Shared/Loading View.swift b/Mlem/Views/Shared/Loading View.swift index 703980eb8..8c977eeae 100644 --- a/Mlem/Views/Shared/Loading View.swift +++ b/Mlem/Views/Shared/Loading View.swift @@ -9,7 +9,7 @@ import SwiftUI struct LoadingView: View { enum PossibleThingsToLoad { - case posts, image, comments, inbox, replies, mentions, messages, communityDetails + case posts, image, comments, inbox, replies, mentions, messages, communityDetails, search } let whatIsLoading: PossibleThingsToLoad @@ -37,6 +37,8 @@ struct LoadingView: View { Text("Loading messages") case .communityDetails: Text("Loading community details") + case .search: + Text("Searching...") } Spacer() diff --git a/Mlem/Views/Tabs/Posts/Components/Post/Components/Community View/Community Search/Community Search View.swift b/Mlem/Views/Tabs/Posts/Components/Post/Components/Community View/Community Search/Community Search View.swift index 17bfcdb4f..f690faa38 100644 --- a/Mlem/Views/Tabs/Posts/Components/Post/Components/Community View/Community Search/Community Search View.swift +++ b/Mlem/Views/Tabs/Posts/Components/Post/Components/Community View/Community Search/Community Search View.swift @@ -113,8 +113,8 @@ struct CommunitySearchResultsView: View { } else { Section { ForEach(communitySearchResultsTracker.foundCommunities) { foundCommunity in - NavigationLink(value: foundCommunity) { - communityNameView(for: foundCommunity) + NavigationLink(value: foundCommunity.community) { + communityNameView(for: foundCommunity.community) .swipeActions(edge: .trailing, allowsFullSwipe: true) { // This is when a community is already favorited if favoritedCommunitiesTracker.favoriteCommunities @@ -122,7 +122,7 @@ struct CommunitySearchResultsView: View { Button(role: .destructive) { unfavoriteCommunity( account: account, - community: foundCommunity, + community: foundCommunity.community, favoritedCommunitiesTracker: favoritedCommunitiesTracker ) } label: { @@ -132,7 +132,7 @@ struct CommunitySearchResultsView: View { Button { favoriteCommunity( account: account, - community: foundCommunity, + community: foundCommunity.community, favoritedCommunitiesTracker: favoritedCommunitiesTracker ) } label: { diff --git a/Mlem/Views/Tabs/Posts/Components/Post/Components/Community View/Community Search/Components/Community Search Field.swift b/Mlem/Views/Tabs/Posts/Components/Post/Components/Community View/Community Search/Components/Community Search Field.swift index 0786fc143..2899454d7 100644 --- a/Mlem/Views/Tabs/Posts/Components/Post/Components/Community View/Community Search/Components/Community Search Field.swift +++ b/Mlem/Views/Tabs/Posts/Components/Post/Components/Community View/Community Search/Components/Community Search Field.swift @@ -56,8 +56,7 @@ struct CommunitySearchField: View { ) let response = try await APIClient().perform(request: request) - let communities = response.communities.map { $0.community } - communitySearchResultsTracker.foundCommunities = communities + communitySearchResultsTracker.foundCommunities = response.communities } catch { print("Search command error: \(error)") errorAlert = .init( diff --git a/Mlem/Views/Tabs/Search/Search View.swift b/Mlem/Views/Tabs/Search/Search View.swift new file mode 100644 index 000000000..6c53af1e5 --- /dev/null +++ b/Mlem/Views/Tabs/Search/Search View.swift @@ -0,0 +1,184 @@ +// +// Search View.swift +// Mlem +// +// Created by Jake Shirley on 7/5/23. +// + +import Foundation +import SwiftUI + +struct SearchView: View { + // paremters + let account: SavedAccount + + // environment + @EnvironmentObject var communitySearchResultsTracker: CommunitySearchResultsTracker + @EnvironmentObject var recentSearchesTracker: RecentSearchesTracker + + // private state + @State private var isSearching: Bool = false + @State private var lastSearchedText: String = "" + @State private var showRecentSearches: Bool = true + @State private var searchTask: Task<(), Never>? + @State private var searchText: String = "" + + @State private var searchPage: Int = 1 + @State private var hasMorePages: Bool = true + + @State private var navigationPath = NavigationPath() + + // constants + private let searchPageSize = 50 + + var body: some View { + NavigationStack(path: $navigationPath) { + content + .handleLemmyViews() + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Search") + } + .searchable(text: getSearchTextBinding(), prompt: "Search for communities") + .onSubmit(of: .search) { + performSearch() + } + } + + @ViewBuilder + private var content: some View { + if showRecentSearches { + recentSearches + } else { + searchedContents + } + } + + @ViewBuilder + private var recentSearches: some View { + List { + Section { + ForEach(recentSearchesTracker.recentSearches, id: \.self) { recentlySearchedText in + Button(recentlySearchedText) { + searchText = recentlySearchedText + performSearch() + } + } + } header: { + Text(recentSearchesTracker.recentSearches.isEmpty ? "No recent searches" : "Recent searches") + } + + Button(role: .destructive) { + recentSearchesTracker.clearRecentSearches() + } label: { + HStack { + Spacer() + Text("Clear recent searches") + .foregroundColor(.red) + Spacer() + } + } + }.listStyle(.insetGrouped) + } + + @ViewBuilder + private var searchedContents: some View { + if isSearching && communitySearchResultsTracker.foundCommunities.isEmpty { + LoadingView(whatIsLoading: .search) + } else if communitySearchResultsTracker.foundCommunities.isEmpty { + Text("No communities found for search") + } else { + List { + ForEach(communitySearchResultsTracker.foundCommunities) { community in + CommunityLinkView( + community: community.community, + extraText: "\(community.counts.subscribers.roundedWithAbbreviations) subscribers" + ) + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { + let communityIndex = communitySearchResultsTracker.foundCommunities.firstIndex(of: community) + if let index = communityIndex { + + // If we are half a page from the end, ask for more + let distanceFromEnd = communitySearchResultsTracker.foundCommunities.count - index + if distanceFromEnd == searchPageSize / 2 { + if hasMorePages { + performSearch() + } + } + } + } + } + } + } + } + + private func getSearchTextBinding() -> Binding { + return Binding(get: { searchText }, set: { + searchText = $0 + + // Revert to show suggestions if we clear the search + showRecentSearches = searchText.isEmpty || lastSearchedText != searchText + }) + } + + func performSearch() { + // If we are searching, cancel the task + if let task = searchTask { + if !task.isCancelled { + task.cancel() + searchTask = nil + } + } + + // Fresh search, reset paging and results + if lastSearchedText != searchText { + searchPage = 1 + communitySearchResultsTracker.foundCommunities = [] + } + + // Only cache recent searches on first search + if searchPage == 1 { + recentSearchesTracker.addRecentSearch(searchText) + } + + isSearching = true + showRecentSearches = false + lastSearchedText = searchText + + let currentSearchPage = searchPage + searchPage += 1 + + searchTask = Task(priority: .userInitiated) { [searchText] in + do { + defer { + isSearching = false + } + + print("Searching for '\(searchText)' on page \(searchPage)") + + let request = SearchRequest( + account: account, + query: searchText, + searchType: .communities, + sortOption: .topAll, + listingType: .all, + page: currentSearchPage, + limit: searchPageSize + ) + + let response = try await APIClient().perform(request: request) + communitySearchResultsTracker.foundCommunities.append(contentsOf: response.communities) + + // We have more data to load if we get the amount we asked for + hasMorePages = response.communities.count == searchPageSize + + print("Found \(response.communities.count) communities") + + } catch is CancellationError { + print("Search cancelled") + } catch { + print("Unknown error: \(error)") + } + } + } +} diff --git a/Mlem/Window.swift b/Mlem/Window.swift index 31d8931f3..8f17ad7e9 100644 --- a/Mlem/Window.swift +++ b/Mlem/Window.swift @@ -12,6 +12,7 @@ struct Window: View { @StateObject var favoriteCommunitiesTracker: FavoriteCommunitiesTracker = .init() @StateObject var communitySearchResultsTracker: CommunitySearchResultsTracker = .init() @StateObject var filtersTracker: FiltersTracker = .init() + @StateObject var recentSearchesTracker: RecentSearchesTracker = .init() var body: some View { ContentView() @@ -19,6 +20,7 @@ struct Window: View { .environmentObject(appState) .environmentObject(favoriteCommunitiesTracker) .environmentObject(communitySearchResultsTracker) + .environmentObject(recentSearchesTracker) .onChange(of: filtersTracker.filteredKeywords) { newValue in print("Change detected in filtered keywords: \(newValue)") do {