Skip to content

Commit 97fc21c

Browse files
Add localizable strings in show commands view (permissionlesstech#828)
* Add localizable strings in show commands view n refactor code to remove duplicate string declarations. * Modify struct to enum in separate file to Models/CommandsInfo. * Add corrections code in refactory and enum. * Correct code in command enum and ContentView. * Dedicated CommandSuggestionsView to extract code * Simplify CommandInfo + minor optimization * Fix preview --------- Co-authored-by: islam <2553451+qalandarov@users.noreply.github.com>
1 parent 79bd4af commit 97fc21c

4 files changed

Lines changed: 169 additions & 131 deletions

File tree

bitchat/Models/CommandInfo.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//
2+
// CommandsInfo.swift
3+
// bitchat
4+
//
5+
// This is free and unencumbered software released into the public domain.
6+
// For more information, see <https://unlicense.org>
7+
//
8+
9+
import Foundation
10+
11+
// MARK: - CommandInfo Enum
12+
13+
enum CommandInfo: String, Identifiable {
14+
case block
15+
case clear
16+
case hug
17+
case message = "dm"
18+
case slap
19+
case unblock
20+
case who
21+
case favorite
22+
case unfavorite
23+
24+
var id: String { rawValue }
25+
26+
var alias: String { "/" + rawValue }
27+
28+
var placeholder: String? {
29+
switch self {
30+
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
31+
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
32+
case .clear, .who:
33+
return nil
34+
}
35+
}
36+
37+
var description: String {
38+
switch self {
39+
case .block: String(localized: "content.commands.block")
40+
case .clear: String(localized: "content.commands.clear")
41+
case .hug: String(localized: "content.commands.hug")
42+
case .message: String(localized: "content.commands.message")
43+
case .slap: String(localized: "content.commands.slap")
44+
case .unblock: String(localized: "content.commands.unblock")
45+
case .who: String(localized: "content.commands.who")
46+
case .favorite: String(localized: "content.commands.favorite")
47+
case .unfavorite: String(localized: "content.commands.unfavorite")
48+
}
49+
}
50+
51+
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
52+
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
53+
if isGeoPublic || isGeoDM {
54+
return baseCommands + [.favorite, .unfavorite]
55+
}
56+
return baseCommands
57+
}
58+
}

bitchat/Protocols/LocationChannel.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,18 @@ enum ChannelID: Equatable, Codable {
116116
case .location(let ch): return ch.geohash
117117
}
118118
}
119+
120+
var isMesh: Bool {
121+
switch self {
122+
case .mesh: true
123+
case .location: false
124+
}
125+
}
126+
127+
var isLocation: Bool {
128+
switch self {
129+
case .mesh: false
130+
case .location: true
131+
}
132+
}
119133
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// CommandSuggestionsView.swift
3+
// bitchat
4+
//
5+
// Created by Islam on 29/10/2025.
6+
//
7+
8+
import SwiftUI
9+
10+
struct CommandSuggestionsView: View {
11+
@EnvironmentObject private var viewModel: ChatViewModel
12+
@ObservedObject private var locationManager = LocationChannelManager.shared
13+
14+
@Binding var messageText: String
15+
16+
let textColor: Color
17+
let backgroundColor: Color
18+
let secondaryTextColor: Color
19+
20+
private var filteredCommands: [CommandInfo] {
21+
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
22+
let isGeoPublic = locationManager.selectedChannel.isLocation
23+
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
24+
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
25+
command.alias.starts(with: messageText.lowercased())
26+
}
27+
}
28+
29+
var body: some View {
30+
VStack(alignment: .leading, spacing: 0) {
31+
ForEach(filteredCommands) { command in
32+
Button {
33+
messageText = command.alias + " "
34+
} label: {
35+
buttonRow(for: command)
36+
}
37+
.buttonStyle(.plain)
38+
.background(Color.gray.opacity(0.1))
39+
}
40+
}
41+
.background(backgroundColor)
42+
.overlay(
43+
RoundedRectangle(cornerRadius: 4)
44+
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
45+
)
46+
}
47+
48+
private func buttonRow(for command: CommandInfo) -> some View {
49+
HStack {
50+
Text(command.alias)
51+
.font(.bitchatSystem(size: 11, design: .monospaced))
52+
.foregroundColor(textColor)
53+
.fontWeight(.medium)
54+
55+
if let placeholder = command.placeholder {
56+
Text(placeholder)
57+
.font(.bitchatSystem(size: 10, design: .monospaced))
58+
.foregroundColor(secondaryTextColor.opacity(0.8))
59+
}
60+
61+
Spacer()
62+
63+
Text(command.description)
64+
.font(.bitchatSystem(size: 10, design: .monospaced))
65+
.foregroundColor(secondaryTextColor)
66+
}
67+
.padding(.horizontal, 12)
68+
.padding(.vertical, 3)
69+
.frame(maxWidth: .infinity, alignment: .leading)
70+
}
71+
}
72+
73+
@available(iOS 17, macOS 14, *)
74+
#Preview {
75+
@Previewable @State var messageText: String = "/"
76+
let keychain = KeychainManager()
77+
let viewModel = ChatViewModel(
78+
keychain: keychain,
79+
idBridge: NostrIdentityBridge(),
80+
identityManager: SecureIdentityStateManager(keychain)
81+
)
82+
83+
CommandSuggestionsView(
84+
messageText: $messageText,
85+
textColor: .green,
86+
backgroundColor: .primary,
87+
secondaryTextColor: .secondary
88+
)
89+
.environmentObject(viewModel)
90+
}

bitchat/Views/ContentView.swift

Lines changed: 7 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ struct ContentView: View {
4343
@State private var showPeerList = false
4444
@State private var showSidebar = false
4545
@State private var showAppInfo = false
46-
@State private var showCommandSuggestions = false
47-
@State private var commandSuggestions: [String] = []
4846
@State private var showMessageActions = false
4947
@State private var selectedMessageSender: String?
5048
@State private var selectedMessageSenderID: PeerID?
@@ -605,77 +603,12 @@ struct ContentView: View {
605603
.padding(.horizontal, 12)
606604
}
607605

608-
// Command suggestions
609-
if showCommandSuggestions && !commandSuggestions.isEmpty {
610-
VStack(alignment: .leading, spacing: 0) {
611-
// Define commands with aliases and syntax
612-
let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
613-
(["/block"], "[nickname]", "block or list blocked peers"),
614-
(["/clear"], nil, "clear chat messages"),
615-
(["/hug"], "<nickname>", "send someone a warm hug"),
616-
(["/m", "/msg"], "<nickname> [message]", "send private message"),
617-
(["/slap"], "<nickname>", "slap someone with a trout"),
618-
(["/unblock"], "<nickname>", "unblock a peer"),
619-
(["/w"], nil, "see who's online")
620-
]
621-
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
622-
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
623-
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
624-
(["/fav"], "<nickname>", "add to favorites"),
625-
(["/unfav"], "<nickname>", "remove from favorites")
626-
]
627-
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
628-
629-
// Build the display
630-
let allCommands = commandInfo
631-
632-
// Show matching commands
633-
ForEach(commandSuggestions, id: \.self) { command in
634-
// Find the command info for this suggestion
635-
if let info = allCommands.first(where: { $0.commands.contains(command) }) {
636-
Button(action: {
637-
// Replace current text with selected command
638-
messageText = command + " "
639-
showCommandSuggestions = false
640-
commandSuggestions = []
641-
}) {
642-
HStack {
643-
// Show all aliases together
644-
Text(info.commands.joined(separator: ", "))
645-
.font(.bitchatSystem(size: 11, design: .monospaced))
646-
.foregroundColor(textColor)
647-
.fontWeight(.medium)
648-
649-
// Show syntax if any
650-
if let syntax = info.syntax {
651-
Text(syntax)
652-
.font(.bitchatSystem(size: 10, design: .monospaced))
653-
.foregroundColor(secondaryTextColor.opacity(0.8))
654-
}
655-
656-
Spacer()
657-
658-
// Show description
659-
Text(info.description)
660-
.font(.bitchatSystem(size: 10, design: .monospaced))
661-
.foregroundColor(secondaryTextColor)
662-
}
663-
.padding(.horizontal, 12)
664-
.padding(.vertical, 3)
665-
.frame(maxWidth: .infinity, alignment: .leading)
666-
}
667-
.buttonStyle(.plain)
668-
.background(Color.gray.opacity(0.1))
669-
}
670-
}
671-
}
672-
.background(backgroundColor)
673-
.overlay(
674-
RoundedRectangle(cornerRadius: 4)
675-
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
676-
)
677-
.padding(.horizontal, 12)
678-
}
606+
CommandSuggestionsView(
607+
messageText: $messageText,
608+
textColor: textColor,
609+
backgroundColor: backgroundColor,
610+
secondaryTextColor: secondaryTextColor
611+
)
679612

680613
// Recording indicator
681614
if isPreparingVoiceNote || isRecordingVoiceNote {
@@ -709,68 +642,11 @@ struct ContentView: View {
709642
)
710643
.frame(maxWidth: .infinity, alignment: .leading)
711644
.onChange(of: messageText) { newValue in
712-
// Cancel previous debounce timer
713645
autocompleteDebounceTimer?.invalidate()
714-
715-
// Debounce autocomplete updates to reduce calls during rapid typing
716646
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
717-
// Get cursor position (approximate - end of text for now)
718647
let cursorPosition = newValue.count
719648
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
720649
}
721-
722-
// Check for command autocomplete (instant, no debounce needed)
723-
if newValue.hasPrefix("/") && newValue.count >= 1 {
724-
// Build context-aware command list
725-
let isGeoPublic: Bool = {
726-
if case .location = locationManager.selectedChannel { return true }
727-
return false
728-
}()
729-
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
730-
var commandDescriptions = [
731-
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
732-
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
733-
("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")),
734-
("/m", String(localized: "content.commands.message", comment: "Description for /m command")),
735-
("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")),
736-
("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")),
737-
("/w", String(localized: "content.commands.who", comment: "Description for /w command"))
738-
]
739-
// Only show favorites commands when not in geohash context
740-
if !(isGeoPublic || isGeoDM) {
741-
commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command")))
742-
commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command")))
743-
}
744-
745-
let input = newValue.lowercased()
746-
747-
// Map of aliases to primary commands
748-
let aliases: [String: String] = [
749-
"/join": "/j",
750-
"/msg": "/m"
751-
]
752-
753-
// Filter commands, but convert aliases to primary
754-
commandSuggestions = commandDescriptions
755-
.filter { $0.0.starts(with: input) }
756-
.map { $0.0 }
757-
758-
// Also check if input matches an alias
759-
for (alias, primary) in aliases {
760-
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
761-
if commandDescriptions.contains(where: { $0.0 == primary }) {
762-
commandSuggestions.append(primary)
763-
}
764-
}
765-
}
766-
767-
// Remove duplicates and sort
768-
commandSuggestions = Array(Set(commandSuggestions)).sorted()
769-
showCommandSuggestions = !commandSuggestions.isEmpty
770-
} else {
771-
showCommandSuggestions = false
772-
commandSuggestions = []
773-
}
774650
}
775651

776652
HStack(alignment: .center, spacing: 4) {
@@ -787,7 +663,7 @@ struct ContentView: View {
787663
.padding(.bottom, 8)
788664
.background(backgroundColor.opacity(0.95))
789665
}
790-
666+
791667
private func handleOpenURL(_ url: URL) {
792668
guard url.scheme == "bitchat" else { return }
793669
switch url.host {

0 commit comments

Comments
 (0)