mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Localize CommandProcessor: all 48 command strings, 30 locales (#1657)
* Add 26 code-referenced keys to the catalog + a test that closes the gap LocalizationCoverageTests validated the catalog but never the code: a String(localized:) whose key is missing from every catalog compiles fine and silently ships its English defaultValue to all 29 non-source locales. That blind spot let 26 keys go untranslated while CI stayed green: the entire notices/board composer (10), the private-media encryption warnings and delivery-failure reasons (9), both courier-header strings, two delivery states, two media failure reasons, and the [? people] channel row. - All 26 keys added with full 30-locale coverage. - New everyCodeReferencedKeyExistsInACatalog scans the source for literal String(localized:) keys and fails on any key absent from both catalogs. (The audit's original count of 17 was itself an undercount — its scan missed multi-line String( localized: calls; the test's regex does not.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Localize CommandProcessor: all 48 command strings, 30 locales CommandProcessor had zero String(localized:) calls — every command error, usage hint, and the entire /help reference was hardcoded English. The autocomplete panel (CommandInfo) is fully localized, so a non-English speaker got localized suggestions while typing and English the moment anything went wrong or they asked for help. - 47 literal sites converted to String(localized:)/String(format:) with positional specifiers where languages reorder (%1$@, %2$lld). - /trace's "hop"/"hops" pluralization split into path_one/path_many; the "you" chain label localized too. - helpText and groupUsage become computed so they resolve per-locale. - 49 command.* keys added with full 30-locale coverage (command syntax stays verbatim; only prose translates). - Copy fix from the audit: "blocked X. you will no longer receive messages from them" → "…no longer see their messages" (blocking filters at display time; packets still arrive and relay). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
eca147191c
commit
5c4b814c56
File diff suppressed because it is too large
Load Diff
@ -105,7 +105,7 @@ final class CommandProcessor {
|
||||
@MainActor
|
||||
func process(_ command: String) -> CommandResult {
|
||||
let parts = command.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard let cmd = parts.first else { return .error(message: "Invalid command") }
|
||||
guard let cmd = parts.first else { return .error(message: String(localized: "command.error.invalid", defaultValue: "invalid command", comment: "Error for an empty or unparseable slash command")) }
|
||||
let args = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||
@ -133,19 +133,19 @@ final class CommandProcessor {
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/group":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.groups_mesh_only", defaultValue: "groups are only for mesh peers in #mesh", comment: "Error when a group command is used outside the mesh channel")) }
|
||||
return handleGroup(args)
|
||||
case "/fav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.favorites_mesh_only", defaultValue: "favorites are only for mesh peers in #mesh", comment: "Error when a favorites command is used outside the mesh channel")) }
|
||||
return handleFavorite(args, add: true)
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.favorites_mesh_only", defaultValue: "favorites are only for mesh peers in #mesh", comment: "Error when a favorites command is used outside the mesh channel")) }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/ping":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.ping_mesh_only", defaultValue: "ping only works for mesh peers in #mesh", comment: "Error when /ping is used outside the mesh channel")) }
|
||||
return handlePing(args)
|
||||
case "/trace":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.trace_mesh_only", defaultValue: "trace only works for mesh peers in #mesh", comment: "Error when /trace is used outside the mesh channel")) }
|
||||
return handleTrace(args)
|
||||
case "/pay":
|
||||
return handlePay(args)
|
||||
@ -154,31 +154,16 @@ final class CommandProcessor {
|
||||
case "/help":
|
||||
return .success(message: Self.helpText)
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd) — type /help for commands")
|
||||
return .error(message: String(format: String(localized: "command.error.unknown", defaultValue: "unknown command: %@ — type /help for commands", comment: "Error for an unrecognized slash command; placeholder is the typed command"), locale: .current, String(cmd)))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/group create <name> — start an encrypted group
|
||||
/group invite @name · /group remove @name — manage members (creator)
|
||||
/group leave · /group list — leave or list your groups
|
||||
/ping @name — measure round-trip time (mesh only)
|
||||
/trace @name — estimated mesh path (mesh only)
|
||||
/pay <token> — send a cashu ecash token in this chat
|
||||
/drop <message> — pin a note to this place for 24h (needs location)
|
||||
/help — this list
|
||||
"""
|
||||
static var helpText: String {
|
||||
String(localized: "command.help.text", defaultValue: "commands:\n/msg @name [message] — start a private chat\n/who — list who's here\n/clear — clear this chat\n/hug @name — send a hug\n/slap @name — slap with a large trout\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorites (mesh only)\n/group create <name> — start an encrypted group\n/group invite @name · /group remove @name — manage members (creator)\n/group leave · /group list — leave or list your groups\n/ping @name — measure round-trip time (mesh only)\n/trace @name — estimated mesh path (mesh only)\n/pay <token> — send a cashu ecash token in this chat\n/drop <message> — pin a note to this place for 24h (needs location)\n/help — this list", comment: "The /help reference list; command syntax stays verbatim, only the descriptions after each dash are translated")
|
||||
}
|
||||
|
||||
/// /drop <text> — a dead drop: pins a note to the current building-level
|
||||
/// geohash with a 24h NIP-40 expiry. Anyone who passes through here and
|
||||
@ -186,28 +171,28 @@ final class CommandProcessor {
|
||||
/// reads it.
|
||||
private func handleDrop(_ args: String) -> CommandResult {
|
||||
guard LocationNotesSettings.enabled else {
|
||||
return .error(message: "location notes are off — enable them in the info screen")
|
||||
return .error(message: String(localized: "command.drop.notes_off", defaultValue: "location notes are off — enable them in the info screen", comment: "Error when /drop is used while location notes are disabled"))
|
||||
}
|
||||
guard let content = args.trimmedOrNilIfEmpty else {
|
||||
return .error(message: "usage: /drop <message>")
|
||||
return .error(message: String(localized: "command.drop.usage", defaultValue: "usage: /drop <message>", comment: "Usage hint for /drop"))
|
||||
}
|
||||
let location = LocationChannelManager.shared
|
||||
guard location.permissionState == .authorized else {
|
||||
return .error(message: "leaving a note needs location — enable it in the info screen")
|
||||
return .error(message: String(localized: "command.drop.needs_location", defaultValue: "leaving a note needs location — enable it in the info screen", comment: "Error when /drop is used without location permission"))
|
||||
}
|
||||
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
|
||||
location.refreshChannels()
|
||||
return .error(message: "still finding this place — try again in a moment")
|
||||
return .error(message: String(localized: "command.drop.finding_place", defaultValue: "still finding this place — try again in a moment", comment: "Error when /drop runs before a location fix arrives"))
|
||||
}
|
||||
guard let nickname = contextProvider?.nickname,
|
||||
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
|
||||
return .error(message: "no geo relays reachable — note not left")
|
||||
return .error(message: String(localized: "command.drop.no_relays", defaultValue: "no geo relays reachable — note not left", comment: "Error when /drop finds no reachable geo relays"))
|
||||
}
|
||||
// Leaving a note is an explicit notes act: it unlocks the passive
|
||||
// nearby-notes counter (tap-to-reveal) so the sender sees their own
|
||||
// drop counted on the timeline.
|
||||
NearbyNotesCounter.shared.reveal()
|
||||
return .success(message: "📍 note left here — it fades in 24h")
|
||||
return .success(message: String(localized: "command.drop.left", defaultValue: "📍 note left here — it fades in 24h", comment: "Confirmation after /drop pins a note"))
|
||||
}
|
||||
|
||||
// MARK: - Command Handlers
|
||||
@ -215,14 +200,14 @@ final class CommandProcessor {
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard !parts.isEmpty else {
|
||||
return .error(message: "usage: /msg @nickname [message]")
|
||||
return .error(message: String(localized: "command.msg.usage", defaultValue: "usage: /msg @nickname [message]", comment: "Usage hint for /msg"))
|
||||
}
|
||||
|
||||
let targetName = String(parts[0])
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "'\(nickname)' not found")
|
||||
return .error(message: String(format: String(localized: "command.msg.not_found", defaultValue: "'%@' not found", comment: "Error when /msg can't resolve the nickname"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
contextProvider?.startPrivateChat(with: peerID)
|
||||
@ -232,7 +217,7 @@ final class CommandProcessor {
|
||||
contextProvider?.sendPrivateMessage(message, to: peerID)
|
||||
}
|
||||
|
||||
return .success(message: "started private chat with \(nickname)")
|
||||
return .success(message: String(format: String(localized: "command.msg.started", defaultValue: "started private chat with %@", comment: "Confirmation after /msg opens a private chat"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
private func handleWho() -> CommandResult {
|
||||
@ -240,22 +225,22 @@ final class CommandProcessor {
|
||||
switch contextProvider?.activeChannel ?? .mesh {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||
guard let vm = contextProvider else { return .success(message: String(localized: "command.who.nobody", defaultValue: "nobody around", comment: "Reply to /who when no context is available")) }
|
||||
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.getVisibleGeoParticipants().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
}
|
||||
let names = people.map { $0.displayName }
|
||||
if names.isEmpty { return .success(message: "no one else is online right now") }
|
||||
return .success(message: "online: " + names.sorted().joined(separator: ", "))
|
||||
if names.isEmpty { return .success(message: String(localized: "command.who.none_online", defaultValue: "no one else is online right now", comment: "Reply to /who when nobody else is online")) }
|
||||
return .success(message: String(format: String(localized: "command.who.online", defaultValue: "online: %@", comment: "Reply to /who; placeholder is the list of names"), locale: .current, names.sorted().joined(separator: ", ")))
|
||||
case .mesh:
|
||||
// Mesh context: show connected peer nicknames
|
||||
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||
return .success(message: "no one else is online right now")
|
||||
return .success(message: String(localized: "command.who.none_online", defaultValue: "no one else is online right now", comment: "Reply to /who when nobody else is online"))
|
||||
}
|
||||
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||
return .success(message: "online: \(onlineList)")
|
||||
return .success(message: String(format: String(localized: "command.who.online", defaultValue: "online: %@", comment: "Reply to /who; placeholder is the list of names"), locale: .current, onlineList))
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,14 +256,14 @@ final class CommandProcessor {
|
||||
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(command) <nickname>")
|
||||
return .error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ <nickname>", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, command))
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let myNickname = contextProvider?.nickname else {
|
||||
return .error(message: "cannot \(command) \(nickname): not found")
|
||||
return .error(message: String(format: String(localized: "command.action.not_found", defaultValue: "cannot %1$@ %2$@: not found", comment: "Error when an action command can't resolve its target; placeholders are the command and the nickname"), locale: .current, command, nickname))
|
||||
}
|
||||
|
||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||
@ -345,7 +330,7 @@ final class CommandProcessor {
|
||||
|
||||
let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ")
|
||||
let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ")
|
||||
return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)")
|
||||
return .success(message: String(format: String(localized: "command.block.list", defaultValue: "blocked peers: %1$@ | geohash blocks: %2$@", comment: "Reply to /block with no argument; placeholders are the mesh and geohash block lists"), locale: .current, meshList, geoList))
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
@ -353,7 +338,7 @@ final class CommandProcessor {
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname))
|
||||
}
|
||||
// Block the user (mesh/noise identity)
|
||||
if var identity = identityManager.getSocialIdentity(for: fingerprint) {
|
||||
@ -375,24 +360,24 @@ final class CommandProcessor {
|
||||
// Scrub their carried public messages now, while the peerID is
|
||||
// resolvable, so they can't resurface as archived echoes.
|
||||
meshArchive?.purgeArchivedPublicMessages(from: peerID)
|
||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||
return .success(message: String(format: String(localized: "command.block.done", defaultValue: "blocked %@. you will no longer see their messages", comment: "Confirmation after blocking a mesh peer"), locale: .current, nickname))
|
||||
}
|
||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname))
|
||||
}
|
||||
identityManager.setNostrBlocked(pub, isBlocked: true)
|
||||
return .success(message: "blocked \(nickname) in geohash chats")
|
||||
return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in geohash chats", comment: "Confirmation after blocking a geohash participant"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
||||
return .error(message: String(format: String(localized: "command.block.failed", defaultValue: "cannot block %@: not found or unable to verify identity", comment: "Error when /block can't resolve or verify the target"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
private func handleUnblock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /unblock <nickname>")
|
||||
return .error(message: String(localized: "command.unblock.usage", defaultValue: "usage: /unblock <nickname>", comment: "Usage hint for /unblock"))
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
@ -400,23 +385,23 @@ final class CommandProcessor {
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname))
|
||||
}
|
||||
identityManager.setBlocked(fingerprint, isBlocked: false)
|
||||
return .success(message: "unblocked \(nickname)")
|
||||
return .success(message: String(format: String(localized: "command.unblock.done", defaultValue: "unblocked %@", comment: "Confirmation after unblocking a mesh peer"), locale: .current, nickname))
|
||||
}
|
||||
// Try geohash unblock
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname))
|
||||
}
|
||||
identityManager.setNostrBlocked(pub, isBlocked: false)
|
||||
return .success(message: "unblocked \(nickname) in geohash chats")
|
||||
return .success(message: String(format: String(localized: "command.unblock.done_geo", defaultValue: "unblocked %@ in geohash chats", comment: "Confirmation after unblocking a geohash participant"), locale: .current, nickname))
|
||||
}
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
return .error(message: String(format: String(localized: "command.unblock.failed", defaultValue: "cannot unblock %@: not found", comment: "Error when /unblock can't resolve the target"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
|
||||
private static var groupUsage: String { String(localized: "command.group.usage", defaultValue: "usage: /group create <name> · invite @name · remove @name · leave · list", comment: "Usage hint for /group subcommands") }
|
||||
|
||||
private func handleGroup(_ args: String) -> CommandResult {
|
||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
@ -454,12 +439,12 @@ final class CommandProcessor {
|
||||
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .failed(.error(message: "usage: /\(command) <nickname>"))
|
||||
return .failed(.error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ <nickname>", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, command)))
|
||||
}
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
!peerID.isGeoDM, !peerID.isGeoChat else {
|
||||
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
|
||||
return .failed(.error(message: String(format: String(localized: "command.action.not_found_mesh", defaultValue: "cannot %1$@ %2$@: not found on mesh", comment: "Error when a mesh-only command can't resolve its target; placeholders are the command and the nickname"), locale: .current, command, nickname)))
|
||||
}
|
||||
return .resolved(peerID: peerID, nickname: nickname)
|
||||
}
|
||||
@ -488,7 +473,7 @@ final class CommandProcessor {
|
||||
} ?? ""
|
||||
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
|
||||
}
|
||||
return .success(message: "pinging \(nickname)…")
|
||||
return .success(message: String(format: String(localized: "command.ping.started", defaultValue: "pinging %@…", comment: "Confirmation that /ping sent a probe"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
private func handleTrace(_ args: String) -> CommandResult {
|
||||
@ -500,16 +485,20 @@ final class CommandProcessor {
|
||||
|
||||
guard let mesh = meshService,
|
||||
let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else {
|
||||
return .success(message: "no known path to \(target.nickname)")
|
||||
return .success(message: String(format: String(localized: "command.trace.no_path", defaultValue: "no known path to %@", comment: "Reply when /trace has no mesh path to the target"), locale: .current, target.nickname))
|
||||
}
|
||||
// Graph-derived from gossiped neighbor claims, not route-recorded —
|
||||
// present it as an estimate.
|
||||
let hopNames = intermediates.map { hop in
|
||||
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…"
|
||||
}
|
||||
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ")
|
||||
let you = String(localized: "command.trace.you", defaultValue: "you", comment: "Label for the local device at the start of a /trace path")
|
||||
let chain = ([you] + hopNames + [target.nickname]).joined(separator: " → ")
|
||||
let hops = intermediates.count + 1
|
||||
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
|
||||
let pathMessage = hops == 1
|
||||
? String(format: String(localized: "command.trace.path_one", defaultValue: "estimated path: %@ (1 hop)", comment: "Reply to /trace for a single-hop path; placeholder is the node chain"), locale: .current, chain)
|
||||
: String(format: String(localized: "command.trace.path_many", defaultValue: "estimated path: %1$@ (%2$lld hops)", comment: "Reply to /trace; placeholders are the node chain and hop count"), locale: .current, chain, hops)
|
||||
return .success(message: pathMessage)
|
||||
}
|
||||
|
||||
/// `/pay <cashu-token>` — validates the token decodes, then sends it as
|
||||
@ -520,44 +509,44 @@ final class CommandProcessor {
|
||||
private func handlePay(_ args: String) -> CommandResult {
|
||||
var parts = args.trimmed.split(separator: " ").map(String.init)
|
||||
guard !parts.isEmpty else {
|
||||
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
|
||||
return .success(message: String(localized: "command.pay.usage", defaultValue: "usage: /pay <token> — paste a cashu token: /pay cashuA…", comment: "Usage hint for /pay"))
|
||||
}
|
||||
|
||||
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
|
||||
if confirmedPublic { parts.removeLast() }
|
||||
|
||||
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
|
||||
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
|
||||
return .error(message: String(localized: "command.pay.not_token", defaultValue: "that doesn't look like a cashu token — expected cashuA… or cashuB…", comment: "Error when /pay input has no cashu prefix"))
|
||||
}
|
||||
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
|
||||
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
|
||||
return .error(message: String(localized: "command.pay.invalid", defaultValue: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it", comment: "Error when /pay input fails to decode"))
|
||||
}
|
||||
|
||||
let summary = info.displayAmount ?? "a cashu token"
|
||||
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.sendPrivateMessage(token, to: peerID)
|
||||
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
|
||||
return .success(message: String(format: String(localized: "command.pay.sent_private", defaultValue: "sent %@ — cashu is a bearer token; whoever redeems it first gets the funds", comment: "Confirmation after sending a cashu token in a private chat; placeholder is the amount summary"), locale: .current, summary))
|
||||
}
|
||||
|
||||
guard confirmedPublic else {
|
||||
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
|
||||
return .error(message: String(localized: "command.pay.public_confirm", defaultValue: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public", comment: "Confirmation gate before sending a cashu token to a public channel"))
|
||||
}
|
||||
|
||||
contextProvider?.sendPublicMessage(token)
|
||||
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
|
||||
return .success(message: String(format: String(localized: "command.pay.sent_public", defaultValue: "sent %@ to the public channel — anyone here can redeem it", comment: "Confirmation after sending a cashu token to a public channel; placeholder is the amount summary"), locale: .current, summary))
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
return .error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ <nickname>", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, (add ? "fav" : "unfav")))
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
return .error(message: String(format: String(localized: "command.fav.not_found", defaultValue: "can't find peer: %@", comment: "Error when /fav or /unfav can't resolve the nickname"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
// Resolve current state by the peer's real noise key. The resolved
|
||||
@ -571,13 +560,17 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
guard add != isCurrentlyFavorite else {
|
||||
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
|
||||
return .success(message: add
|
||||
? String(format: String(localized: "command.fav.already", defaultValue: "%@ is already a favorite", comment: "Reply when /fav targets an existing favorite"), locale: .current, nickname)
|
||||
: String(format: String(localized: "command.fav.not_favorite", defaultValue: "%@ is not a favorite", comment: "Reply when /unfav targets someone who isn't a favorite"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
// toggleFavorite persists by the real noise key and notifies the peer.
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
|
||||
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
||||
return .success(message: add
|
||||
? String(format: String(localized: "command.fav.added", defaultValue: "added %@ to favorites", comment: "Confirmation after /fav"), locale: .current, nickname)
|
||||
: String(format: String(localized: "command.fav.removed", defaultValue: "removed %@ from favorites", comment: "Confirmation after /unfav"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -255,7 +255,9 @@ struct CommandProcessorTests {
|
||||
}
|
||||
switch blockResult {
|
||||
case .success(let message):
|
||||
#expect(message == "blocked bob. you will no longer receive messages from them")
|
||||
// "no longer see" — blocking filters at display time; packets still
|
||||
// arrive and relay, so "receive" overpromised (UX audit fix).
|
||||
#expect(message == "blocked bob. you will no longer see their messages")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user