diff --git a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift index 586dde94..2fab0422 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/ComposeInput.swift @@ -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() + } + } + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteHashtagView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteHashtagView.swift new file mode 100644 index 00000000..3abcbbbd --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteHashtagView.swift @@ -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() + 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)") + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift new file mode 100644 index 00000000..a87c04f4 --- /dev/null +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/Autocomplete/AutocompleteView.swift @@ -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() + } + } +} diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift index 32895e88..57f808d6 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeToolbarView.swift @@ -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: View { @ViewBuilder let content: Content diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift index 7c43f992..b21bf0bd 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/ComposeView.swift @@ -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) } } } diff --git a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift index 9f577659..a46bc4e0 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/Views/NewMainTextView.swift @@ -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? {