Autocomplete hashtags
This commit is contained in:
parent
618dc78fe9
commit
86e1ac495a
@ -107,3 +107,36 @@ struct FocusedInput: DynamicProperty {
|
||||
}
|
||||
#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,8 +12,23 @@ import Pachyderm
|
||||
import TuskerPreferences
|
||||
|
||||
struct ComposeToolbarView: View {
|
||||
static let height: CGFloat = 44
|
||||
static let toolbarHeight: CGFloat = 44
|
||||
static let autocompleteHeight: CGFloat = 44
|
||||
|
||||
@ObservedObject var draft: Draft
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
@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?
|
||||
@ -26,12 +41,8 @@ struct ComposeToolbarView: View {
|
||||
buttons
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.frame(height: Self.height)
|
||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||
.overlay(alignment: .top) {
|
||||
Divider()
|
||||
.ignoresSafeArea(edges: [.leading, .trailing])
|
||||
}
|
||||
.frame(height: ComposeToolbarView.toolbarHeight)
|
||||
.composeToolbarBackground()
|
||||
#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)
|
||||
private struct ToolbarScrollView<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
@ -298,16 +298,28 @@ enum FocusableField: Hashable {
|
||||
#if !os(visionOS) && !targetEnvironment(macCatalyst)
|
||||
private struct ToolbarSafeAreaInsetModifier: ViewModifier {
|
||||
@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 {
|
||||
if #available(iOS 17.0, *) {
|
||||
content
|
||||
.safeAreaPadding(.bottom, keyboardReader.isVisible ? 0 : ComposeToolbarView.height)
|
||||
.safeAreaPadding(.bottom, inset)
|
||||
} else {
|
||||
content
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
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) {
|
||||
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
|
Loading…
x
Reference in New Issue
Block a user