Files
simplex-chat/apps/ios/Shared/Views/Chat/CommandsMenuView.swift
Evgeny 4811d663e6 rfc: bot messages and buttons, core: command markdown, supported commands in profile preferences, chat sessions preference, peer type field in profile to identify bots (#5360)
* rfc: bot messages and buttons

* update

* update bot rfc

* core: add bot commands to chat preferences and peer type to profile

* update postgresql schema

* update query plans

* chat sessions preference

* markdown for bot commands

* schema

* core: file preference, options to create bot from CLI

* core: different command type

* ios: commands menu

* update types

* update ios

* improve command markdown

* core, ios: update types

* android, desktop: clickable commands in messages in chats with bots

* android, desktop: commands menu

* command menu button, bot icon

* ios: connect flow for bots

* android, desktop: connect flow for bots

* icon

* CLI commands to view and set commands, remove "hidden" property of command, bot api docs

* corrections

* fix inheriting profile preferences to business groups

* note on business address

* ios: export localizations

* fix test

* commands to set file preference on user/contact, tidy up layout and display of command and attachment buttons
2025-08-07 11:13:35 +01:00

188 lines
6.8 KiB
Swift

//
// CommandsMenuView.swift
// SimpleX (iOS)
//
// Created by EP on 03/08/2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
let COMMAND_ROW_SIZE: CGFloat = 48
let MAX_VISIBLE_COMMAND_ROWS: CGFloat = 5.8
struct CommandsMenuView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
@Binding var composeState: ComposeState
@Binding var selectedRange: NSRange
@Binding var showCommandsMenu: Bool
@State private var currentCommands: [ChatBotCommand] = []
@State private var menuTreeBackPath: [(label: String, commands: [ChatBotCommand])] = []
@State private var keywordWidth: CGFloat = 0
var body: some View {
ZStack(alignment: .bottom) {
if !currentCommands.isEmpty {
Color.white.opacity(0.01)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
showCommandsMenu = false
currentCommands = []
menuTreeBackPath = []
}
VStack(spacing: 0) {
Spacer()
let cmdsCount = currentCommands.count + (menuTreeBackPath.isEmpty ? 0 : 1)
let scroll = ScrollView {
VStack(spacing: 0) {
if let prev = menuTreeBackPath.last {
Divider()
menuLabelRow(prev)
}
ForEach(currentCommands, id: \.self, content: commandRow)
}
}
.frame(maxWidth: .infinity, maxHeight: COMMAND_ROW_SIZE * min(MAX_VISIBLE_COMMAND_ROWS, CGFloat(cmdsCount)))
.background(theme.colors.background)
if #available(iOS 16.0, *) {
scroll.scrollDismissesKeyboard(.never)
} else {
scroll
}
}
.onPreferenceChange(DetermineWidth.Key.self) { keywordWidth = $0 }
}
}
.onChange(of: composeState.message) { message in
let msg = message.trimmingCharacters(in: .whitespaces)
if msg == "/" {
currentCommands = chat.chatInfo.menuCommands
} else if msg.first == "/" {
currentCommands = filterShownCommands(chat.chatInfo.menuCommands, msg.dropFirst())
} else {
showCommandsMenu = false
currentCommands = []
}
menuTreeBackPath = []
}
.onChange(of: showCommandsMenu) { show in
currentCommands = show ? chat.chatInfo.menuCommands : []
menuTreeBackPath = []
}
}
private func menuLabelRow(_ prev: (label: String, commands: [ChatBotCommand])) -> some View {
HStack {
Image(systemName: "chevron.left")
.foregroundColor(theme.colors.secondary)
Text(prev.label)
.fontWeight(.medium)
.frame(maxWidth: .infinity)
}
.padding(.horizontal)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: COMMAND_ROW_SIZE, alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
if !menuTreeBackPath.isEmpty {
currentCommands = menuTreeBackPath.removeLast().commands
}
}
}
@ViewBuilder
private func commandRow(_ command: ChatBotCommand) -> some View {
Divider()
switch command {
case let .command(keyword, label, params):
HStack {
Text(label)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text("/" + keyword)
.font(.subheadline)
.lineLimit(1)
.foregroundColor(theme.colors.secondary)
.frame(minWidth: keywordWidth, alignment: .trailing)
.overlay(DetermineWidth())
}
.padding(.horizontal)
.frame(height: COMMAND_ROW_SIZE, alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
if let params {
composeState.message = "/\(keyword) \(params)"
selectedRange = NSRange(location: composeState.message.count, length: 0)
} else {
composeState.message = ""
sendCommandMsg(chat, "/\(keyword)")
}
showCommandsMenu = false
currentCommands = []
menuTreeBackPath = []
}
case let .menu(label, cmds):
HStack {
Text(label)
.fontWeight(.medium)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(theme.colors.secondary)
}
.padding(.horizontal)
.frame(height: COMMAND_ROW_SIZE, alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
menuTreeBackPath.append((label: label, commands: currentCommands))
currentCommands = cmds
}
}
}
private func filterShownCommands(_ commands: [ChatBotCommand], _ msg: String.SubSequence) -> [ChatBotCommand] {
var cmds: [ChatBotCommand] = []
for command in commands {
switch command {
case let .command(keyword, _, _):
if keyword.starts(with: msg) {
cmds.append(command)
}
case let .menu(_, innerCmds):
cmds.append(contentsOf: filterShownCommands(innerCmds, msg))
}
}
return cmds
}
}
func sendCommandMsg(_ chat: Chat, _ cmd: String) {
if chat.chatInfo.sndReady {
Task {
if let chatItems = await apiSendMessages(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
scope: chat.chatInfo.groupChatScope(),
composedMessages: [ComposedMessage(msgContent: .text(cmd))]
) {
await MainActor.run {
for ci in chatItems {
ChatModel.shared.addChatItem(chat.chatInfo, ci)
}
}
}
}
} else {
showAlert(
NSLocalizedString("You can't send messages!", comment: "alert title"),
message: NSLocalizedString("To send commands you must be connected.", comment: "alert message"),
actions: { [okAlertAction] }
)
}
}