// // AutocompleteMentionsController.swift // ComposeUI // // Created by Shadowfacts on 3/25/23. // import SwiftUI import Combine import Pachyderm class AutocompleteMentionsController: ViewController { unowned let composeController: ComposeController var mastodonController: ComposeMastodonContext { composeController.mastodonController } private var stateCancellable: AnyCancellable? @Published private var accounts: [AnyAccount] = [] private var searchTask: Task? init(composeController: ComposeController) { self.composeController = composeController stateCancellable = composeController.$currentInput .compactMap { $0 } .flatMap { $0.autocompleteStatePublisher } .compactMap { if case .mention(let s) = $0 { return s } else { return nil } } .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) .sink { [unowned self] query in self.searchTask?.cancel() self.searchTask = Task { await self.queryChanged(query) } } } @MainActor private func queryChanged(_ query: String) async { guard !query.isEmpty else { accounts = [] return } let localSearchTask = Task { // we only want to search locally if the search API call takes more than .25sec or it fails try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC) let results = self.mastodonController.searchCachedAccounts(query: query) try Task.checkCancellation() if !results.isEmpty { self.loadAccounts(results.map { .init(value: $0) }, query: query) } } let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0 guard let accounts, !Task.isCancelled else { return } localSearchTask.cancel() loadAccounts(accounts.map { .init(value: $0) }, query: query) } @MainActor private func loadAccounts(_ accounts: [AnyAccount], query: String) { guard case .mention(query) = composeController.currentInput?.autocompleteState else { return } // 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) -> (AnyAccount, (matched: Bool, score: Int)) in let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr)) return res } .filter(\.1.matched) .map { (account, res) -> (AnyAccount, Int) in // give higher weight to accounts that the user follows or is followed by var score = res.score if let relationship = mastodonController.cachedRelationship(for: account.value.id) { if relationship.following { score += 3 } if relationship.followedBy { score += 2 } } return (account, score) } .sorted { $0.1 > $1.1 } .map(\.0) } private func autocomplete(with account: AnyAccount) { guard let input = composeController.currentInput else { return } input.autocomplete(with: "@\(account.value.acct)") } var view: some View { AutocompleteMentionsView() } struct AutocompleteMentionsView: View { @EnvironmentObject private var controller: AutocompleteMentionsController var body: some View { ScrollView(.horizontal) { HStack(spacing: 8) { ForEach(controller.accounts) { account in AutocompleteMentionButton(account: account) } Spacer() } .padding(.horizontal, 8) .animation(.linear(duration: 0.2), value: controller.accounts) } .onDisappear { controller.searchTask?.cancel() } } } private struct AutocompleteMentionButton: View { @EnvironmentObject private var controller: AutocompleteMentionsController let account: AnyAccount var body: some View { Button(action: { controller.autocomplete(with: account) }) { HStack(spacing: 4) { AvatarImageView(url: account.value.avatar, size: 30) VStack(alignment: .leading) { controller.composeController.displayNameLabel(account.value, .subheadline, 14) .foregroundColor(.primary) Text(verbatim: "@\(account.value.acct)") .font(.caption) .foregroundColor(.primary) } } } .frame(height: 30) .padding(.vertical, 8) } } } fileprivate struct AnyAccount: Equatable, Identifiable { let value: any AccountProtocol var id: String { value.id } static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool { return lhs.value.id == rhs.value.id } }