Autocomplete hashtags
This commit is contained in:
parent
618dc78fe9
commit
86e1ac495a
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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? {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user