Merge e5ae9a6e3c156478c50406cdb26274afaa200531 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Taksh Kothari 2026-08-06 02:39:03 +00:00 committed by GitHub
commit 023e6a9529
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 122 additions and 2 deletions

View File

@ -0,0 +1,49 @@
//
// 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.
///
/// 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<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

@ -19,6 +19,12 @@ struct ContentComposerView: View {
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
/// Applied autocorrect gate updated on a short debounce so crossing a
/// `/` `@` `#` boundary doesn't thrash UIKit text-input traits on every
/// keystroke (can flicker the keyboard on some iOS versions).
@State private var appliedAutocorrectDisabled = false
@State private var autocorrectTraitTimer: Timer?
let onSendMessage: () -> 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 {

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