// // AddReactionView.swift // Tusker // // Created by Shadowfacts on 4/17/24. // Copyright © 2024 Shadowfacts. All rights reserved. // import SwiftUI import Pachyderm import TuskerComponents struct AddReactionView: View { let mastodonController: MastodonController let addReaction: (Reaction) async throws -> Void @Environment(\.dismiss) private var dismiss @ScaledMetric private var emojiSize = 30 @State private var allEmojis: [Emoji] = [] @State private var emojisBySection: [String: [Emoji]] = [:] @State private var query = "" @State private var error: (any Error)? var body: some View { NavigationView { ScrollView(.vertical) { LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) { if query.count == 1 { Section { AddReactionButton { await doAddReaction(.emoji(query)) } label: { Text(query) .font(.system(size: 25)) } .buttonStyle(.plain) } } ForEach(emojisBySection.keys.sorted(), id: \.self) { section in Section { ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in AddReactionButton { await doAddReaction(.custom(emoji)) } label: { CustomEmojiImageView(emoji: 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(.horizontal) } .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always)) .searchPresentationToolbarBehaviorIfAvailable() .onChange(of: query) { _ in updateFilteredEmojis() } .navigationTitle("Add Reaction") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(role: .cancel) { dismiss() } label: { Text("Cancel") } } } } .navigationViewStyle(.stack) .mediumPresentationDetentIfAvailable() .alertWithData("Error Adding Reaction", data: $error, actions: { _ in Button("OK") {} }, message: { error in Text(error.localizedDescription) }) .task { allEmojis = await mastodonController.getCustomEmojis() updateFilteredEmojis() } } private func updateFilteredEmojis() { let filteredEmojis = if !query.isEmpty { allEmojis.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) } else { allEmojis } var shortcodes = Set() var newEmojis = [Emoji]() var newEmojisBySection = [String: [Emoji]]() for emoji in filteredEmojis where !shortcodes.contains(emoji.shortcode) { newEmojis.append(emoji) shortcodes.insert(emoji.shortcode) let category = emoji.category ?? "" if newEmojisBySection.keys.contains(category) { newEmojisBySection[category]!.append(emoji) } else { newEmojisBySection[category] = [emoji] } } emojisBySection = newEmojisBySection } private func doAddReaction(_ reaction: Reaction) async { try! await Task.sleep(nanoseconds: NSEC_PER_SEC) do { try await addReaction(reaction) dismiss() } catch { self.error = error } } enum Reaction { case emoji(String) case custom(Emoji) } } private struct AddReactionButton: View { let addReaction: () async -> Void @ViewBuilder let label: Label @State private var isLoading = false var body: some View { Button { isLoading = true Task { await addReaction() isLoading = false } } label: { ZStack { label .opacity(isLoading ? 0 : 1) if isLoading { ProgressView() } } } .padding(2) .hoverEffect() } } private extension View { @available(iOS, obsoleted: 16.0) @ViewBuilder func mediumPresentationDetentIfAvailable() -> some View { if #available(iOS 16.0, *) { self.presentationDetents([.medium, .large]) } else { self } } @available(iOS, obsoleted: 17.1) @ViewBuilder func searchPresentationToolbarBehaviorIfAvailable() -> some View { if #available(iOS 17.1, *) { self.searchPresentationToolbarBehavior(.avoidHidingContent) } else { self } } } //#Preview { // AddReactionView() //}