// // ComposeAutocompleteView.swift // Tusker // // Created by Shadowfacts on 10/10/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import SwiftUI import CoreData import Pachyderm struct ComposeAutocompleteView: View { let autocompleteState: ComposeUIState.AutocompleteState @Environment(\.colorScheme) var colorScheme: ColorScheme private var backgroundColor: Color { Color(white: colorScheme == .light ? 0.98 : 0.15) } private var borderColor: Color { Color(white: colorScheme == .light ? 0.85 : 0.25) } var body: some View { suggestionsView // animate changes of the scroll view items .animation(.default) .background(backgroundColor) .overlay(borderColor.frame(height: 0.5), alignment: .top) } @ViewBuilder private var suggestionsView: some View { switch autocompleteState { case .mention(_): ComposeAutocompleteMentionsView() case .emoji(_): ComposeAutocompleteEmojisView() case .hashtag(_): ComposeAutocompleteHashtagsView() } } } fileprivate extension View { @ViewBuilder func iOS13OnlyPadding() -> some View { // on iOS 13, if the scroll view content's height changes after the view is added to the hierarchy, // it doesn't appear on screen until interactive keyboard dismissal is started and then cancelled :S if #available(iOS 14.0, *) { self } else { self.frame(height: 46) } } } struct ComposeAutocompleteMentionsView: View { @EnvironmentObject private var mastodonController: MastodonController @EnvironmentObject private var uiState: ComposeUIState @ObservedObject private var preferences = Preferences.shared // can't use AccountProtocol because of associated type requirements @State private var accounts: [EitherAccount] = [] @State private var searchRequest: URLSessionTask? var body: some View { ScrollView(.horizontal) { // can't use LazyHStack because changing the contents of the ForEach causes the ScrollView to hang HStack(spacing: 8) { ForEach(accounts, id: \.id) { (account) in Button { uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct) ") } label: { HStack(spacing: 4) { ComposeAvatarImageView(url: account.avatar) .frame(width: 30, height: 30) .cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 30) VStack(alignment: .leading) { switch account { case let .pachyderm(underlying): AccountDisplayNameLabel(account: underlying, fontSize: 14) .foregroundColor(Color(UIColor.label)) case let .coreData(underlying): AccountDisplayNameLabel(account: underlying, fontSize: 14) .foregroundColor(Color(UIColor.label)) } Text(verbatim: "@\(account.acct)") .font(.system(size: 12)) .foregroundColor(Color(UIColor.label)) } } } .frame(height: 30) .padding(.vertical, 8) .animation(.linear(duration: 0.1)) } Spacer() } .padding(.horizontal, 8) .iOS13OnlyPadding() } .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) .onDisappear { searchRequest?.cancel() } } private func queryChanged(_ state: ComposeUIState.AutocompleteState?) { guard case let .mention(query) = state, !query.isEmpty else { accounts = [] return } let localSearchWorkItem = DispatchWorkItem { // todo: there's got to be something more efficient than this :/ let wildcardedQuery = query.map { "*\($0)" }.joined() + "*" let request: NSFetchRequest = AccountMO.fetchRequest() request.predicate = NSPredicate(format: "displayName LIKE %@ OR acct LIKE %@", wildcardedQuery, wildcardedQuery) if let results = try? mastodonController.persistentContainer.viewContext.fetch(request) { loadAccounts(results.map { .coreData($0) }, query: query) } } // we only want to search locally if the search API call takes more than .25sec or it fails DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: localSearchWorkItem) if let oldRequest = searchRequest { oldRequest.cancel() } let apiRequest = Client.searchForAccount(query: query) searchRequest = mastodonController.run(apiRequest) { (response) in guard case let .success(accounts, _) = response else { return } localSearchWorkItem.cancel() // if the query has changed, don't bother loading the now-outdated results if case .mention(query) = uiState.autocompleteState { self.loadAccounts(accounts.map { .pachyderm($0) }, query: query) } } } private func loadAccounts(_ accounts: [EitherAccount], query: String) { // when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself let ignoreDomain = !query.contains("@") self.accounts = accounts.map { (account: EitherAccount) -> (EitherAccount, (matched: Bool, score: Int)) in let fuzzyStr = ignoreDomain ? String(account.acct.split(separator: "@").first!) : account.acct let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr)) return res } .filter(\.1.matched) // todo: it would be nice to prioritize followee/follower accounts, but relationships aren't cached .sorted { $0.1.score > $1.1.score } .map(\.0) } private enum EitherAccount { case pachyderm(Account) case coreData(AccountMO) var id: String { switch self { case let .pachyderm(account): return account.id case let .coreData(account): return account.id } } var acct: String { switch self { case let .pachyderm(account): return account.acct case let .coreData(account): return account.acct } } var avatar: URL { switch self { case let .pachyderm(account): return account.avatar case let .coreData(account): return account.avatar } } } } struct ComposeAutocompleteEmojisView: View { @EnvironmentObject private var mastodonController: MastodonController @EnvironmentObject private var uiState: ComposeUIState @State private var emojis: [Emoji] = [] var body: some View { ScrollView(.horizontal) { HStack(spacing: 8) { ForEach(emojis, id: \.shortcode) { (emoji) in Button { uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode): ") } label: { HStack(spacing: 4) { CustomEmojiImageView(emoji: emoji) .frame(height: 30) Text(verbatim: ":\(emoji.shortcode):") .foregroundColor(Color(UIColor.label)) } } .frame(height: 30) .padding(.vertical, 8) .animation(.linear(duration: 0.1)) } Spacer() } .padding(.horizontal, 8) .iOS13OnlyPadding() } .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) } private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { guard case let .emoji(query) = autocompleteState, !query.isEmpty else { emojis = [] return } mastodonController.getCustomEmojis { (emojis) in guard case .emoji(query) = self.uiState.autocompleteState else { return } self.emojis = emojis.map { (emoji) -> (Emoji, (matched: Bool, score: Int)) in (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode)) } .filter(\.1.matched) .sorted { $0.1.score > $1.1.score } .map(\.0) } } } struct ComposeAutocompleteHashtagsView: View { @EnvironmentObject private var mastodonController: MastodonController @EnvironmentObject private var uiState: ComposeUIState @State private var hashtags: [Hashtag] = [] @State private var trendingRequest: URLSessionTask? @State private var searchRequest: URLSessionTask? var body: some View { ScrollView(.horizontal) { HStack(spacing: 8) { ForEach(hashtags, id: \.name) { (hashtag) in Button { uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name) ") } label: { Text(verbatim: "#\(hashtag.name)") .foregroundColor(Color(UIColor.label)) } .frame(height: 30) .padding(.vertical, 8) .animation(.linear(duration: 0.1)) } Spacer() } .padding(.horizontal, 8) .iOS13OnlyPadding() } .onReceive(uiState.$autocompleteState.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main), perform: queryChanged) .onDisappear { trendingRequest?.cancel() } } private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { guard case let .hashtag(query) = autocompleteState, !query.isEmpty else { hashtags = [] return } let onlySavedTagsWorkItem = DispatchWorkItem { self.updateHashtags(searchResults: [], trendingTags: [], query: query) } // we only want to do the local-only search if the trends API call takes more than .25sec or it fails DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: onlySavedTagsWorkItem) var trendingTags: [Hashtag] = [] var searchedTags: [Hashtag] = [] let group = DispatchGroup() group.enter() trendingRequest = mastodonController.run(Client.getTrends()) { (response) in defer { group.leave() } guard case let .success(trends, _) = response else { return } trendingTags = trends } group.enter() searchRequest = mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])) { (response) in defer { group.leave() } guard case let .success(results, _) = response else { return } searchedTags = results.hashtags } group.notify(queue: .main) { onlySavedTagsWorkItem.cancel() // if the query has changed, don't bother loading the now-outdated results if case .hashtag(query) = self.uiState.autocompleteState { self.updateHashtags(searchResults: searchedTags, trendingTags: trendingTags, query: query) } } } private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) { let savedTags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) hashtags = (searchResults + savedTags + trendingTags) .map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in return (tag, FuzzyMatcher.match(pattern: query, str: tag.name)) } .filter(\.1.matched) .sorted { $0.1.score > $1.1.score } .map(\.0) } } struct ComposeAutocompleteView_Previews: PreviewProvider { static var previews: some View { ComposeAutocompleteView(autocompleteState: .mention("shadowfacts")) } }