mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:25:19 +00:00
Fix slash-command suggestions that insert commands the processor rejects (#1354)
The autocomplete panel is the only in-app surface for discovering slash
commands, but several suggestions do not match what CommandProcessor
accepts, so tapping them inserts a command that returns "unknown
command":
- CommandInfo suggests /dm, /favorite, /unfavorite, but the processor
only handles /m, /msg, /fav, /unfav. Aliases are aligned to the
accepted spellings (msg, fav, unfav).
- Favorites are suggested only in geohash contexts (isGeoPublic ||
isGeoDM) — exactly where the processor rejects them ("favorites are
only for mesh peers"). The gating is inverted so they appear in mesh,
where they work.
Also, small related fixes to the discovery surface:
- /help is now handled (the ChatViewModel command docstring already
claimed it existed); it prints a local system line listing the valid
commands, and the unknown-command error points at it.
- The suggestion panel keeps the matched command's usage row (e.g.
"/msg <nickname>") visible while arguments are typed, instead of
vanishing at the first space; in that mode the row is informational
and no longer overwrites the draft on tap.
New string is added source-language (en) only. The CommandInfo contract
test is updated to the corrected metadata.
This commit is contained in:
@@ -14707,6 +14707,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"content.commands.help" : {
|
||||||
|
"comment" : "Description of the /help command in the suggestions panel",
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "show available commands"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"content.commands.hug" : {
|
"content.commands.hug" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -11,33 +11,38 @@ import Foundation
|
|||||||
// MARK: - CommandInfo Enum
|
// MARK: - CommandInfo Enum
|
||||||
|
|
||||||
enum CommandInfo: String, Identifiable {
|
enum CommandInfo: String, Identifiable {
|
||||||
|
// Raw values must match the aliases CommandProcessor actually accepts —
|
||||||
|
// the suggestion panel is the app's only command-discovery surface, and
|
||||||
|
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||||
case block
|
case block
|
||||||
case clear
|
case clear
|
||||||
|
case help
|
||||||
case hug
|
case hug
|
||||||
case message = "dm"
|
case message = "msg"
|
||||||
case slap
|
case slap
|
||||||
case unblock
|
case unblock
|
||||||
case who
|
case who
|
||||||
case favorite
|
case favorite = "fav"
|
||||||
case unfavorite
|
case unfavorite = "unfav"
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var alias: String { "/" + rawValue }
|
var alias: String { "/" + rawValue }
|
||||||
|
|
||||||
var placeholder: String? {
|
var placeholder: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||||
case .clear, .who:
|
case .clear, .help, .who:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .block: String(localized: "content.commands.block")
|
case .block: String(localized: "content.commands.block")
|
||||||
case .clear: String(localized: "content.commands.clear")
|
case .clear: String(localized: "content.commands.clear")
|
||||||
|
case .help: String(localized: "content.commands.help")
|
||||||
case .hug: String(localized: "content.commands.hug")
|
case .hug: String(localized: "content.commands.hug")
|
||||||
case .message: String(localized: "content.commands.message")
|
case .message: String(localized: "content.commands.message")
|
||||||
case .slap: String(localized: "content.commands.slap")
|
case .slap: String(localized: "content.commands.slap")
|
||||||
@@ -47,12 +52,14 @@ enum CommandInfo: String, Identifiable {
|
|||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||||
|
// The processor rejects favorites in geohash contexts, so only
|
||||||
|
// suggest them where they actually work: mesh.
|
||||||
if isGeoPublic || isGeoDM {
|
if isGeoPublic || isGeoDM {
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
return baseCommands
|
||||||
}
|
}
|
||||||
return baseCommands
|
return baseCommands + [.favorite, .unfavorite]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,11 +105,28 @@ final class CommandProcessor {
|
|||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
|
case "/help":
|
||||||
|
return .success(message: Self.helpText)
|
||||||
default:
|
default:
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return .error(message: "unknown command: \(cmd) — type /help for commands")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local-only command reference, printed as a system message. The
|
||||||
|
/// suggestion panel hides once arguments are typed, and typos used to
|
||||||
|
/// dead-end in a bare "unknown command" — this is the way out.
|
||||||
|
static let helpText = """
|
||||||
|
commands:
|
||||||
|
/msg @name [message] — start a private chat
|
||||||
|
/who — list who's here
|
||||||
|
/clear — clear this chat
|
||||||
|
/hug @name — send a hug
|
||||||
|
/slap @name — slap with a large trout
|
||||||
|
/block @name · /unblock @name
|
||||||
|
/fav @name · /unfav @name — favorites (mesh only)
|
||||||
|
/help — this list
|
||||||
|
"""
|
||||||
|
|
||||||
// MARK: - Command Handlers
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
private func handleMessage(_ args: String) -> CommandResult {
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
|
|||||||
@@ -14,23 +14,41 @@ struct CommandSuggestionsView: View {
|
|||||||
|
|
||||||
@Binding var messageText: String
|
@Binding var messageText: String
|
||||||
|
|
||||||
|
/// The command already typed in full, once arguments have begun.
|
||||||
|
private var typedCommandAlias: String? {
|
||||||
|
guard messageText.hasPrefix("/"),
|
||||||
|
let spaceIndex = messageText.firstIndex(of: " ")
|
||||||
|
else { return nil }
|
||||||
|
return String(messageText[..<spaceIndex]).lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
private var filteredCommands: [CommandInfo] {
|
private var filteredCommands: [CommandInfo] {
|
||||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
guard messageText.hasPrefix("/") else { return [] }
|
||||||
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
||||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
|
||||||
|
// While arguments are being typed, keep the matched command's usage
|
||||||
|
// row visible instead of vanishing at the first space.
|
||||||
|
if let typed = typedCommandAlias {
|
||||||
|
return commands.filter { $0.alias == typed && $0.placeholder != nil }
|
||||||
|
}
|
||||||
|
return commands.filter { command in
|
||||||
command.alias.starts(with: messageText.lowercased())
|
command.alias.starts(with: messageText.lowercased())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Render nothing when there are no matches: a zero-height view would
|
// Render nothing when there are no matches: a zero-height view would
|
||||||
// still receive the composer VStack's spacing and push the input row
|
// still receive the composer VStack's spacing and push the input row
|
||||||
// off-center.
|
// off-center.
|
||||||
if !filteredCommands.isEmpty {
|
if !filteredCommands.isEmpty {
|
||||||
|
let isUsageReminder = typedCommandAlias != nil
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(filteredCommands) { command in
|
ForEach(filteredCommands) { command in
|
||||||
Button {
|
Button {
|
||||||
|
// In usage-reminder mode the row is informational; an
|
||||||
|
// insert here would wipe the arguments being typed.
|
||||||
|
guard !isUsageReminder else { return }
|
||||||
messageText = command.alias + " "
|
messageText = command.alias + " "
|
||||||
} label: {
|
} label: {
|
||||||
buttonRow(for: command)
|
buttonRow(for: command)
|
||||||
|
|||||||
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
|
|||||||
struct ProtocolContractTests {
|
struct ProtocolContractTests {
|
||||||
@Test
|
@Test
|
||||||
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
|
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
|
||||||
#expect(CommandInfo.message.id == "dm")
|
// Aliases must match what CommandProcessor actually accepts —
|
||||||
#expect(CommandInfo.message.alias == "/dm")
|
// the suggestion panel is the only command-discovery surface.
|
||||||
|
#expect(CommandInfo.message.id == "msg")
|
||||||
|
#expect(CommandInfo.message.alias == "/msg")
|
||||||
#expect(CommandInfo.message.placeholder != nil)
|
#expect(CommandInfo.message.placeholder != nil)
|
||||||
#expect(CommandInfo.clear.placeholder == nil)
|
#expect(CommandInfo.clear.placeholder == nil)
|
||||||
#expect(CommandInfo.favorite.description.isEmpty == false)
|
#expect(CommandInfo.favorite.description.isEmpty == false)
|
||||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
|
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
|
||||||
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
|
// Favorites are rejected by the processor in geohash contexts, so
|
||||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
|
// they are suggested only in mesh.
|
||||||
|
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite))
|
||||||
|
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false)
|
||||||
|
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user