diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift new file mode 100644 index 00000000..0bf25537 --- /dev/null +++ b/bitchat/Models/CommandInfo.swift @@ -0,0 +1,58 @@ +// +// CommandsInfo.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +// MARK: - CommandInfo Enum + +enum CommandInfo: String, Identifiable { + case block + case clear + case hug + case message = "dm" + case slap + case unblock + case who + case favorite + case unfavorite + + var id: String { rawValue } + + var alias: String { "/" + rawValue } + + var placeholder: String? { + switch self { + case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: + return "<" + String(localized: "content.input.nickname_placeholder") + ">" + case .clear, .who: + return nil + } + } + + var description: String { + switch self { + case .block: String(localized: "content.commands.block") + case .clear: String(localized: "content.commands.clear") + case .hug: String(localized: "content.commands.hug") + case .message: String(localized: "content.commands.message") + case .slap: String(localized: "content.commands.slap") + case .unblock: String(localized: "content.commands.unblock") + case .who: String(localized: "content.commands.who") + case .favorite: String(localized: "content.commands.favorite") + case .unfavorite: String(localized: "content.commands.unfavorite") + } + } + + static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { + let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who] + if isGeoPublic || isGeoDM { + return baseCommands + [.favorite, .unfavorite] + } + return baseCommands + } +} diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index 23834c28..b7c0d5b6 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -116,4 +116,18 @@ enum ChannelID: Equatable, Codable { case .location(let ch): return ch.geohash } } + + var isMesh: Bool { + switch self { + case .mesh: true + case .location: false + } + } + + var isLocation: Bool { + switch self { + case .mesh: false + case .location: true + } + } } diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift new file mode 100644 index 00000000..4c9d0213 --- /dev/null +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -0,0 +1,90 @@ +// +// CommandSuggestionsView.swift +// bitchat +// +// Created by Islam on 29/10/2025. +// + +import SwiftUI + +struct CommandSuggestionsView: View { + @EnvironmentObject private var viewModel: ChatViewModel + @ObservedObject private var locationManager = LocationChannelManager.shared + + @Binding var messageText: String + + let textColor: Color + let backgroundColor: Color + let secondaryTextColor: Color + + private var filteredCommands: [CommandInfo] { + guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] } + let isGeoPublic = locationManager.selectedChannel.isLocation + let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true + return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in + command.alias.starts(with: messageText.lowercased()) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(filteredCommands) { command in + Button { + messageText = command.alias + " " + } label: { + buttonRow(for: command) + } + .buttonStyle(.plain) + .background(Color.gray.opacity(0.1)) + } + } + .background(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) + ) + } + + private func buttonRow(for command: CommandInfo) -> some View { + HStack { + Text(command.alias) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(textColor) + .fontWeight(.medium) + + if let placeholder = command.placeholder { + Text(placeholder) + .font(.bitchatSystem(size: 10, design: .monospaced)) + .foregroundColor(secondaryTextColor.opacity(0.8)) + } + + Spacer() + + Text(command.description) + .font(.bitchatSystem(size: 10, design: .monospaced)) + .foregroundColor(secondaryTextColor) + } + .padding(.horizontal, 12) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +@available(iOS 17, macOS 14, *) +#Preview { + @Previewable @State var messageText: String = "/" + let keychain = KeychainManager() + let viewModel = ChatViewModel( + keychain: keychain, + idBridge: NostrIdentityBridge(), + identityManager: SecureIdentityStateManager(keychain) + ) + + CommandSuggestionsView( + messageText: $messageText, + textColor: .green, + backgroundColor: .primary, + secondaryTextColor: .secondary + ) + .environmentObject(viewModel) +} diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 393659df..1a5ee44e 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -43,8 +43,6 @@ struct ContentView: View { @State private var showPeerList = false @State private var showSidebar = false @State private var showAppInfo = false - @State private var showCommandSuggestions = false - @State private var commandSuggestions: [String] = [] @State private var showMessageActions = false @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: PeerID? @@ -605,77 +603,12 @@ struct ContentView: View { .padding(.horizontal, 12) } - // Command suggestions - if showCommandSuggestions && !commandSuggestions.isEmpty { - VStack(alignment: .leading, spacing: 0) { - // Define commands with aliases and syntax - let baseInfo: [(commands: [String], syntax: String?, description: String)] = [ - (["/block"], "[nickname]", "block or list blocked peers"), - (["/clear"], nil, "clear chat messages"), - (["/hug"], "", "send someone a warm hug"), - (["/m", "/msg"], " [message]", "send private message"), - (["/slap"], "", "slap someone with a trout"), - (["/unblock"], "", "unblock a peer"), - (["/w"], nil, "see who's online") - ] - let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }() - let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true - let favInfo: [(commands: [String], syntax: String?, description: String)] = [ - (["/fav"], "", "add to favorites"), - (["/unfav"], "", "remove from favorites") - ] - let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo) - - // Build the display - let allCommands = commandInfo - - // Show matching commands - ForEach(commandSuggestions, id: \.self) { command in - // Find the command info for this suggestion - if let info = allCommands.first(where: { $0.commands.contains(command) }) { - Button(action: { - // Replace current text with selected command - messageText = command + " " - showCommandSuggestions = false - commandSuggestions = [] - }) { - HStack { - // Show all aliases together - Text(info.commands.joined(separator: ", ")) - .font(.bitchatSystem(size: 11, design: .monospaced)) - .foregroundColor(textColor) - .fontWeight(.medium) - - // Show syntax if any - if let syntax = info.syntax { - Text(syntax) - .font(.bitchatSystem(size: 10, design: .monospaced)) - .foregroundColor(secondaryTextColor.opacity(0.8)) - } - - Spacer() - - // Show description - Text(info.description) - .font(.bitchatSystem(size: 10, design: .monospaced)) - .foregroundColor(secondaryTextColor) - } - .padding(.horizontal, 12) - .padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - .background(Color.gray.opacity(0.1)) - } - } - } - .background(backgroundColor) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) - ) - .padding(.horizontal, 12) - } + CommandSuggestionsView( + messageText: $messageText, + textColor: textColor, + backgroundColor: backgroundColor, + secondaryTextColor: secondaryTextColor + ) // Recording indicator if isPreparingVoiceNote || isRecordingVoiceNote { @@ -709,68 +642,11 @@ struct ContentView: View { ) .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: messageText) { newValue in - // Cancel previous debounce timer autocompleteDebounceTimer?.invalidate() - - // Debounce autocomplete updates to reduce calls during rapid typing autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in - // Get cursor position (approximate - end of text for now) let cursorPosition = newValue.count viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) } - - // Check for command autocomplete (instant, no debounce needed) - if newValue.hasPrefix("/") && newValue.count >= 1 { - // Build context-aware command list - let isGeoPublic: Bool = { - if case .location = locationManager.selectedChannel { return true } - return false - }() - let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true - var commandDescriptions = [ - ("/block", String(localized: "content.commands.block", comment: "Description for /block command")), - ("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")), - ("/hug", String(localized: "content.commands.hug", comment: "Description for /hug command")), - ("/m", String(localized: "content.commands.message", comment: "Description for /m command")), - ("/slap", String(localized: "content.commands.slap", comment: "Description for /slap command")), - ("/unblock", String(localized: "content.commands.unblock", comment: "Description for /unblock command")), - ("/w", String(localized: "content.commands.who", comment: "Description for /w command")) - ] - // Only show favorites commands when not in geohash context - if !(isGeoPublic || isGeoDM) { - commandDescriptions.append(("/fav", String(localized: "content.commands.favorite", comment: "Description for /fav command"))) - commandDescriptions.append(("/unfav", String(localized: "content.commands.unfavorite", comment: "Description for /unfav command"))) - } - - let input = newValue.lowercased() - - // Map of aliases to primary commands - let aliases: [String: String] = [ - "/join": "/j", - "/msg": "/m" - ] - - // Filter commands, but convert aliases to primary - commandSuggestions = commandDescriptions - .filter { $0.0.starts(with: input) } - .map { $0.0 } - - // Also check if input matches an alias - for (alias, primary) in aliases { - if alias.starts(with: input) && !commandSuggestions.contains(primary) { - if commandDescriptions.contains(where: { $0.0 == primary }) { - commandSuggestions.append(primary) - } - } - } - - // Remove duplicates and sort - commandSuggestions = Array(Set(commandSuggestions)).sorted() - showCommandSuggestions = !commandSuggestions.isEmpty - } else { - showCommandSuggestions = false - commandSuggestions = [] - } } HStack(alignment: .center, spacing: 4) { @@ -787,7 +663,7 @@ struct ContentView: View { .padding(.bottom, 8) .background(backgroundColor.opacity(0.95)) } - + private func handleOpenURL(_ url: URL) { guard url.scheme == "bitchat" else { return } switch url.host {