fix: make composer autocorrect token-aware

Enable autocorrect for prose, but disable it while the current token
starts with /, @, or # so commands, mentions, and channels stay exact
and aren't fed into keyboard learning.
This commit is contained in:
Taksh 2026-07-29 11:43:15 +03:00
parent 4e49a4be86
commit 22386a42b8
3 changed files with 89 additions and 3 deletions

View File

@ -0,0 +1,45 @@
//
// ComposerAutocorrect.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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.
enum ComposerAutocorrect {
/// Sigils that mean "exact token, don't rewrite me".
static let specialPrefixes: Set<Character> = ["/", "@", "#"]
/// 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[..<end]
let tokenStart: String.Index
if let ws = before.lastIndex(where: { $0.isWhitespace || $0.isNewline }) {
tokenStart = before.index(after: ws)
} else {
tokenStart = text.startIndex
}
return String(before[tokenStart...])
}
}

View File

@ -76,10 +76,12 @@ struct ContentComposerView: View {
.bitchatFont(size: 15)
.foregroundColor(palette.primary)
.focused(isTextFieldFocused)
// Autocorrect left enabled for chat typing (#969). Nicknames
// and geohashes keep `.autocorrectionDisabled(true)` elsewhere.
// 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(shouldDisableAutocorrect)
#if os(iOS)
.textInputAutocapitalization(.sentences)
.textInputAutocapitalization(shouldDisableAutocorrect ? .never : .sentences)
#endif
.submitLabel(.send)
.modifier(AutocompleteKeyboardNavigationModifier(
@ -150,6 +152,12 @@ struct ContentComposerView: View {
}
private extension ContentComposerView {
/// Mirror autocomplete's end-of-string caret: SwiftUI's TextField doesn't
/// expose selection, and suggestions already assume the caret is at the end.
var shouldDisableAutocorrect: Bool {
ComposerAutocorrect.shouldDisable(for: messageText, cursorPosition: messageText.count)
}
/// The nearby-only scope toggle appears only where it means something:
/// the public mesh channel with the bridge on.
var showsNearbyOnlyToggle: Bool {

View File

@ -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) == "")
}
}