Autocomplete hashtags

This commit is contained in:
Shadowfacts 2025-02-27 12:45:36 -05:00
parent 618dc78fe9
commit 86e1ac495a
6 changed files with 212 additions and 9 deletions

View File

@ -107,3 +107,36 @@ struct FocusedInput: DynamicProperty {
} }
#endif #endif
} }
@propertyWrapper
struct FocusedInputAutocompleteState: DynamicProperty {
@FocusedInput private var input
@StateObject private var updater = Updater()
var wrappedValue: AutocompleteState? {
input?.autocompleteState
}
func update() {
updater.update(input: input)
}
@MainActor
private class Updater: ObservableObject {
private var lastInput: (any ComposeInput)?
private var cancellable: AnyCancellable?
func update(input: (any ComposeInput)?) {
guard lastInput !== input else {
return
}
lastInput = input
cancellable = input?.autocompleteStatePublisher.sink { [unowned self] _ in
// the autocomplete state sometimes changes during a view update, so defer this
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
}
}
}

View File

@ -0,0 +1,95 @@
//
// AutocompleteHashtagView.swift
// ComposeUI
//
// Created by Shadowfacts on 2/26/25.
//
import SwiftUI
import Pachyderm
import TuskerComponents
struct AutocompleteHashtagView: View {
let query: String
let mastodonController: any ComposeMastodonContext
@State private var loading = false
@State private var hashtags: [Hashtag] = []
@FocusedInput private var input
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
if hashtags.isEmpty && loading {
ProgressView()
.progressViewStyle(.circular)
}
ForEach(hashtags, id: \.name) { hashtag in
Button {
autocomplete(hashtag: hashtag)
} label: {
Text(verbatim: "#\(hashtag.name)")
}
// total height is 44, ComposeToolbarView.autocompleteHeight
.frame(height: 30)
.padding(.vertical, 7)
}
Spacer()
}
.padding(.horizontal, 8)
.animation(.snappy, value: hashtags)
}
.frame(height: ComposeToolbarView.autocompleteHeight)
.task(id: query) {
await queryChanged()
}
}
private func queryChanged() async {
guard !query.isEmpty else {
loading = false
hashtags = []
return
}
loading = true
let localTags = mastodonController.searchCachedHashtags(query: query)
async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0
async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags
let trending = await trendingTags ?? []
let search = await searchResults ?? []
guard !Task.isCancelled else {
return
}
updateHashtags(searchResults: search, trending: trending, local: localTags)
loading = false
}
private func updateHashtags(searchResults: [Hashtag], trending: [Hashtag], local: [Hashtag]) {
var seenHashtags = Set<String>()
var hashtags = [(Hashtag, Int)]()
for group in [searchResults, trending, local] {
for tag in group where !seenHashtags.contains(tag.name) {
seenHashtags.insert(tag.name)
let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name)
if matched {
hashtags.append((tag, score))
}
}
}
self.hashtags = hashtags
.sorted { $0.1 > $1.1 }
.map(\.0)
}
private func autocomplete(hashtag: Hashtag) {
input?.autocomplete(with: "#\(hashtag.name)")
}
}

View File

@ -0,0 +1,32 @@
//
// AutocompleteView.swift
// ComposeUI
//
// Created by Shadowfacts on 2/26/25.
//
import SwiftUI
struct AutocompleteView: View {
let mastodonController: any ComposeMastodonContext
@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
case .hashtag(let s):
AutocompleteHashtagView(query: s, mastodonController: mastodonController)
.composeToolbarBackground()
default:
Color.red
.composeToolbarBackground()
}
}
}

View File

@ -12,12 +12,27 @@ import Pachyderm
import TuskerPreferences import TuskerPreferences
struct ComposeToolbarView: View { struct ComposeToolbarView: View {
static let height: CGFloat = 44 static let toolbarHeight: CGFloat = 44
static let autocompleteHeight: CGFloat = 44
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext let mastodonController: any ComposeMastodonContext
@FocusState.Binding var focusedField: FocusableField? @FocusState.Binding var focusedField: FocusableField?
var body: some View {
VStack(spacing: 0) {
AutocompleteView(mastodonController: mastodonController)
ToolbarContentView(draft: draft, mastodonController: mastodonController, focusedField: $focusedField)
}
}
}
private struct ToolbarContentView: View {
@ObservedObject var draft: Draft
let mastodonController: any ComposeMastodonContext
@FocusState.Binding var focusedField: FocusableField?
var body: some View { var body: some View {
#if os(visionOS) #if os(visionOS)
buttons buttons
@ -26,12 +41,8 @@ struct ComposeToolbarView: View {
buttons buttons
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
.frame(height: Self.height) .frame(height: ComposeToolbarView.toolbarHeight)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing]) .composeToolbarBackground()
.overlay(alignment: .top) {
Divider()
.ignoresSafeArea(edges: [.leading, .trailing])
}
#endif #endif
} }
@ -52,6 +63,17 @@ struct ComposeToolbarView: View {
} }
} }
extension View {
func composeToolbarBackground() -> some View {
self
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
.overlay(alignment: .top) {
Divider()
.ignoresSafeArea(edges: [.leading, .trailing])
}
}
}
#if !os(visionOS) #if !os(visionOS)
private struct ToolbarScrollView<Content: View>: View { private struct ToolbarScrollView<Content: View>: View {
@ViewBuilder let content: Content @ViewBuilder let content: Content

View File

@ -298,16 +298,28 @@ enum FocusableField: Hashable {
#if !os(visionOS) && !targetEnvironment(macCatalyst) #if !os(visionOS) && !targetEnvironment(macCatalyst)
private struct ToolbarSafeAreaInsetModifier: ViewModifier { private struct ToolbarSafeAreaInsetModifier: ViewModifier {
@StateObject private var keyboardReader = KeyboardReader() @StateObject private var keyboardReader = KeyboardReader()
@FocusedInputAutocompleteState private var autocompleteState
private var inset: CGFloat {
var height: CGFloat = 0
if keyboardReader.isVisible {
height += ComposeToolbarView.toolbarHeight
}
if autocompleteState != nil {
height += ComposeToolbarView.autocompleteHeight
}
return height
}
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
content content
.safeAreaPadding(.bottom, keyboardReader.isVisible ? 0 : ComposeToolbarView.height) .safeAreaPadding(.bottom, inset)
} else { } else {
content content
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
if !keyboardReader.isVisible { if !keyboardReader.isVisible {
Color.clear.frame(height: ComposeToolbarView.height) Color.clear.frame(height: inset)
} }
} }
} }

View File

@ -266,7 +266,16 @@ extension WrappedTextViewCoordinator: UITextViewDelegate {
} }
} }
func textViewDidBeginEditing(_ textView: UITextView) {
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
}
func textViewDidEndEditing(_ textView: UITextView) {
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
}
func textViewDidChangeSelection(_ textView: UITextView) { func textViewDidChangeSelection(_ textView: UITextView) {
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
} }
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {