From 85a9f9fb4954a394bec92e40a5bf6d6f1a3fe16f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 28 Feb 2025 12:35:37 -0500 Subject: [PATCH] Autocomplete custom emojis --- .../Sources/ComposeUI/ComposeUIConfig.swift | 2 + .../Autocomplete/AutocompleteEmojiView.swift | 185 ++++++++++++++++++ .../Views/Autocomplete/AutocompleteView.swift | 10 +- .../ComposeUI/Views/ComposeToolbarView.swift | 11 +- .../Sources/ComposeUI/Views/ComposeView.swift | 33 +++- ShareExtension/ShareHostingController.swift | 13 ++ .../Compose/ComposeHostingController.swift | 4 + 7 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteEmojiView.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift index 46ac96ae..f861077c 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeUIConfig.swift @@ -50,6 +50,8 @@ public protocol ComposeUIDelegate: AnyObject { func userActivityForDraft(_ draft: Draft) -> NSItemProvider? func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView + + func emojiImageView(_ emoji: Emoji) -> AnyView } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteEmojiView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteEmojiView.swift new file mode 100644 index 00000000..43fb718f --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteEmojiView.swift @@ -0,0 +1,185 @@ +// +// AutocompleteEmojiView.swift +// ComposeUI +// +// Created by Shadowfacts on 2/28/25. +// + +import SwiftUI +import Pachyderm +import TuskerComponents + +struct AutocompleteEmojiView: View { + let query: String + let mastodonController: any ComposeMastodonContext + @State private var expanded = false + @State private var loading = false + @State private var emojis: [Emoji] = [] + + var body: some View { + HStack(alignment: expanded ? .top : .center, spacing: 0) { + scrollView + + ToggleExpandedButton(expanded: $expanded) + .padding(.trailing, 8) + .padding(.top, expanded ? 8 : 0) + } + .task(id: query) { + await queryChanged() + } + } + + @ViewBuilder + private var scrollView: some View { + if expanded { + ScrollView(.vertical) { + ExpandedEmojiView(emojis: emojis) + } + .frame(height: 150) + .frame(maxWidth: .infinity) + } else { + ScrollView(.horizontal) { + InlineEmojiView(loading: loading, emojis: emojis) + .animation(.snappy, value: emojis) + } + } + } + + private func queryChanged() async { + loading = true + + var emojis = await mastodonController.getCustomEmojis() + + if !query.isEmpty { + emojis = emojis + .map { emoji in + (emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode)) + } + .filter(\.1.matched) + .sorted { $0.1.score > $1.1.score } + .map(\.0) + } + + self.emojis = emojis + + loading = false + } +} + +private struct InlineEmojiView: View { + let loading: Bool + let emojis: [Emoji] + + var body: some View { + LazyHStack(spacing: 8) { + if emojis.isEmpty && loading { + ProgressView() + .progressViewStyle(.circular) + } + + ForEach(emojis, id: \.shortcode) { emoji in + AutocompleteEmojiButton(emoji: emoji) + } + } + .padding(.horizontal, 8) + .frame(height: ComposeToolbarView.autocompleteHeight) + } +} + +private struct ExpandedEmojiView: View { + let emojis: [Emoji] + @State private var emojisBySection: [String: [Emoji]] = [:] + @ScaledMetric private var emojiSize = 30 + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize))], spacing: 4) { + ForEach(emojisBySection.keys.sorted(), id: \.self) { section in + ExpandedEmojiSection(section: section, emojis: emojisBySection[section]!) + } + } + .padding(.all, 8) + .task(id: emojis) { + groupEmojisBySection() + } + } + + private func groupEmojisBySection() { + var shortcodes = Set() + var emojisBySection: [String: [Emoji]] = [:] + for emoji in emojis where !shortcodes.contains(emoji.shortcode) { + shortcodes.insert(emoji.shortcode) + + let category = emoji.category ?? "" + emojisBySection[category, default: []].append(emoji) + } + self.emojisBySection = emojisBySection + } +} + +private struct ExpandedEmojiSection: View { + let section: String + let emojis: [Emoji] + + var body: some View { + Section { + ForEach(emojis, id: \.shortcode) { emoji in + AutocompleteEmojiButton(emoji: emoji) + .labelStyle(.iconOnly) + } + .animation(.snappy, value: emojis) + } header: { + if !section.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text(section) + .font(.caption) + + Divider() + } + .padding(.top, 4) + .animation(.snappy, value: emojis) + } + } + } +} + +private struct AutocompleteEmojiButton: View { + let emoji: Emoji + @FocusedInput private var input + @Environment(\.composeUIDelegate) private var delegate + + var body: some View { + Button { + input?.autocomplete(with: ":\(emoji.shortcode):") + } label: { + Label { + Text(verbatim: ":\(emoji.shortcode):") + } icon: { + if let delegate { + delegate.emojiImageView(emoji) + } + } + } + .accessibilityLabel(emoji.shortcode) + .frame(height: 30) + .padding(.vertical, 7) + } +} + +private struct ToggleExpandedButton: View { + @Binding var expanded: Bool + + var body: some View { + Button { + withAnimation(nil) { + expanded.toggle() + } + } label: { + Image(systemName: "chevron.down") + .resizable() + .aspectRatio(contentMode: .fit) + .rotationEffect(expanded ? .zero : .degrees(180)) + } + .accessibilityLabel(expanded ? "Collapse" : "Expand") + .frame(width: 20, height: 20) + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift index 7fb2165e..51166e16 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift @@ -12,12 +12,6 @@ struct AutocompleteView: View { @FocusedInputAutocompleteState private var state var body: some View { - contentView - .frame(height: ComposeToolbarView.autocompleteHeight) - } - - @ViewBuilder - private var contentView: some View { switch state { case nil: Color.clear @@ -27,8 +21,8 @@ struct AutocompleteView: View { case .mention(let s): AutocompleteMentionView(query: s, mastodonController: mastodonController) .composeToolbarBackground() - default: - Color.red + case .emoji(let s): + AutocompleteEmojiView(query: s, mastodonController: mastodonController) .composeToolbarBackground() } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift index 57f808d6..2219575f 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift @@ -53,12 +53,14 @@ private struct ToolbarContentView: View { VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures) LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController) - - InsertEmojiButton() + Spacer() + FormatButtons() Spacer() + + InsertEmojiButton() } } } @@ -222,7 +224,10 @@ private struct InsertEmojiButton: View { } private func beginAutocompletingEmoji() { - input?.beginAutocompletingEmoji() + if let input, + input.autocompleteState == nil { + input.beginAutocompletingEmoji() + } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index ea0803e0..055e4647 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -60,7 +60,12 @@ public struct ComposeView: View { .environment(\.composeUIDelegate, delegate) .environment(\.currentAccount, currentAccount) #if !targetEnvironment(macCatalyst) && !os(visionOS) - .injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField, delegate: delegate) + .injectInputAccessoryHost( + state: state, + mastodonController: mastodonController, + focusedField: $focusedField, + delegate: delegate + ) #endif .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange) } @@ -352,7 +357,14 @@ private extension View { delegate: (any ComposeUIDelegate)? ) -> some View { if #available(iOS 16.0, *) { - self.modifier(InputAccessoryHostInjector(state: state, mastodonController: mastodonController, focusedField: focusedField, delegate: delegate)) + self.modifier( + InputAccessoryHostInjector( + state: state, + mastodonController: mastodonController, + focusedField: focusedField, + delegate: delegate + ) + ) } else { self } @@ -371,7 +383,14 @@ private struct InputAccessoryHostInjector: ViewModifier { focusedField: FocusState.Binding, delegate: (any ComposeUIDelegate)? ) { - self._factory = StateObject(wrappedValue: ViewFactory(state: state, mastodonController: mastodonController, focusedField: focusedField, delegate: delegate)) + self._factory = StateObject( + wrappedValue: ViewFactory( + state: state, + mastodonController: mastodonController, + focusedField: focusedField, + delegate: delegate + ) + ) } func body(content: Content) -> some View { @@ -394,7 +413,13 @@ private struct InputAccessoryHostInjector: ViewModifier { delegate: (any ComposeUIDelegate)? ) { self._focusedInput = MutableObservableBox(wrappedValue: nil) - let view = InputAccessoryToolbarView(state: state, mastodonController: mastodonController, focusedField: focusedField, focusedInputBox: _focusedInput, delegate: delegate) + let view = InputAccessoryToolbarView( + state: state, + mastodonController: mastodonController, + focusedField: focusedField, + focusedInputBox: _focusedInput, + delegate: delegate + ) let controller = UIHostingController(rootView: view) controller.sizingOptions = .intrinsicContentSize controller.view.autoresizingMask = .flexibleHeight diff --git a/ShareExtension/ShareHostingController.swift b/ShareExtension/ShareHostingController.swift index a7b7be71..b8a6f171 100644 --- a/ShareExtension/ShareHostingController.swift +++ b/ShareExtension/ShareHostingController.swift @@ -193,4 +193,17 @@ private class ShareComposeUIDelegate: ComposeUIDelegate { func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView { AnyView(EmptyView()) } + + func emojiImageView(_ emoji: Emoji) -> AnyView { + guard let url = URL(emoji.url) else { + return AnyView(EmptyView()) + } + return AnyView(AsyncImage(url: url, content: { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + }, placeholder: { + Image(systemName: "smiley.fill") + })) + } } diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index ff08df35..9d882153 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -362,4 +362,8 @@ private class ComposeUIDelegateImpl: ComposeUIDelegate { func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView { AnyView(ComposeReplyContentView(status: status, mastodonController: mastodonController, heightChanged: heightChanged)) } + + func emojiImageView(_ emoji: Emoji) -> AnyView { + AnyView(CustomEmojiImageView(emoji: emoji)) + } }