diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 91f0afef..77f8842d 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -9,6 +9,7 @@ import UIKit final class ConversationUIModel: ObservableObject { @Published private(set) var showAutocomplete = false @Published private(set) var autocompleteSuggestions: [String] = [] + @Published private(set) var selectedAutocompleteIndex = 0 @Published private(set) var currentNickname: String @Published private(set) var isBatchingPublic = false @Published private(set) var canSendMediaInCurrentContext = true @@ -36,6 +37,7 @@ final class ConversationUIModel: ObservableObject { self.isBatchingPublic = chatViewModel.isBatchingPublic self.showAutocomplete = chatViewModel.showAutocomplete self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions + self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext bind() @@ -104,6 +106,22 @@ final class ConversationUIModel: ObservableObject { chatViewModel.completeNickname(nickname, in: &text) } + /// Accept the currently highlighted mention suggestion, if any. + func completeSelectedSuggestion(in text: inout String) -> Bool { + guard showAutocomplete, + autocompleteSuggestions.indices.contains(selectedAutocompleteIndex) + else { return false } + _ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text) + return true + } + + func moveAutocompleteSelection(by delta: Int) { + guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return } + let count = min(4, autocompleteSuggestions.count) + let next = (selectedAutocompleteIndex + delta + count) % count + chatViewModel.selectedAutocompleteIndex = next + } + func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString { chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme) } @@ -193,6 +211,10 @@ final class ConversationUIModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$autocompleteSuggestions) + chatViewModel.$selectedAutocompleteIndex + .receive(on: DispatchQueue.main) + .assign(to: &$selectedAutocompleteIndex) + chatViewModel.$isBatchingPublic .receive(on: DispatchQueue.main) .assign(to: &$isBatchingPublic) diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index b5020980..c82b22c9 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -29,7 +29,7 @@ struct ContentComposerView: View { VStack(alignment: .leading, spacing: 6) { if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in + ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in Button(action: { _ = conversationUIModel.completeNickname(suggestion, in: &messageText) }) { @@ -43,6 +43,11 @@ struct ContentComposerView: View { .padding(.horizontal, 12) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading) + .background( + index == conversationUIModel.selectedAutocompleteIndex + ? palette.secondary.opacity(0.18) + : Color.clear + ) } .buttonStyle(.plain) } @@ -81,6 +86,16 @@ struct ContentComposerView: View { // dismissed keyboard, so it stays out of this. isTextFieldFocused.wrappedValue = true } + .modifier(AutocompleteKeyboardNavigationModifier( + isActive: conversationUIModel.showAutocomplete + && !conversationUIModel.autocompleteSuggestions.isEmpty, + onMove: { delta in + conversationUIModel.moveAutocompleteSelection(by: delta) + }, + onAccept: { + conversationUIModel.completeSelectedSuggestion(in: &messageText) + } + )) .padding(.vertical, theme.usesGlassChrome ? 8 : 4) .padding(.horizontal, 6) .themedInputBackground() @@ -374,3 +389,33 @@ private extension ContentComposerView { ) } } + +/// Arrow/Tab navigation for the mention suggestion list. Inactive when the +/// panel is hidden so Tab keeps its normal focus-cycle behavior. +private struct AutocompleteKeyboardNavigationModifier: ViewModifier { + let isActive: Bool + let onMove: (Int) -> Void + let onAccept: () -> Bool + + func body(content: Content) -> some View { + if #available(iOS 17.0, macOS 14.0, *) { + content + .onKeyPress(.upArrow) { + guard isActive else { return .ignored } + onMove(-1) + return .handled + } + .onKeyPress(.downArrow) { + guard isActive else { return .ignored } + onMove(1) + return .handled + } + .onKeyPress(.tab) { + guard isActive else { return .ignored } + return onAccept() ? .handled : .ignored + } + } else { + content + } + } +}