// // 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? @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() 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) } } }