mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +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" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -11,15 +11,19 @@ import Foundation
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
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 clear
|
||||
case help
|
||||
case hug
|
||||
case message = "dm"
|
||||
case message = "msg"
|
||||
case slap
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
case favorite = "fav"
|
||||
case unfavorite = "unfav"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -29,7 +33,7 @@ enum CommandInfo: String, Identifiable {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .clear, .who:
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -38,6 +42,7 @@ enum CommandInfo: String, Identifiable {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
@@ -49,10 +54,12 @@ enum CommandInfo: String, Identifiable {
|
||||
}
|
||||
|
||||
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 {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
return baseCommands
|
||||
}
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,28 @@ final class CommandProcessor {
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/help":
|
||||
return .success(message: Self.helpText)
|
||||
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
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
|
||||
@@ -14,11 +14,25 @@ struct CommandSuggestionsView: View {
|
||||
|
||||
@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] {
|
||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
||||
guard messageText.hasPrefix("/") else { return [] }
|
||||
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -28,9 +42,13 @@ struct CommandSuggestionsView: View {
|
||||
// still receive the composer VStack's spacing and push the input row
|
||||
// off-center.
|
||||
if !filteredCommands.isEmpty {
|
||||
let isUsageReminder = typedCommandAlias != nil
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(filteredCommands) { command in
|
||||
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 + " "
|
||||
} label: {
|
||||
buttonRow(for: command)
|
||||
|
||||
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
|
||||
struct ProtocolContractTests {
|
||||
@Test
|
||||
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
|
||||
#expect(CommandInfo.message.id == "dm")
|
||||
#expect(CommandInfo.message.alias == "/dm")
|
||||
// Aliases must match what CommandProcessor actually accepts —
|
||||
// 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.clear.placeholder == nil)
|
||||
#expect(CommandInfo.favorite.description.isEmpty == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
|
||||
// Favorites are rejected by the processor in geohash contexts, so
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user