2023-04-16 17:23:13 +00:00
|
|
|
//
|
|
|
|
// AutocompleteMentionsController.swift
|
|
|
|
// ComposeUI
|
|
|
|
//
|
|
|
|
// Created by Shadowfacts on 3/25/23.
|
|
|
|
//
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
import Combine
|
|
|
|
import Pachyderm
|
2023-04-16 17:47:06 +00:00
|
|
|
import TuskerComponents
|
2023-04-16 17:23:13 +00:00
|
|
|
|
|
|
|
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<Void, Never>?
|
|
|
|
|
|
|
|
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 {
|
2023-04-16 17:47:06 +00:00
|
|
|
@EnvironmentObject private var composeController: ComposeController
|
2023-04-16 17:23:13 +00:00
|
|
|
@EnvironmentObject private var controller: AutocompleteMentionsController
|
|
|
|
let account: AnyAccount
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
Button(action: { controller.autocomplete(with: account) }) {
|
|
|
|
HStack(spacing: 4) {
|
2023-04-16 17:47:06 +00:00
|
|
|
AvatarImageView(
|
|
|
|
url: account.value.avatar,
|
|
|
|
size: 30,
|
|
|
|
style: composeController.config.avatarStyle,
|
|
|
|
fetchAvatar: composeController.fetchAvatar
|
|
|
|
)
|
2023-04-16 17:23:13 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|