Autocomplete custom emojis

This commit is contained in:
Shadowfacts 2025-02-28 12:35:37 -05:00
parent 4dbb7a372a
commit 85a9f9fb49
7 changed files with 243 additions and 15 deletions

View File

@ -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
}

View File

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

View File

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

View File

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

View File

@ -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<FocusableField?>.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

View File

@ -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")
}))
}
}

View File

@ -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))
}
}