mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
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:
parent
4e49a4be86
commit
22386a42b8
45
bitchat/Utils/ComposerAutocorrect.swift
Normal file
45
bitchat/Utils/ComposerAutocorrect.swift
Normal 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...])
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
33
bitchatTests/ComposerAutocorrectTests.swift
Normal file
33
bitchatTests/ComposerAutocorrectTests.swift
Normal 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) == "")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user