2020-10-12 02:14:45 +00:00
|
|
|
//
|
|
|
|
// 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 {
|
2020-10-13 02:03:50 +00:00
|
|
|
uiState.autocompleteHandler?.autocomplete(with: "@\(account.acct)")
|
2020-10-12 02:14:45 +00:00
|
|
|
} 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> = 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()
|
|
|
|
|
2020-10-14 23:28:32 +00:00
|
|
|
// 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)
|
|
|
|
}
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
2020-10-14 23:28:32 +00:00
|
|
|
.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 }
|
2020-10-12 02:14:45 +00:00
|
|
|
.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
|
|
|
|
|
2020-10-18 15:11:47 +00:00
|
|
|
@State var expanded = false
|
2020-10-12 02:14:45 +00:00
|
|
|
@State private var emojis: [Emoji] = []
|
|
|
|
|
|
|
|
var body: some View {
|
2020-10-18 15:11:47 +00:00
|
|
|
// 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 {
|
2020-10-12 02:14:45 +00:00
|
|
|
ScrollView(.horizontal) {
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
ForEach(emojis, id: \.shortcode) { (emoji) in
|
|
|
|
Button {
|
2020-10-13 02:03:50 +00:00
|
|
|
uiState.autocompleteHandler?.autocomplete(with: ":\(emoji.shortcode):")
|
2020-10-12 02:14:45 +00:00
|
|
|
} label: {
|
|
|
|
HStack(spacing: 4) {
|
|
|
|
CustomEmojiImageView(emoji: emoji)
|
|
|
|
.frame(height: 30)
|
|
|
|
Text(verbatim: ":\(emoji.shortcode):")
|
|
|
|
.foregroundColor(Color(UIColor.label))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.frame(height: 30)
|
|
|
|
.padding(.vertical, 8)
|
2020-10-18 15:11:47 +00:00
|
|
|
.animation(.linear(duration: 0.2))
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
|
2020-10-18 15:11:47 +00:00
|
|
|
Spacer(minLength: 30)
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
.padding(.horizontal, 8)
|
2020-10-18 15:11:47 +00:00
|
|
|
.frame(height: 46)
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
2020-10-18 15:11:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
2020-10-12 02:14:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
2020-10-13 02:03:50 +00:00
|
|
|
uiState.autocompleteHandler?.autocomplete(with: "#\(hashtag.name)")
|
2020-10-12 02:14:45 +00:00
|
|
|
} 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"))
|
|
|
|
}
|
|
|
|
}
|