mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Merge e5ae9a6e3c156478c50406cdb26274afaa200531 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
023e6a9529
49
bitchat/Utils/ComposerAutocorrect.swift
Normal file
49
bitchat/Utils/ComposerAutocorrect.swift
Normal 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...])
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
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