forked from shadowfacts/Tusker
197 lines
7.0 KiB
Swift
197 lines
7.0 KiB
Swift
//
|
|
// AutocompleteEmojisController.swift
|
|
// ComposeUI
|
|
//
|
|
// Created by Shadowfacts on 3/26/23.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Pachyderm
|
|
import Combine
|
|
|
|
class AutocompleteEmojisController: ViewController {
|
|
unowned let composeController: ComposeController
|
|
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
|
|
|
private var stateCancellable: AnyCancellable?
|
|
private var searchTask: Task<Void, Never>?
|
|
|
|
@Published var expanded = false
|
|
@Published var emojis: [Emoji] = []
|
|
|
|
var emojisBySection: [String: [Emoji]] {
|
|
var values: [String: [Emoji]] = [:]
|
|
for emoji in emojis {
|
|
let key = emoji.category ?? ""
|
|
if !values.keys.contains(key) {
|
|
values[key] = [emoji]
|
|
} else {
|
|
values[key]!.append(emoji)
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
init(composeController: ComposeController) {
|
|
self.composeController = composeController
|
|
|
|
stateCancellable = composeController.$currentInput
|
|
.compactMap { $0 }
|
|
.flatMap { $0.autocompleteStatePublisher }
|
|
.compactMap {
|
|
if case .emoji(let s) = $0 {
|
|
return s
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
.removeDuplicates()
|
|
.sink { [unowned self] query in
|
|
self.searchTask?.cancel()
|
|
self.searchTask = Task {
|
|
await self.queryChanged(query)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func queryChanged(_ query: String) async {
|
|
var emojis = await withCheckedContinuation { continuation in
|
|
composeController.mastodonController.getCustomEmojis {
|
|
continuation.resume(returning: $0)
|
|
}
|
|
}
|
|
guard !Task.isCancelled else {
|
|
return
|
|
}
|
|
|
|
if !query.isEmpty {
|
|
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)
|
|
}
|
|
|
|
var shortcodes = Set<String>()
|
|
var newEmojis = [Emoji]()
|
|
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
|
newEmojis.append(emoji)
|
|
shortcodes.insert(emoji.shortcode)
|
|
}
|
|
self.emojis = newEmojis
|
|
}
|
|
|
|
private func toggleExpanded() {
|
|
withAnimation {
|
|
expanded.toggle()
|
|
}
|
|
}
|
|
|
|
private func autocomplete(with emoji: Emoji) {
|
|
guard let input = composeController.currentInput else { return }
|
|
input.autocomplete(with: ":\(emoji.shortcode):")
|
|
}
|
|
|
|
var view: some View {
|
|
AutocompleteEmojisView()
|
|
}
|
|
|
|
struct AutocompleteEmojisView: View {
|
|
@EnvironmentObject private var composeController: ComposeController
|
|
@EnvironmentObject private var controller: AutocompleteEmojisController
|
|
@ScaledMetric private var emojiSize = 30
|
|
|
|
var body: some View {
|
|
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
|
|
HStack(alignment: controller.expanded ? .top : .center, spacing: 0) {
|
|
emojiList
|
|
.transition(.move(edge: .bottom))
|
|
|
|
toggleExpandedButton
|
|
.padding(.trailing, 8)
|
|
.padding(.top, controller.expanded ? 8 : 0)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emojiList: some View {
|
|
if controller.expanded {
|
|
verticalGrid
|
|
.frame(height: 150)
|
|
} else {
|
|
horizontalScrollView
|
|
}
|
|
}
|
|
|
|
private var verticalGrid: some View {
|
|
ScrollView {
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
|
ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in
|
|
Section {
|
|
ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in
|
|
Button(action: { controller.autocomplete(with: emoji) }) {
|
|
composeController.emojiImageView(emoji)
|
|
.frame(height: emojiSize)
|
|
}
|
|
.accessibilityLabel(emoji.shortcode)
|
|
}
|
|
} header: {
|
|
if !section.isEmpty {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(section)
|
|
.font(.caption)
|
|
|
|
Divider()
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.all, 8)
|
|
// the spacing between the grid sections doesn't seem to be taken into account by the ScrollView?
|
|
.padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var horizontalScrollView: some View {
|
|
ScrollView(.horizontal) {
|
|
HStack(spacing: 8) {
|
|
ForEach(controller.emojis, id: \.shortcode) { emoji in
|
|
Button(action: { controller.autocomplete(with: emoji) }) {
|
|
HStack(spacing: 4) {
|
|
composeController.emojiImageView(emoji)
|
|
.frame(height: emojiSize)
|
|
Text(verbatim: ":\(emoji.shortcode):")
|
|
.foregroundColor(.primary)
|
|
}
|
|
}
|
|
.accessibilityLabel(emoji.shortcode)
|
|
.frame(height: emojiSize)
|
|
}
|
|
.animation(.linear(duration: 0.2), value: controller.emojis)
|
|
|
|
Spacer(minLength: emojiSize)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.frame(height: emojiSize + 16)
|
|
}
|
|
}
|
|
|
|
private var toggleExpandedButton: some View {
|
|
Button(action: controller.toggleExpanded) {
|
|
Image(systemName: "chevron.down")
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.rotationEffect(controller.expanded ? .zero : .degrees(180))
|
|
}
|
|
.accessibilityLabel(controller.expanded ? "Collapse" : "Expand")
|
|
.frame(width: 20, height: 20)
|
|
}
|
|
}
|
|
}
|