diff --git a/bitchat/Utils/ComposerAutocorrect.swift b/bitchat/Utils/ComposerAutocorrect.swift new file mode 100644 index 00000000..51d2a90c --- /dev/null +++ b/bitchat/Utils/ComposerAutocorrect.swift @@ -0,0 +1,49 @@ +// +// ComposerAutocorrect.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Token-aware autocorrect policy for the message composer (#969). +/// +/// Autocorrect helps with prose but fights `/commands`, `@mentions`, and +/// `#channels`. Disable it while the token under the cursor starts with one +/// of those sigils; leave it on otherwise. The composer TextField only exposes +/// the text (not a live selection), so callers pass the caret — typically the +/// end of the string, matching autocomplete. +/// +/// Mid-string caret is a known v1 limitation: when callers only know +/// `text.count`, moving the caret back into an existing `/` `@` `#` token +/// still leaves autocorrect on. Plumb selection through when SwiftUI exposes it. +enum ComposerAutocorrect { + /// Sigils that mean "exact token, don't rewrite me". + static let specialPrefixes: Set = ["/", "@", "#"] + + /// Whether `.autocorrectionDisabled` should be on for this caret position. + static func shouldDisable(for text: String, cursorPosition: Int) -> Bool { + let token = currentToken(in: text, cursorPosition: cursorPosition) + guard let first = token.first else { return false } + return specialPrefixes.contains(first) + } + + /// The whitespace-delimited token containing `cursorPosition` (or ending + /// at it when the caret sits on a boundary). + static func currentToken(in text: String, cursorPosition: Int) -> String { + guard !text.isEmpty else { return "" } + let clamped = min(max(0, cursorPosition), text.count) + let end = text.index(text.startIndex, offsetBy: clamped) + let before = text[.. Void #if os(iOS) @@ -76,9 +82,12 @@ struct ContentComposerView: View { .bitchatFont(size: 15) .foregroundColor(palette.primary) .focused(isTextFieldFocused) - .autocorrectionDisabled(true) + // Token-aware (#969): autocorrect for prose, off while the + // current token is a /command, @mention, or #channel so the + // keyboard doesn't fight exact tokens (or learn them). + .autocorrectionDisabled(appliedAutocorrectDisabled) #if os(iOS) - .textInputAutocapitalization(.sentences) + .textInputAutocapitalization(appliedAutocorrectDisabled ? .never : .sentences) #endif .submitLabel(.send) .modifier(AutocompleteKeyboardNavigationModifier( @@ -123,6 +132,13 @@ struct ContentComposerView: View { conversationUIModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) } } + scheduleAutocorrectTraitUpdate(for: newValue) + } + .onAppear { + appliedAutocorrectDisabled = ComposerAutocorrect.shouldDisable( + for: messageText, + cursorPosition: messageText.count + ) } HStack(alignment: .center, spacing: 4) { @@ -144,11 +160,33 @@ struct ContentComposerView: View { .themedChromePanel(edge: .bottom) .onDisappear { autocompleteDebounceTimer?.invalidate() + autocorrectTraitTimer?.invalidate() } } } private extension ContentComposerView { + /// Debounce trait flips so UIKit isn't asked to reload the keyboard on + /// every character while the user is still deciding the token. + func scheduleAutocorrectTraitUpdate(for text: String) { + let desired = ComposerAutocorrect.shouldDisable(for: text, cursorPosition: text.count) + guard desired != appliedAutocorrectDisabled else { + autocorrectTraitTimer?.invalidate() + return + } + autocorrectTraitTimer?.invalidate() + autocorrectTraitTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: false) { _ in + Task { @MainActor in + let latest = ComposerAutocorrect.shouldDisable( + for: messageText, + cursorPosition: messageText.count + ) + guard latest != appliedAutocorrectDisabled else { return } + appliedAutocorrectDisabled = latest + } + } + } + /// The nearby-only scope toggle appears only where it means something: /// the public mesh channel with the bridge on. var showsNearbyOnlyToggle: Bool { diff --git a/bitchatTests/ComposerAutocorrectTests.swift b/bitchatTests/ComposerAutocorrectTests.swift new file mode 100644 index 00000000..a0ffb351 --- /dev/null +++ b/bitchatTests/ComposerAutocorrectTests.swift @@ -0,0 +1,33 @@ +import Testing +@testable import bitchat + +struct ComposerAutocorrectTests { + @Test func emptyAndProseKeepAutocorrectOn() { + #expect(!ComposerAutocorrect.shouldDisable(for: "", cursorPosition: 0)) + #expect(!ComposerAutocorrect.shouldDisable(for: "hello there", cursorPosition: 11)) + #expect(!ComposerAutocorrect.shouldDisable(for: "hello there", cursorPosition: 5)) + } + + @Test func commandMentionAndChannelTokensDisableAutocorrect() { + #expect(ComposerAutocorrect.shouldDisable(for: "/help", cursorPosition: 5)) + #expect(ComposerAutocorrect.shouldDisable(for: "/h", cursorPosition: 2)) + #expect(ComposerAutocorrect.shouldDisable(for: "@alice", cursorPosition: 6)) + #expect(ComposerAutocorrect.shouldDisable(for: "#u4pruy", cursorPosition: 7)) + #expect(ComposerAutocorrect.shouldDisable(for: "say @al", cursorPosition: 7)) + #expect(ComposerAutocorrect.shouldDisable(for: "go #u4", cursorPosition: 6)) + #expect(ComposerAutocorrect.shouldDisable(for: "run /bl", cursorPosition: 7)) + } + + @Test func finishedSpecialTokenFollowedBySpaceReenablesAutocorrect() { + // Caret after the space starts a new (empty) prose token. + #expect(!ComposerAutocorrect.shouldDisable(for: "@alice ", cursorPosition: 7)) + #expect(!ComposerAutocorrect.shouldDisable(for: "/help ", cursorPosition: 6)) + #expect(!ComposerAutocorrect.shouldDisable(for: "hi @alice more", cursorPosition: 14)) + } + + @Test func currentTokenSplitsOnWhitespace() { + #expect(ComposerAutocorrect.currentToken(in: "a @bo", cursorPosition: 5) == "@bo") + #expect(ComposerAutocorrect.currentToken(in: "/msg", cursorPosition: 4) == "/msg") + #expect(ComposerAutocorrect.currentToken(in: "hi ", cursorPosition: 3) == "") + } +}