192 lines
6.1 KiB
Swift
192 lines
6.1 KiB
Swift
|
//
|
||
|
// 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<String>()
|
||
|
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<Label: View>: 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()
|
||
|
//}
|