// // 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() } } } 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) } .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() // dispatch back to the main thread because loadAccounts uses CoreData DispatchQueue.main.async { // 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) .map { (account, res) -> (EitherAccount, Int) in // give higher weight to accounts that the user follows or is followed by var score = res.score if let relationship = mastodonController.persistentContainer.relationship(forAccount: account.id) { if relationship.following { score += 3 } if relationship.followedBy { score += 2 } } return (account, score) } .sorted { $0.1 > $1.1 } .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 var expanded = false @State private var emojis: [Emoji] = [] var body: some View { // When exapnded, the toggle button should be at the top. When collapsed, it should be centered. HStack(alignment: expanded ? .top : .center, spacing: 0) { if case let .emoji(query) = uiState.autocompleteState { emojiList(query: query) .animation(.default) .transition(.move(edge: .bottom)) } else { // when the autocomplete view is animating out, the autocomplete state is nil // add a spacer so the expand button remains on the right Spacer() } toggleExpandedButton .padding(.trailing, 8) .padding(.top, expanded ? 8 : 0) } } @ViewBuilder private func emojiList(query: String) -> some View { if expanded { EmojiPickerWrapper(searchQuery: query) .frame(height: 150) } else { horizontalScrollView .onReceive(uiState.$autocompleteState, perform: queryChanged) } } private var horizontalScrollView: 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.2)) } Spacer(minLength: 30) } .padding(.horizontal, 8) .frame(height: 46) } } private var toggleExpandedButton: some View { Button { expanded.toggle() } label: { Image(systemName: expanded ? "chevron.down" : "chevron.up") .resizable() .aspectRatio(contentMode: .fit) } .frame(width: 20, height: 20) } private func queryChanged(_ autocompleteState: ComposeUIState.AutocompleteState?) { guard case let .emoji(query) = autocompleteState, !query.isEmpty else { emojis = [] return } mastodonController.getCustomEmojis { (emojis) in 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) } .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")) } }