Autocomplete custom emojis
This commit is contained in:
parent
4dbb7a372a
commit
85a9f9fb49
@ -50,6 +50,8 @@ public protocol ComposeUIDelegate: AnyObject {
|
|||||||
func userActivityForDraft(_ draft: Draft) -> NSItemProvider?
|
func userActivityForDraft(_ draft: Draft) -> NSItemProvider?
|
||||||
|
|
||||||
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView
|
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView
|
||||||
|
|
||||||
|
func emojiImageView(_ emoji: Emoji) -> AnyView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -12,12 +12,6 @@ struct AutocompleteView: View {
|
|||||||
@FocusedInputAutocompleteState private var state
|
@FocusedInputAutocompleteState private var state
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
contentView
|
|
||||||
.frame(height: ComposeToolbarView.autocompleteHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var contentView: some View {
|
|
||||||
switch state {
|
switch state {
|
||||||
case nil:
|
case nil:
|
||||||
Color.clear
|
Color.clear
|
||||||
@ -27,8 +21,8 @@ struct AutocompleteView: View {
|
|||||||
case .mention(let s):
|
case .mention(let s):
|
||||||
AutocompleteMentionView(query: s, mastodonController: mastodonController)
|
AutocompleteMentionView(query: s, mastodonController: mastodonController)
|
||||||
.composeToolbarBackground()
|
.composeToolbarBackground()
|
||||||
default:
|
case .emoji(let s):
|
||||||
Color.red
|
AutocompleteEmojiView(query: s, mastodonController: mastodonController)
|
||||||
.composeToolbarBackground()
|
.composeToolbarBackground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -53,12 +53,14 @@ private struct ToolbarContentView: View {
|
|||||||
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
VisibilityButton(draft: draft, instanceFeatures: mastodonController.instanceFeatures)
|
||||||
|
|
||||||
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
|
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
|
||||||
|
|
||||||
InsertEmojiButton()
|
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
FormatButtons()
|
FormatButtons()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
InsertEmojiButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -222,7 +224,10 @@ private struct InsertEmojiButton: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func beginAutocompletingEmoji() {
|
private func beginAutocompletingEmoji() {
|
||||||
input?.beginAutocompletingEmoji()
|
if let input,
|
||||||
|
input.autocompleteState == nil {
|
||||||
|
input.beginAutocompletingEmoji()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +60,12 @@ public struct ComposeView: View {
|
|||||||
.environment(\.composeUIDelegate, delegate)
|
.environment(\.composeUIDelegate, delegate)
|
||||||
.environment(\.currentAccount, currentAccount)
|
.environment(\.currentAccount, currentAccount)
|
||||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||||
.injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField, delegate: delegate)
|
.injectInputAccessoryHost(
|
||||||
|
state: state,
|
||||||
|
mastodonController: mastodonController,
|
||||||
|
focusedField: $focusedField,
|
||||||
|
delegate: delegate
|
||||||
|
)
|
||||||
#endif
|
#endif
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
|
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
|
||||||
}
|
}
|
||||||
@ -352,7 +357,14 @@ private extension View {
|
|||||||
delegate: (any ComposeUIDelegate)?
|
delegate: (any ComposeUIDelegate)?
|
||||||
) -> some View {
|
) -> some View {
|
||||||
if #available(iOS 16.0, *) {
|
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 {
|
} else {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@ -371,7 +383,14 @@ private struct InputAccessoryHostInjector: ViewModifier {
|
|||||||
focusedField: FocusState<FocusableField?>.Binding,
|
focusedField: FocusState<FocusableField?>.Binding,
|
||||||
delegate: (any ComposeUIDelegate)?
|
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 {
|
func body(content: Content) -> some View {
|
||||||
@ -394,7 +413,13 @@ private struct InputAccessoryHostInjector: ViewModifier {
|
|||||||
delegate: (any ComposeUIDelegate)?
|
delegate: (any ComposeUIDelegate)?
|
||||||
) {
|
) {
|
||||||
self._focusedInput = MutableObservableBox(wrappedValue: nil)
|
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)
|
let controller = UIHostingController(rootView: view)
|
||||||
controller.sizingOptions = .intrinsicContentSize
|
controller.sizingOptions = .intrinsicContentSize
|
||||||
controller.view.autoresizingMask = .flexibleHeight
|
controller.view.autoresizingMask = .flexibleHeight
|
||||||
|
@ -193,4 +193,17 @@ private class ShareComposeUIDelegate: ComposeUIDelegate {
|
|||||||
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView {
|
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView {
|
||||||
AnyView(EmptyView())
|
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")
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -362,4 +362,8 @@ private class ComposeUIDelegateImpl: ComposeUIDelegate {
|
|||||||
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView {
|
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView {
|
||||||
AnyView(ComposeReplyContentView(status: status, mastodonController: mastodonController, heightChanged: heightChanged))
|
AnyView(ComposeReplyContentView(status: status, mastodonController: mastodonController, heightChanged: heightChanged))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func emojiImageView(_ emoji: Emoji) -> AnyView {
|
||||||
|
AnyView(CustomEmojiImageView(emoji: emoji))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user