diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 30ed3fa1a4..e743e0bffa 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -93,7 +93,18 @@ struct MsgContentView: View { @inline(__always) private func msgContentView() -> some View { - let r = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: containerBackground, prefix: prefix) + let r = messageText( + text, + formattedText, + textStyle: textStyle, + sender: sender, + mentions: mentions, + userMemberId: userMemberId, + showSecrets: showSecrets, + commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady, + backgroundColor: containerBackground, + prefix: prefix + ) let s = r.string let t: Text if let mt = meta { @@ -104,7 +115,7 @@ struct MsgContentView: View { } else { t = Text(AttributedString(s)) } - return msgTextResultView(r, t, showSecrets: $showSecrets) + return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) }) } @inline(__always) @@ -120,14 +131,27 @@ struct MsgContentView: View { } } -func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding>? = nil, centered: Bool = false, smallFont: Bool = false) -> some View { +func msgTextResultView( + _ r: MsgTextResult, + _ t: Text, + showSecrets: Binding>? = nil, + sendCommand: ((String) -> Void)? = nil, + centered: Bool = false, + smallFont: Bool = false +) -> some View { t.if(r.hasSecrets, transform: hiddenSecretsView) - .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets, centered: centered, smallFont: smallFont)) } + .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets, sendCommand: sendCommand, centered: centered, smallFont: smallFont)) } } // smallFont parameter is used to pad height, otherwise CTFrameGetLines fails to see them as lines - it's needed if font is not .body @inline(__always) -private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding>? = nil, centered: Bool, smallFont: Bool) -> some View { +private func handleTextTaps( + _ s: NSAttributedString, + showSecrets: Binding>? = nil, + sendCommand: ((String) -> Void)? = nil, + centered: Bool, + smallFont: Bool +) -> some View { return GeometryReader { g in Rectangle() .fill(Color.clear) @@ -187,6 +211,8 @@ private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding?, + commands: Bool = false, backgroundColor: UIColor, prefix: NSAttributedString? = nil ) -> MsgTextResult { @@ -343,6 +373,15 @@ func messageText( if case .description = privacySimplexLinkModeDefault.get() { t = simplexLinkText(linkType, smpHosts) } + case let .command(cmdStr): + snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular) + attrs[.font] = snippet + t = "/" + cmdStr + if !preview && commands { + attrs[.foregroundColor] = uiLinkColor + attrs[commandAttrKey] = t + handleTaps = true + } case let .mention(memberName): if let m = mentions?[memberName] { mention = mention ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: descr.pointSize) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 712a88114f..2f8d6f2acd 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -59,6 +59,7 @@ struct ChatView: View { @State private var ignoreLoadingRequests: Int64? = nil @State private var animatedScrollingInProgress: Bool = false @State private var showUserSupportChatSheet = false + @State private var showCommandsMenu = false @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @@ -109,6 +110,9 @@ struct ChatView: View { if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { GroupMentionsView(im: im, groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) } + if !chat.chatInfo.menuCommands.isEmpty { + CommandsMenuView(chat: chat, composeState: $composeState, selectedRange: $selectedRange, showCommandsMenu: $showCommandsMenu) + } FloatingButtons(im: im, theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: { mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) @@ -135,6 +139,7 @@ struct ChatView: View { chat: chat, im: im, composeState: $composeState, + showCommandsMenu: $showCommandsMenu, keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, selectedRange: $selectedRange, @@ -919,10 +924,12 @@ struct ChatView: View { case .inv: "Tap Connect to chat" case .con: - "Tap Connect to send request" + contact.isBot ? "Tap Connect to use bot" : "Tap Connect to send request" } } else if contact.nextAcceptContactRequest { "Accept contact request" + } else if case .bot = contact.profile.peerType { + "Bot" } else { "Your contact" } @@ -956,9 +963,7 @@ struct ChatView: View { switch (chat.chatInfo) { case let .direct(contact): if !contact.sndReady && contact.active && !contact.sendMsgToConnect && !contact.nextAcceptContactRequest { - contact.preparedContact?.uiConnLinkType == .con - ? "contact should accept…" - : contact.contactGroupMemberId != nil + (contact.preparedContact?.uiConnLinkType == .con && !contact.isBot) || contact.contactGroupMemberId != nil ? "contact should accept…" : "connecting…" } else { diff --git a/apps/ios/Shared/Views/Chat/CommandsMenuView.swift b/apps/ios/Shared/Views/Chat/CommandsMenuView.swift new file mode 100644 index 0000000000..525bf5725c --- /dev/null +++ b/apps/ios/Shared/Views/Chat/CommandsMenuView.swift @@ -0,0 +1,187 @@ +// +// 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] } + ) + } +} diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 876761a588..a6e6518e7b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -323,6 +323,7 @@ struct ComposeView: View { @ObservedObject var chat: Chat @ObservedObject var im: ItemsModel @Binding var composeState: ComposeState + @Binding var showCommandsMenu: Bool @Binding var keyboardVisible: Bool @Binding var keyboardHiddenDate: Date @Binding var selectedRange: NSRange @@ -410,18 +411,7 @@ struct ComposeView: View { if chat.chatInfo.groupInfo?.nextConnectPrepared == true { if chat.chatInfo.groupInfo?.businessChat == nil { - Button(action: connectPreparedGroup) { - ZStack(alignment: .trailing) { - Label("Join group", systemImage: "person.2.fill") - .frame(maxWidth: .infinity) - if composeState.progressByTimeout { - ProgressView() - .padding() - } - } - } - .frame(height: 60) - .disabled(composeState.inProgress) + connectButtonView("Join group", icon: "person.2.fill", connect: connectPreparedGroup) } else { sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup) } @@ -429,27 +419,22 @@ struct ComposeView: View { contextSendMessageToConnect("Send direct message to connect") Divider() HStack (alignment: .center) { - attachmentButton().disabled(true) + attachmentAndCommandsButtons().disabled(true) sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation) } .padding(.horizontal, 12) - } else if contact?.nextConnectPrepared == true, let linkType = contact?.preparedContact?.uiConnLinkType { + } else if let contact, + contact.nextConnectPrepared == true, + let linkType = contact.preparedContact?.uiConnLinkType { switch linkType { case .inv: - Button(action: sendConnectPreparedContact) { - ZStack(alignment: .trailing) { - Label("Connect", systemImage: "person.fill.badge.plus") - .frame(maxWidth: .infinity) - if composeState.progressByTimeout { - ProgressView() - .padding() - } - } - } - .frame(height: 60) - .disabled(composeState.inProgress) + connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact) case .con: - sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest) + if contact.isBot { + connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact) + } else { + sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest) + } } } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId { ContextContactRequestActionsView(contactRequestId: crId) @@ -457,7 +442,7 @@ struct ComposeView: View { ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv) } else { HStack (alignment: .center) { - attachmentButton() + attachmentAndCommandsButtons() sendMessageView(disableSendButton) } .padding(.horizontal, 12) @@ -635,6 +620,21 @@ struct ComposeView: View { } } + private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View { + Button(action: connect) { + ZStack(alignment: .trailing) { + Label(label, systemImage: icon) + .frame(maxWidth: .infinity) + if composeState.progressByTimeout { + ProgressView() + .padding() + } + } + } + .frame(height: 60) + .disabled(composeState.inProgress) + } + private func sendContactRequestView(_ disableSendButton: Bool, icon: String, sendRequest: @escaping () -> Void) -> some View { HStack (alignment: .center) { sendMessageView( @@ -703,6 +703,35 @@ struct ComposeView: View { } } + @ViewBuilder private func attachmentAndCommandsButtons() -> some View { + let msg = composeState.message.trimmingCharacters(in: .whitespaces) + let showAttachment = chat.chatInfo.contact?.profile.peerType != .bot || chat.chatInfo.featureEnabled(.files) + let showCommands = chat.chatInfo.useCommands && (!showAttachment || msg.isEmpty || msg.starts(with: "/")) + if showCommands { + commandsButton() + } + if showAttachment { + attachmentButton() + .padding(.trailing, 3) + .if(showCommands) { v in v.padding(.leading, 3) } + } + } + + private func commandsButton() -> some View { + Button { + showCommandsMenu.toggle() + } label: { + Text(verbatim: "//") + .font(.title3) + .italic() + .contentShape(Rectangle()) + } + .disabled(!chat.chatInfo.sendMsgEnabled || chat.chatInfo.menuCommands.isEmpty) + .frame(width: 25, height: 25) + .tint(theme.colors.primary) + .padding(.bottom, 2) + } + @ViewBuilder private func attachmentButton() -> some View { let b = Button { showChooseSource = true @@ -714,12 +743,11 @@ struct ComposeView: View { .frame(width: 25, height: 25) .tint(theme.colors.primary) if im.secondaryIMFilter == nil, - case let .group(g, _) = chat.chatInfo, - !g.fullGroupPreferences.files.on(for: g.membership) { + !chat.chatInfo.featureEnabled(.files) { b.disabled(true).onTapGesture { AlertManager.shared.showAlertMsg( title: "Files and media prohibited!", - message: "Only group owners can enable files and media." + message: chat.chatInfo.groupInfo == nil ? nil : "Only group owners can enable files and media." ) } } else { @@ -750,7 +778,6 @@ struct ComposeView: View { } } - // TODO [short links] different messages for business private func sendConnectPreparedContactRequest() { hideKeyboard() let empty = composeState.whitespaceOnly @@ -1489,33 +1516,3 @@ struct ComposeView: View { cancelledLinks = [] } } - -struct ComposeView_Previews: PreviewProvider { - static var previews: some View { - let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) - let im = ItemsModel.shared - @State var composeState = ComposeState(message: "hello") - @State var selectedRange = NSRange() - - return Group { - ComposeView( - chat: chat, - im: im, - composeState: $composeState, - keyboardVisible: Binding.constant(true), - keyboardHiddenDate: Binding.constant(Date.now), - selectedRange: $selectedRange - ) - .environmentObject(ChatModel()) - ComposeView( - chat: chat, - im: im, - composeState: $composeState, - keyboardVisible: Binding.constant(true), - keyboardHiddenDate: Binding.constant(Date.now), - selectedRange: $selectedRange - ) - .environmentObject(ChatModel()) - } - } -} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift index 07cc7bd217..440ed5227d 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -67,7 +67,7 @@ struct GroupMentionsView: View { } } .frame(maxHeight: MEMBER_ROW_SIZE * min(MAX_VISIBLE_MEMBER_ROWS, CGFloat(filtered.count))) - .background(Color(UIColor.systemBackground)) + .background(theme.colors.background) if #available(iOS 16.0, *) { scroll.scrollDismissesKeyboard(.never) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 79f72e539a..c56d947a5a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -351,12 +351,14 @@ struct ChatPreviewView: View { if contact.isContactCard { Text("Tap to Connect") .foregroundColor(theme.colors.primary) + } else if contact.isBot && contact.nextConnectPrepared { + Text("Open to use bot") } else if contact.sendMsgToConnect { Text("Open to connect") } else if contact.nextAcceptContactRequest { Text("Open to accept") } else if !contact.sndReady && contact.activeConn != nil && contact.active { - contact.preparedContact?.uiConnLinkType == .con + (contact.preparedContact?.uiConnLinkType == .con && !contact.isBot) || contact.contactGroupMemberId != nil ? Text("contact should accept…") : Text("connecting…") } else { diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index 4cc244cb24..9c2916880c 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -27,6 +27,7 @@ struct ProfileImage: View { Image(systemName: iconName) .resizable() .foregroundColor(c) + .scaledToFit() .frame(width: size, height: size) .background( Circle() diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 432422d77b..3de1fdb972 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1039,7 +1039,11 @@ private func showPrepareContactAlert( profileImage: ProfileImage( imageStr: contactShortLinkData.profile.image, - iconName: contactShortLinkData.business ? "briefcase.circle.fill" : "person.crop.circle.fill", + iconName: contactShortLinkData.business + ? "briefcase.circle.fill" + : contactShortLinkData.profile.peerType == .bot + ? "cube.fill" + : "person.crop.circle.fill", size: alertProfileImageSize ), theme: theme, @@ -1112,7 +1116,7 @@ private func showOpenKnownContactAlert( profileImage: ProfileImage( imageStr: contact.profile.image, - iconName: "person.crop.circle.fill", + iconName: contact.chatIconName, size: alertProfileImageSize ), theme: theme, @@ -1138,7 +1142,7 @@ private func showOpenKnownGroupAlert( profileImage: ProfileImage( imageStr: groupInfo.groupProfile.image, - iconName: groupInfo.businessChat == nil ? "person.2.circle.fill" : "briefcase.circle.fill", + iconName: groupInfo.chatIconName, size: alertProfileImageSize ), theme: theme, diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index c4f75e1989..bfa410ca8e 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -855,6 +855,10 @@ swipe action Позволи понижаване No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Позволи необратимо изтриване на съобщение само ако вашият контакт го рарешава. (24 часа) @@ -939,6 +943,10 @@ swipe action Позволи на вашите контакти да изпращат изчезващи съобщения. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Позволи на вашите контакти да изпращат гласови съобщения. @@ -1287,6 +1295,10 @@ swipe action Размазване на медия No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. И вие, и вашият контакт можете да добавяте реакции към съобщението. @@ -1307,6 +1319,10 @@ swipe action И вие, и вашият контакт можете да изпращате изчезващи съобщения. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. И вие, и вашият контакт можете да изпращате гласови съобщения. @@ -3607,6 +3623,10 @@ snd error text Файлове и медия chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Файловете и медията са забранени в тази група. @@ -5378,6 +5398,10 @@ Requires compatible VPN. Само вие можете да изпращате изчезващи съобщения. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Само вие можете да изпращате гласови съобщения. @@ -5403,6 +5427,10 @@ Requires compatible VPN. Само вашият контакт може да изпраща изчезващи съобщения. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Само вашият контакт може да изпраща гласови съобщения. @@ -5470,6 +5498,10 @@ Requires compatible VPN. Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Приложението се отваря… @@ -7431,6 +7463,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. No comment provided by engineer. @@ -7815,6 +7851,10 @@ You will be prompted to complete authentication before this feature is enabled.< To send No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. За поддръжка на незабавни push известия, базата данни за чат трябва да бъде мигрирана. @@ -8658,7 +8698,7 @@ Repeat join request? You can't send messages! Не може да изпращате съобщения! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 0ae8e0a70a..87524b09ab 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -819,6 +819,10 @@ swipe action Allow downgrade No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Povolte nevratné smazání zprávy pouze v případě, že vám to váš kontakt dovolí. (24 hodin) @@ -901,6 +905,10 @@ swipe action Umožněte svým kontaktům odesílat mizící zprávy. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Povolte svým kontaktům odesílání hlasových zpráv. @@ -1219,6 +1227,10 @@ swipe action Blur media No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Vy i váš kontakt můžete přidávat reakce na zprávy. @@ -1239,6 +1251,10 @@ swipe action Vy i váš kontakt můžete posílat mizící zprávy. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Hlasové zprávy můžete posílat vy i váš kontakt. @@ -3469,6 +3485,10 @@ snd error text Soubory a média chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Soubory a média jsou zakázány v této skupině. @@ -5185,6 +5205,10 @@ Vyžaduje povolení sítě VPN. Mizící zprávy můžete odesílat pouze vy. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Hlasové zprávy můžete posílat pouze vy. @@ -5210,6 +5234,10 @@ Vyžaduje povolení sítě VPN. Zmizelé zprávy může odesílat pouze váš kontakt. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Hlasové zprávy může odesílat pouze váš kontakt. @@ -5275,6 +5303,10 @@ Vyžaduje povolení sítě VPN. Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -7190,6 +7222,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. No comment provided by engineer. @@ -7564,6 +7600,10 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. To send No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Pro podporu doručování okamžitých upozornění musí být přenesena chat databáze. @@ -8365,7 +8405,7 @@ Repeat join request? You can't send messages! Nemůžete posílat zprávy! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index cda7e1ead0..3a9c362405 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -867,6 +867,10 @@ swipe action Herabstufung erlauben No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Erlauben Sie das unwiederbringliche Löschen von Nachrichten nur dann, wenn es Ihnen Ihr Kontakt ebenfalls erlaubt. (24 Stunden) @@ -952,6 +956,10 @@ swipe action Erlauben Sie Ihren Kontakten das Senden von verschwindenden Nachrichten. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Erlauben Sie Ihren Kontakten Sprachnachrichten zu senden. @@ -1312,6 +1320,10 @@ swipe action Medien verpixeln No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Sowohl Sie, als auch Ihr Kontakt können Reaktionen auf Nachrichten geben. @@ -1332,6 +1344,10 @@ swipe action Ihr Kontakt und Sie können beide verschwindende Nachrichten senden. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Sowohl Ihr Kontakt, als auch Sie können Sprachnachrichten senden. @@ -3794,6 +3810,10 @@ snd error text Dateien und Medien chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. In dieser Gruppe sind Dateien und Medien nicht erlaubt. @@ -5684,6 +5704,10 @@ Dies erfordert die Aktivierung eines VPNs. Nur Sie können verschwindende Nachrichten senden. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Nur Sie können Sprachnachrichten versenden. @@ -5709,6 +5733,10 @@ Dies erfordert die Aktivierung eines VPNs. Nur Ihr Kontakt kann verschwindende Nachrichten senden. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Nur Ihr Kontakt kann Sprachnachrichten versenden. @@ -5784,6 +5812,10 @@ Dies erfordert die Aktivierung eines VPNs. Zum Beitreten öffnen No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… App wird geöffnet… @@ -7914,6 +7946,10 @@ report reason Verbinden tippen, um die Anfrage zu senden No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Tippen Sie im Menü auf SimpleX-Adresse erstellen, um sie später zu erstellen. @@ -8327,6 +8363,10 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Für das Senden No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Um sofortige Push-Benachrichtigungen zu unterstützen, muss die Chat-Datenbank migriert werden. @@ -9224,7 +9264,7 @@ Verbindungsanfrage wiederholen? You can't send messages! Sie können keine Nachrichten versenden! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 947f0a2d6c..05b9b9bc9d 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -867,6 +867,11 @@ swipe action Allow downgrade No comment provided by engineer. + + Allow files and media only if your contact allows them. + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Allow irreversible message deletion only if your contact allows it to you. (24 hours) @@ -952,6 +957,11 @@ swipe action Allow your contacts to send disappearing messages. No comment provided by engineer. + + Allow your contacts to send files and media. + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Allow your contacts to send voice messages. @@ -1312,6 +1322,11 @@ swipe action Blur media No comment provided by engineer. + + Bot + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Both you and your contact can add message reactions. @@ -1332,6 +1347,11 @@ swipe action Both you and your contact can send disappearing messages. No comment provided by engineer. + + Both you and your contact can send files and media. + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Both you and your contact can send voice messages. @@ -3796,6 +3816,11 @@ snd error text Files and media chat feature + + Files and media are prohibited in this chat. + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Files and media are prohibited. @@ -5687,6 +5712,11 @@ Requires compatible VPN. Only you can send disappearing messages. No comment provided by engineer. + + Only you can send files and media. + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Only you can send voice messages. @@ -5712,6 +5742,11 @@ Requires compatible VPN. Only your contact can send disappearing messages. No comment provided by engineer. + + Only your contact can send files and media. + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Only your contact can send voice messages. @@ -5787,6 +5822,11 @@ Requires compatible VPN. Open to join No comment provided by engineer. + + Open to use bot + Open to use bot + No comment provided by engineer. + Opening app… Opening app… @@ -7917,6 +7957,11 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Tap Create SimpleX address in the menu to create it later. @@ -8331,6 +8376,11 @@ You will be prompted to complete authentication before this feature is enabled.< To send No comment provided by engineer. + + To send commands you must be connected. + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. To support instant push notifications the chat database has to be migrated. @@ -9228,7 +9278,7 @@ Repeat join request? You can't send messages! You can't send messages! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index b58f716f8e..1454ad639d 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -867,6 +867,10 @@ swipe action Permitir versión anterior No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Se permite la eliminación irreversible de mensajes pero sólo si tu contacto también la permite para tí. (24 horas) @@ -952,6 +956,10 @@ swipe action Permites a tus contactos enviar mensajes temporales. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Permites a tus contactos enviar mensajes de voz. @@ -1312,6 +1320,10 @@ swipe action Difuminar multimedia No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Tanto tú como tu contacto podéis añadir reacciones a los mensajes. @@ -1332,6 +1344,10 @@ swipe action Tanto tú como tu contacto podéis enviar mensajes temporales. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Tanto tú como tu contacto podéis enviar mensajes de voz. @@ -3794,6 +3810,10 @@ snd error text Archivos y multimedia chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Los archivos y multimedia no están permitidos en este grupo. @@ -5684,6 +5704,10 @@ Requiere activación de la VPN. Sólo tú puedes enviar mensajes temporales. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Sólo tú puedes enviar mensajes de voz. @@ -5709,6 +5733,10 @@ Requiere activación de la VPN. Sólo tu contacto puede enviar mensajes temporales. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Sólo tu contacto puede enviar mensajes de voz. @@ -5784,6 +5812,10 @@ Requiere activación de la VPN. Abrir para unirte No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Iniciando aplicación… @@ -7914,6 +7946,10 @@ report reason Pulsa Conectar para enviar solicitud No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Pulsa Crear dirección SimpleX en el menú para crearla más tarde. @@ -8327,6 +8363,10 @@ Se te pedirá que completes la autenticación antes de activar esta función.Para enviar No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Para permitir las notificaciones automáticas instantáneas, la base de datos se debe migrar. @@ -9224,7 +9264,7 @@ Repeat join request? You can't send messages! ¡No puedes enviar mensajes! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index b5ba7bb864..4b42238a22 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -792,6 +792,10 @@ swipe action Allow downgrade No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Salli peruuttamaton viestien poisto vain, jos kontaktisi sallii ne sinulle. (24 tuntia) @@ -874,6 +878,10 @@ swipe action Salli kontaktiesi lähettää katoavia viestejä. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Salli kontaktiesi lähettää ääniviestejä. @@ -1191,6 +1199,10 @@ swipe action Blur media No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Sekä sinä että kontaktisi voivat käyttää viestireaktioita. @@ -1211,6 +1223,10 @@ swipe action Sekä sinä että kontaktisi voitte lähettää katoavia viestejä. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Sekä sinä että kontaktisi voitte lähettää ääniviestejä. @@ -3437,6 +3453,10 @@ snd error text Tiedostot ja media chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Tiedostot ja media ovat tässä ryhmässä kiellettyjä. @@ -5152,6 +5172,10 @@ Edellyttää VPN:n sallimista. Vain sinä voit lähettää katoavia viestejä. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Vain sinä voit lähettää ääniviestejä. @@ -5177,6 +5201,10 @@ Edellyttää VPN:n sallimista. Vain kontaktisi voi lähettää katoavia viestejä. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Vain kontaktisi voi lähettää ääniviestejä. @@ -5241,6 +5269,10 @@ Edellyttää VPN:n sallimista. Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -7154,6 +7186,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. No comment provided by engineer. @@ -7528,6 +7564,10 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote To send No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Keskustelujen-tietokanta on siirrettävä välittömien push-ilmoitusten tukemiseksi. @@ -8328,7 +8368,7 @@ Repeat join request? You can't send messages! Et voi lähettää viestejä! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 8072b5784b..36c1fdfc91 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -861,6 +861,10 @@ swipe action Autoriser la rétrogradation No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Autoriser la suppression irréversible des messages uniquement si votre contact vous l'autorise. (24 heures) @@ -946,6 +950,10 @@ swipe action Autorise votre contact à envoyer des messages éphémères. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Autorise vos contacts à envoyer des messages vocaux. @@ -1303,6 +1311,10 @@ swipe action Flouter les médias No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Vous et votre contact pouvez ajouter des réactions aux messages. @@ -1323,6 +1335,10 @@ swipe action Vous et votre contact êtes tous deux en mesure d'envoyer des messages éphémères. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Vous et votre contact êtes tous deux en mesure d'envoyer des messages vocaux. @@ -3767,6 +3783,10 @@ snd error text Fichiers et médias chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Les fichiers et les médias sont interdits dans ce groupe. @@ -5611,6 +5631,10 @@ Nécessite l'activation d'un VPN. Seulement vous pouvez envoyer des messages éphémères. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Vous seul pouvez envoyer des messages vocaux. @@ -5636,6 +5660,10 @@ Nécessite l'activation d'un VPN. Seulement votre contact peut envoyer des messages éphémères. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Seul votre contact peut envoyer des messages vocaux. @@ -5705,6 +5733,10 @@ Nécessite l'activation d'un VPN. Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Ouverture de l'app… @@ -7779,6 +7811,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. @@ -8184,6 +8220,10 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Pour envoyer No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Pour prendre en charge les notifications push instantanées, la base de données du chat doit être migrée. @@ -9065,7 +9105,7 @@ Répéter la demande d'adhésion ? You can't send messages! Vous ne pouvez pas envoyer de messages ! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 1339f0ce74..156d12ea9f 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -867,6 +867,10 @@ swipe action Visszafejlesztés engedélyezése No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) @@ -952,6 +956,10 @@ swipe action Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. A hangüzenetek küldése engedélyezve van a partnerei számára. @@ -1312,6 +1320,10 @@ swipe action Médiatartalom elhomályosítása No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Mindkét fél hozzáadhat az üzenetekhez reakciókat. @@ -1332,6 +1344,10 @@ swipe action Mindkét fél küldhet eltűnő üzeneteket. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Mindkét fél küldhet hangüzeneteket. @@ -3794,6 +3810,10 @@ snd error text Fájlok és médiatartalmak chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. A fájlok- és a médiatartalmak küldése le van tiltva. @@ -5684,6 +5704,10 @@ VPN engedélyezése szükséges. Csak Ön tud eltűnő üzeneteket küldeni. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Csak Ön tud hangüzeneteket küldeni. @@ -5709,6 +5733,10 @@ VPN engedélyezése szükséges. Csak a partnere tud eltűnő üzeneteket küldeni. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Csak a partnere tud hangüzeneteket küldeni. @@ -5784,6 +5812,10 @@ VPN engedélyezése szükséges. Megnyitás a csatlakozáshoz No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Az alkalmazás megnyitása… @@ -7914,6 +7946,10 @@ report reason Koppintson a „Kapcsolódás” gombra a kérés elküldéséhez No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. @@ -8327,6 +8363,10 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll A küldéshez No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Az azonnali push-értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges. @@ -9224,7 +9264,7 @@ Megismétli a csatlakozási kérést? You can't send messages! Nem lehet üzeneteket küldeni! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 4b51e9dae3..5d3e962d25 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -867,6 +867,10 @@ swipe action Consenti downgrade No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Consenti l'eliminazione irreversibile dei messaggi solo se il contatto la consente a te. (24 ore) @@ -952,6 +956,10 @@ swipe action Permetti ai tuoi contatti di inviare messaggi a tempo. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Permetti ai tuoi contatti di inviare messaggi vocali. @@ -1312,6 +1320,10 @@ swipe action Sfocatura dei file multimediali No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Sia tu che il tuo contatto potete aggiungere reazioni ai messaggi. @@ -1332,6 +1344,10 @@ swipe action Sia tu che il tuo contatto potete inviare messaggi a tempo. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Sia tu che il tuo contatto potete inviare messaggi vocali. @@ -3794,6 +3810,10 @@ snd error text File e multimediali chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. File e contenuti multimediali sono vietati in questo gruppo. @@ -5684,6 +5704,10 @@ Richiede l'attivazione della VPN. Solo tu puoi inviare messaggi a tempo. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Solo tu puoi inviare messaggi vocali. @@ -5709,6 +5733,10 @@ Richiede l'attivazione della VPN. Solo il tuo contatto può inviare messaggi a tempo. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Solo il tuo contatto può inviare messaggi vocali. @@ -5784,6 +5812,10 @@ Richiede l'attivazione della VPN. Apri per entrare No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Apertura dell'app… @@ -7914,6 +7946,10 @@ report reason Tocca Connetti per inviare la richiesta No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Tocca Crea indirizzo SimpleX nel menu per crearlo più tardi. @@ -8327,6 +8363,10 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Per inviare No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Per supportare le notifiche push istantanee, il database della chat deve essere migrato. @@ -9224,7 +9264,7 @@ Ripetere la richiesta di ingresso? You can't send messages! Non puoi inviare messaggi! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 743c5392e6..b76edfc106 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -831,6 +831,10 @@ swipe action Allow downgrade No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) 送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間) @@ -915,6 +919,10 @@ swipe action 送信相手が消えるメッセージを送るのを許可する。 No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. 送信相手からの音声メッセージを許可する。 @@ -1240,6 +1248,10 @@ swipe action Blur media No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. 自分も相手もメッセージへのリアクションを追加できます。 @@ -1260,6 +1272,10 @@ swipe action あなたと連絡相手が消えるメッセージを送信できます。 No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. あなたと連絡相手が音声メッセージを送信できます。 @@ -3510,6 +3526,10 @@ snd error text ファイルとメディア chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. このグループでは、ファイルとメディアは禁止されています。 @@ -5228,6 +5248,10 @@ VPN を有効にする必要があります。 消えるメッセージを送れるのはあなただけです。 No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. 音声メッセージを送れるのはあなただけです。 @@ -5253,6 +5277,10 @@ VPN を有効にする必要があります。 消えるメッセージを送れるのはあなたの連絡相手だけです。 No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. 音声メッセージを送れるのはあなたの連絡相手だけです。 @@ -5318,6 +5346,10 @@ VPN を有効にする必要があります。 Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -7225,6 +7257,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. No comment provided by engineer. @@ -7598,6 +7634,10 @@ You will be prompted to complete authentication before this feature is enabled.< To send No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. インスタント プッシュ通知をサポートするには、チャット データベースを移行する必要があります。 @@ -8399,7 +8439,7 @@ Repeat join request? You can't send messages! メッセージを送信できませんでした! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 6d65bd070f..495fd3ee1a 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -865,6 +865,10 @@ swipe action Downgraden toestaan No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Sta het definitief verwijderen van berichten alleen toe als uw contact dit toestaat. (24 uur) @@ -950,6 +954,10 @@ swipe action Sta toe dat uw contacten verdwijnende berichten verzenden. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Sta toe dat uw contacten spraak berichten verzenden. @@ -1308,6 +1316,10 @@ swipe action Vervaag media No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Zowel u als uw contact kunnen bericht reacties toevoegen. @@ -1328,6 +1340,10 @@ swipe action Zowel jij als je contact kunnen verdwijnende berichten sturen. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Zowel jij als je contact kunnen spraak berichten verzenden. @@ -3778,6 +3794,10 @@ snd error text Bestanden en media chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Bestanden en media zijn niet toegestaan. @@ -5660,6 +5680,10 @@ Vereist het inschakelen van VPN. Alleen jij kunt verdwijnende berichten verzenden. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Alleen jij kunt spraak berichten verzenden. @@ -5685,6 +5709,10 @@ Vereist het inschakelen van VPN. Alleen uw contact kan verdwijnende berichten verzenden. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Alleen uw contact kan spraak berichten verzenden. @@ -5755,6 +5783,10 @@ Vereist het inschakelen van VPN. Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… App openen… @@ -7866,6 +7898,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Tik op SimpleX-adres maken in het menu om het later te maken. @@ -8275,6 +8311,10 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Om te verzenden No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Om directe push meldingen te ondersteunen, moet de chat database worden gemigreerd. @@ -9163,7 +9203,7 @@ Deelnameverzoek herhalen? You can't send messages! Je kunt geen berichten versturen! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 5dd679dfff..224bc41e42 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -861,6 +861,10 @@ swipe action Zezwól na obniżenie wersji No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Zezwalaj na nieodwracalne usuwanie wiadomości tylko wtedy, gdy Twój kontakt Ci na to pozwoli. (24 godziny) @@ -946,6 +950,10 @@ swipe action Zezwól swoim kontaktom na wysyłanie znikających wiadomości. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Zezwól swoim kontaktom na wysyłanie wiadomości głosowych. @@ -1302,6 +1310,10 @@ swipe action Rozmycie mediów No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Zarówno Ty, jak i Twój kontakt możecie dodawać reakcje wiadomości. @@ -1322,6 +1334,10 @@ swipe action Zarówno Ty, jak i Twój kontakt możecie wysyłać znikające wiadomości. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Zarówno Ty, jak i Twój kontakt możecie wysyłać wiadomości głosowe. @@ -3708,6 +3724,10 @@ snd error text Pliki i media chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Pliki i media są zabronione w tej grupie. @@ -5525,6 +5545,10 @@ Wymaga włączenia VPN. Tylko Ty możesz wysyłać znikające wiadomości. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Tylko Ty możesz wysyłać wiadomości głosowe. @@ -5550,6 +5574,10 @@ Wymaga włączenia VPN. Tylko Twój kontakt może wysyłać znikające wiadomości. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Tylko Twój kontakt może wysyłać wiadomości głosowe. @@ -5617,6 +5645,10 @@ Wymaga włączenia VPN. Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Otwieranie aplikacji… @@ -7669,6 +7701,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. No comment provided by engineer. @@ -8064,6 +8100,10 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.To send No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Aby obsługiwać natychmiastowe powiadomienia push, należy zmigrować bazę danych czatu. @@ -8933,7 +8973,7 @@ Powtórzyć prośbę dołączenia? You can't send messages! Nie możesz wysyłać wiadomości! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 156c341f8a..00f91be200 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -867,6 +867,10 @@ swipe action Разрешить прямую доставку No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Разрешить необратимое удаление сообщений, только если Ваш контакт разрешает это Вам. (24 часа) @@ -952,6 +956,10 @@ swipe action Разрешить Вашим контактам отправлять исчезающие сообщения. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Разрешить Вашим контактам отправлять голосовые сообщения. @@ -1312,6 +1320,10 @@ swipe action Размытие изображений No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. И Вы, и Ваш контакт можете добавлять реакции на сообщения. @@ -1332,6 +1344,10 @@ swipe action Вы и Ваш контакт можете отправлять исчезающие сообщения. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Вы и Ваш контакт можете отправлять голосовые сообщения. @@ -3796,6 +3812,10 @@ snd error text Файлы и медиа chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Файлы и медиа запрещены в этой группе. @@ -5686,6 +5706,10 @@ Requires compatible VPN. Только Вы можете отправлять исчезающие сообщения. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Только Вы можете отправлять голосовые сообщения. @@ -5711,6 +5735,10 @@ Requires compatible VPN. Только Ваш контакт может отправлять исчезающие сообщения. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Только Ваш контакт может отправлять голосовые сообщения. @@ -5786,6 +5814,10 @@ Requires compatible VPN. Откройте чтобы вступить No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Приложение отрывается… @@ -7916,6 +7948,10 @@ report reason Нажмите Соединиться, чтобы отправить запрос No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Нажмите Создать адрес SimpleX в меню, чтобы создать его позже. @@ -8330,6 +8366,10 @@ You will be prompted to complete authentication before this feature is enabled.< Для оправки No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Для поддержки мгновенный доставки уведомлений данные чата должны быть перемещены. @@ -9227,7 +9267,7 @@ Repeat join request? You can't send messages! Вы не можете отправлять сообщения! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index b7fbf073bd..2c514afc72 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -784,6 +784,10 @@ swipe action Allow downgrade No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) อนุญาตให้ลบข้อความแบบถาวรเฉพาะในกรณีที่ผู้ติดต่อของคุณอนุญาตให้คุณเท่านั้น @@ -866,6 +870,10 @@ swipe action อนุญาตให้ผู้ติดต่อของคุณส่งข้อความที่จะหายไปหลังปิดแชท (disappearing messages) No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. อนุญาตให้ผู้ติดต่อของคุณส่งข้อความเสียง @@ -1183,6 +1191,10 @@ swipe action Blur media No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. ทั้งคุณและผู้ติดต่อของคุณสามารถเพิ่มปฏิกิริยาของข้อความได้ @@ -1203,6 +1215,10 @@ swipe action ทั้งคุณและผู้ติดต่อของคุณสามารถส่งข้อความที่หายไปได้ No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. ทั้งคุณและผู้ติดต่อของคุณสามารถส่งข้อความเสียงได้ @@ -3422,6 +3438,10 @@ snd error text ไฟล์และสื่อ chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. ไฟล์และสื่อเป็นสิ่งต้องห้ามในกลุ่มนี้ @@ -5131,6 +5151,10 @@ Requires compatible VPN. มีเพียงคุณเท่านั้นที่สามารถส่งข้อความที่จะหายไปหลังจากเวลาที่กำหนดหลังการอ่าน (disappearing messages) ได้ No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. มีเพียงคุณเท่านั้นที่สามารถส่งข้อความเสียงได้ @@ -5156,6 +5180,10 @@ Requires compatible VPN. เฉพาะผู้ติดต่อของคุณเท่านั้นที่สามารถส่งข้อความที่จะหายไปหลังจากเวลาที่กำหนดหลังการอ่าน (disappearing messages) ได้ No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. ผู้ติดต่อของคุณเท่านั้นที่สามารถส่งข้อความเสียงได้ @@ -5220,6 +5248,10 @@ Requires compatible VPN. Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… No comment provided by engineer. @@ -7127,6 +7159,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. No comment provided by engineer. @@ -7500,6 +7536,10 @@ You will be prompted to complete authentication before this feature is enabled.< To send No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. เพื่อรองรับการแจ้งเตือนแบบทันที ฐานข้อมูลการแชทจะต้องได้รับการโยกย้าย @@ -8298,7 +8338,7 @@ Repeat join request? You can't send messages! คุณไม่สามารถส่งข้อความได้! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index cd07746337..5f26c285f5 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -867,6 +867,10 @@ swipe action Sürüm düşürmeye izin ver No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Konuştuğun kişi, kalıcı olarak silinebilen mesajlara izin veriyorsa sen de ver. (24 saat içinde) @@ -952,6 +956,10 @@ swipe action Kişilerinizin kaybolan mesajlar göndermesine izin verin. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Kişilerinizin sesli mesajlar göndermesine izin verin. @@ -1312,6 +1320,10 @@ swipe action Medyayı bulanıklaştır No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Sen ve konuştuğun kişi mesaj tepkileri ekleyebilir. @@ -1332,6 +1344,10 @@ swipe action Sen ve konuştuğun kişi kaybolan mesajlar gönderebilir. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Sen ve konuştuğun kişi sesli mesaj gönderebilir. @@ -3792,6 +3808,10 @@ snd error text Dosyalar ve medya chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Dosyalar ve medya bu grupta yasaklandı. @@ -5681,6 +5701,10 @@ VPN'nin etkinleştirilmesi gerekir. Sadece sen kaybolan mesajlar gönderebilirsin. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Sadece sen sesli mesajlar gönderebilirsin. @@ -5706,6 +5730,10 @@ VPN'nin etkinleştirilmesi gerekir. Sadece karşıdaki kişi kaybolan mesajlar gönderebilir. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Sadece karşıdaki kişi sesli mesajlar gönderebilir. @@ -5781,6 +5809,10 @@ VPN'nin etkinleştirilmesi gerekir. Katılmak için aç No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Uygulama açılıyor… @@ -7906,6 +7938,10 @@ report reason Bağlan'a dokunarak isteği gönderin No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Daha sonra oluşturmak için menüden BasitX adresi oluştur'a dokunun. @@ -8316,6 +8352,10 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Göndermek için No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Anlık anlık bildirimleri desteklemek için sohbet veritabanının taşınması gerekir. @@ -9205,7 +9245,7 @@ Katılma isteği tekrarlansın mı? You can't send messages! Mesajlar gönderemezsiniz! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 9b6ff96324..d87f1cc613 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -867,6 +867,10 @@ swipe action Дозволити пониження версії No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) Дозволяйте безповоротне видалення повідомлень, тільки якщо контакт дозволяє вам це зробити. (24 години) @@ -952,6 +956,10 @@ swipe action Дозвольте своїм контактам надсилати зникаючі повідомлення. No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. Дозвольте своїм контактам надсилати голосові повідомлення. @@ -1312,6 +1320,10 @@ swipe action Розмиття медіа No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. Реакції на повідомлення можете додавати як ви, так і ваш контакт. @@ -1332,6 +1344,10 @@ swipe action Ви і ваш контакт можете надсилати зникаючі повідомлення. No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. Надсилати голосові повідомлення можете як ви, так і ваш контакт. @@ -3794,6 +3810,10 @@ snd error text Файли і медіа chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. Файли та медіа в цій групі заборонені. @@ -5684,6 +5704,10 @@ Requires compatible VPN. Тільки ви можете надсилати зникаючі повідомлення. No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. Тільки ви можете надсилати голосові повідомлення. @@ -5709,6 +5733,10 @@ Requires compatible VPN. Тільки ваш контакт може надсилати зникаючі повідомлення. No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. Тільки ваш контакт може надсилати голосові повідомлення. @@ -5784,6 +5812,10 @@ Requires compatible VPN. Відкрито для приєднання No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… Відкриваємо програму… @@ -7914,6 +7946,10 @@ report reason Натисніть Підключитися, щоб відправити запит No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. Натисніть «Створити адресу SimpleX» у меню, щоб створити її пізніше. @@ -8327,6 +8363,10 @@ You will be prompted to complete authentication before this feature is enabled.< Щоб відправити No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. Для підтримки миттєвих push-повідомлень необхідно перенести базу даних чату. @@ -9224,7 +9264,7 @@ Repeat join request? You can't send messages! Ви не можете надсилати повідомлення! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index fce4207e84..f8767b14e4 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -862,6 +862,10 @@ swipe action 允许降级 No comment provided by engineer. + + Allow files and media only if your contact allows them. + No comment provided by engineer. + Allow irreversible message deletion only if your contact allows it to you. (24 hours) 仅有您的联系人许可后才允许不可撤回消息移除 @@ -947,6 +951,10 @@ swipe action 允许您的联系人发送限时消息。 No comment provided by engineer. + + Allow your contacts to send files and media. + No comment provided by engineer. + Allow your contacts to send voice messages. 允许您的联系人发送语音消息。 @@ -1305,6 +1313,10 @@ swipe action 模糊媒体 No comment provided by engineer. + + Bot + No comment provided by engineer. + Both you and your contact can add message reactions. 您和您的联系人都可以添加消息回应。 @@ -1325,6 +1337,10 @@ swipe action 您和您的联系人都可以发送限时消息。 No comment provided by engineer. + + Both you and your contact can send files and media. + No comment provided by engineer. + Both you and your contact can send voice messages. 您和您的联系人都可以发送语音消息。 @@ -3767,6 +3783,10 @@ snd error text 文件和媒体 chat feature + + Files and media are prohibited in this chat. + No comment provided by engineer. + Files and media are prohibited. 此群组中禁止文件和媒体。 @@ -5645,6 +5665,10 @@ Requires compatible VPN. 只有您可以发送限时消息。 No comment provided by engineer. + + Only you can send files and media. + No comment provided by engineer. + Only you can send voice messages. 只有您可以发送语音消息。 @@ -5670,6 +5694,10 @@ Requires compatible VPN. 只有您的联系人才可以发送限时消息。 No comment provided by engineer. + + Only your contact can send files and media. + No comment provided by engineer. + Only your contact can send voice messages. 只有您的联系人可以发送语音消息。 @@ -5739,6 +5767,10 @@ Requires compatible VPN. Open to join No comment provided by engineer. + + Open to use bot + No comment provided by engineer. + Opening app… 正在打开应用程序… @@ -7794,6 +7826,10 @@ report reason Tap Connect to send request No comment provided by engineer. + + Tap Connect to use bot + No comment provided by engineer. + Tap Create SimpleX address in the menu to create it later. No comment provided by engineer. @@ -8186,6 +8222,10 @@ You will be prompted to complete authentication before this feature is enabled.< To send No comment provided by engineer. + + To send commands you must be connected. + alert message + To support instant push notifications the chat database has to be migrated. 为了支持即时推送通知,聊天数据库必须被迁移。 @@ -9053,7 +9093,7 @@ Repeat join request? You can't send messages! 您无法发送消息! - No comment provided by engineer. + alert title You could not be verified; please try again. diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a46384d99b..d79dde0582 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -245,6 +245,7 @@ D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; }; D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; }; E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; }; + E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; @@ -607,6 +608,7 @@ D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = ""; }; + E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = ""; }; E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -771,6 +773,7 @@ 5C2E260E27A30FDC00F70299 /* ChatView.swift */, 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */, 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */, + E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, 5CE4407127ADB1D0007B033A /* Emoji.swift */, 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */, @@ -1512,6 +1515,7 @@ 8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */, 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */, 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */, + E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */, 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */, 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index db370efdc1..f868b787ee 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -115,7 +115,8 @@ public struct Profile: Codable, NamedChat, Hashable { shortDescr: String? = nil, image: String? = nil, contactLink: String? = nil, - preferences: Preferences? = nil + preferences: Preferences? = nil, + peerType: ChatPeerType? = nil ) { self.displayName = displayName self.fullName = fullName @@ -131,6 +132,7 @@ public struct Profile: Codable, NamedChat, Hashable { public var image: String? public var contactLink: String? public var preferences: Preferences? + public var peerType: ChatPeerType? public var localAlias: String { get { "" } } var profileViewName: String { @@ -152,6 +154,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { image: String? = nil, contactLink: String? = nil, preferences: Preferences? = nil, + peerType: ChatPeerType? = nil, localAlias: String ) { self.profileId = profileId @@ -161,6 +164,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { self.image = image self.contactLink = contactLink self.preferences = preferences + self.peerType = peerType self.localAlias = localAlias } @@ -171,6 +175,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var image: String? public var contactLink: String? public var preferences: Preferences? + public var peerType: ChatPeerType? public var localAlias: String var profileViewName: String { @@ -188,6 +193,11 @@ public struct LocalProfile: Codable, NamedChat, Hashable { ) } +public enum ChatPeerType: String, Codable { + case human + case bot +} + public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile { LocalProfile( profileId: profileId, @@ -197,6 +207,7 @@ public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias image: profile.image, contactLink: profile.contactLink, preferences: profile.preferences, + peerType: profile.peerType, localAlias: localAlias ) } @@ -208,7 +219,8 @@ public func fromLocalProfile (_ profile: LocalProfile) -> Profile { shortDescr: profile.shortDescr, image: profile.image, contactLink: profile.contactLink, - preferences: profile.preferences + preferences: profile.preferences, + peerType: profile.peerType ) } @@ -249,20 +261,26 @@ public struct FullPreferences: Decodable, Equatable, Hashable { public var fullDelete: SimplePreference public var reactions: SimplePreference public var voice: SimplePreference + public var files: SimplePreference public var calls: SimplePreference + public var commands: [ChatBotCommand] public init( timedMessages: TimedMessagesPreference, fullDelete: SimplePreference, reactions: SimplePreference, voice: SimplePreference, - calls: SimplePreference + files: SimplePreference, + calls: SimplePreference, + commands: [ChatBotCommand] ) { self.timedMessages = timedMessages self.fullDelete = fullDelete self.reactions = reactions self.voice = voice + self.files = files self.calls = calls + self.commands = commands } public static let sampleData = FullPreferences( @@ -270,7 +288,9 @@ public struct FullPreferences: Decodable, Equatable, Hashable { fullDelete: SimplePreference(allow: .no), reactions: SimplePreference(allow: .yes), voice: SimplePreference(allow: .yes), - calls: SimplePreference(allow: .yes) + files: SimplePreference(allow: .always), + calls: SimplePreference(allow: .yes), + commands: [] ) } @@ -279,20 +299,26 @@ public struct Preferences: Codable, Hashable { public var fullDelete: SimplePreference? public var reactions: SimplePreference? public var voice: SimplePreference? + public var files: SimplePreference? public var calls: SimplePreference? + public var commands: [ChatBotCommand]? public init( timedMessages: TimedMessagesPreference?, fullDelete: SimplePreference?, reactions: SimplePreference?, voice: SimplePreference?, - calls: SimplePreference? + files: SimplePreference?, + calls: SimplePreference?, + commands: [ChatBotCommand]? ) { self.timedMessages = timedMessages self.fullDelete = fullDelete self.reactions = reactions self.voice = voice + self.files = files self.calls = calls + self.commands = commands } func copy( @@ -300,14 +326,18 @@ public struct Preferences: Codable, Hashable { fullDelete: SimplePreference? = nil, reactions: SimplePreference? = nil, voice: SimplePreference? = nil, - calls: SimplePreference? = nil + files: SimplePreference? = nil, + calls: SimplePreference? = nil, + commands: [ChatBotCommand]? = nil ) -> Preferences { Preferences( timedMessages: timedMessages ?? self.timedMessages, fullDelete: fullDelete ?? self.fullDelete, reactions: reactions ?? self.reactions, voice: voice ?? self.voice, - calls: calls ?? self.calls + files: files ?? self.files, + calls: calls ?? self.calls, + commands: commands ?? self.commands ) } @@ -317,6 +347,7 @@ public struct Preferences: Codable, Hashable { case .fullDelete: return copy(fullDelete: SimplePreference(allow: allowed)) case .reactions: return copy(reactions: SimplePreference(allow: allowed)) case .voice: return copy(voice: SimplePreference(allow: allowed)) + case .files: return copy(voice: SimplePreference(allow: allowed)) case .calls: return copy(calls: SimplePreference(allow: allowed)) } } @@ -326,17 +357,72 @@ public struct Preferences: Codable, Hashable { fullDelete: SimplePreference(allow: .no), reactions: SimplePreference(allow: .yes), voice: SimplePreference(allow: .yes), - calls: SimplePreference(allow: .yes) + files: SimplePreference(allow: .always), + calls: SimplePreference(allow: .yes), + commands: nil ) } +public indirect enum ChatBotCommand: Hashable { + case command(keyword: String, label: String, params: String?) + case menu(label: String, commands: [ChatBotCommand]) + + enum CodingKeys: String, CodingKey { + case type + case keyword + case label + case params + case hidden + case commands + } +} + +extension ChatBotCommand: Decodable { + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let type = try c.decode(String.self, forKey: CodingKeys.type) + switch type { + case "command": + let keyword = try c.decode(String.self, forKey: CodingKeys.keyword) + let label = try c.decode(String.self, forKey: CodingKeys.label) + let params = c.contains(CodingKeys.params) ? try c.decode((String?).self, forKey: CodingKeys.params) : nil + self = .command(keyword: keyword, label: label, params: params) + case "menu": + let label = try c.decode(String.self, forKey: CodingKeys.label) + let commands = try c.decode(([ChatBotCommand]).self, forKey: CodingKeys.commands) + self = .menu(label: label, commands: commands) + default: + throw DecodingError.dataCorruptedError(forKey: CodingKeys.type, in: c, debugDescription: "Unsupported command type: \(type)") + } + } +} + +extension ChatBotCommand: Encodable { + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .command(keyword, label, params): + try c.encode("command", forKey: .type) + try c.encode(keyword, forKey: .keyword) + try c.encode(label, forKey: .label) + if let params { try c.encode(params, forKey: .params) } + case let .menu(label, commands): + try c.encode("menu", forKey: .type) + try c.encode(label, forKey: .label) + try c.encode(commands, forKey: .commands) + } + } +} + public func fullPreferencesToPreferences(_ fullPreferences: FullPreferences) -> Preferences { Preferences( timedMessages: fullPreferences.timedMessages, fullDelete: fullPreferences.fullDelete, reactions: fullPreferences.reactions, voice: fullPreferences.voice, - calls: fullPreferences.calls + files: fullPreferences.files, + calls: fullPreferences.calls, + commands: fullPreferences.commands ) } @@ -346,7 +432,9 @@ public func contactUserPreferencesToPreferences(_ contactUserPreferences: Contac fullDelete: contactUserPreferences.fullDelete.userPreference.preference, reactions: contactUserPreferences.reactions.userPreference.preference, voice: contactUserPreferences.voice.userPreference.preference, - calls: contactUserPreferences.calls.userPreference.preference + files: contactUserPreferences.files.userPreference.preference, + calls: contactUserPreferences.calls.userPreference.preference, + commands: contactUserPreferences.commands ) } @@ -484,20 +572,26 @@ public struct ContactUserPreferences: Decodable, Hashable { public var fullDelete: ContactUserPreference public var reactions: ContactUserPreference public var voice: ContactUserPreference + public var files: ContactUserPreference public var calls: ContactUserPreference + public var commands: [ChatBotCommand]? public init( timedMessages: ContactUserPreference, fullDelete: ContactUserPreference, reactions: ContactUserPreference, voice: ContactUserPreference, - calls: ContactUserPreference + files: ContactUserPreference, + calls: ContactUserPreference, + commands: [ChatBotCommand]? ) { self.timedMessages = timedMessages self.fullDelete = fullDelete self.reactions = reactions self.voice = voice + self.files = files self.calls = calls + self.commands = commands } public static let sampleData = ContactUserPreferences( @@ -521,11 +615,17 @@ public struct ContactUserPreferences: Decodable, Hashable { userPreference: ContactUserPref.user(preference: SimplePreference(allow: .yes)), contactPreference: SimplePreference(allow: .yes) ), + files: ContactUserPreference( + enabled: FeatureEnabled(forUser: true, forContact: true), + userPreference: ContactUserPref.user(preference: SimplePreference(allow: .yes)), + contactPreference: SimplePreference(allow: .yes) + ), calls: ContactUserPreference( enabled: FeatureEnabled(forUser: true, forContact: true), userPreference: ContactUserPref.user(preference: SimplePreference(allow: .yes)), contactPreference: SimplePreference(allow: .yes) - ) + ), + commands: nil ) } @@ -598,6 +698,7 @@ public enum ChatFeature: String, Decodable, Feature, Hashable { case fullDelete case reactions case voice + case files case calls public var id: Self { self } @@ -624,6 +725,7 @@ public enum ChatFeature: String, Decodable, Feature, Hashable { case .fullDelete: return NSLocalizedString("Delete for everyone", comment: "chat feature") case .reactions: return NSLocalizedString("Message reactions", comment: "chat feature") case .voice: return NSLocalizedString("Voice messages", comment: "chat feature") + case .files: return NSLocalizedString("Files and media", comment: "chat feature") case .calls: return NSLocalizedString("Audio/video calls", comment: "chat feature") } } @@ -634,6 +736,7 @@ public enum ChatFeature: String, Decodable, Feature, Hashable { case .fullDelete: return "trash.slash" case .reactions: return "face.smiling" case .voice: return "mic" + case .files: return "doc" case .calls: return "phone" } } @@ -644,6 +747,7 @@ public enum ChatFeature: String, Decodable, Feature, Hashable { case .fullDelete: return "trash.slash.fill" case .reactions: return "face.smiling.fill" case .voice: return "mic.fill" + case .files: return "doc.fill" case .calls: return "phone.fill" } } @@ -681,6 +785,12 @@ public enum ChatFeature: String, Decodable, Feature, Hashable { case .yes: return "Allow voice messages only if your contact allows them." case .no: return "Prohibit sending voice messages." } + case .files: + switch allowed { + case .always: return "Allow your contacts to send files and media." + case .yes: return "Allow files and media only if your contact allows them." + case .no: return "Prohibit sending files and media." + } case .calls: switch allowed { case .always: return "Allow your contacts to call you." @@ -724,6 +834,14 @@ public enum ChatFeature: String, Decodable, Feature, Hashable { : enabled.forContact ? "Only your contact can send voice messages." : "Voice messages are prohibited in this chat." + case .files: + return enabled.forUser && enabled.forContact + ? "Both you and your contact can send files and media." + : enabled.forUser + ? "Only you can send files and media." + : enabled.forContact + ? "Only your contact can send files and media." + : "Files and media are prohibited in this chat." case .calls: return enabled.forUser && enabled.forContact ? "Both you and your contact can make calls." @@ -957,7 +1075,9 @@ public struct ContactFeaturesAllowed: Equatable, Hashable { public var fullDelete: ContactFeatureAllowed public var reactions: ContactFeatureAllowed public var voice: ContactFeatureAllowed + public var files: ContactFeatureAllowed public var calls: ContactFeatureAllowed + public var commands: [ChatBotCommand]? public init( timedMessagesAllowed: Bool, @@ -965,14 +1085,18 @@ public struct ContactFeaturesAllowed: Equatable, Hashable { fullDelete: ContactFeatureAllowed, reactions: ContactFeatureAllowed, voice: ContactFeatureAllowed, - calls: ContactFeatureAllowed + files: ContactFeatureAllowed, + calls: ContactFeatureAllowed, + commands: [ChatBotCommand]? ) { self.timedMessagesAllowed = timedMessagesAllowed self.timedMessagesTTL = timedMessagesTTL self.fullDelete = fullDelete self.reactions = reactions self.voice = voice + self.files = files self.calls = calls + self.commands = commands } public static let sampleData = ContactFeaturesAllowed( @@ -981,7 +1105,9 @@ public struct ContactFeaturesAllowed: Equatable, Hashable { fullDelete: ContactFeatureAllowed.userDefault(.no), reactions: ContactFeatureAllowed.userDefault(.yes), voice: ContactFeatureAllowed.userDefault(.yes), - calls: ContactFeatureAllowed.userDefault(.yes) + files: ContactFeatureAllowed.userDefault(.always), + calls: ContactFeatureAllowed.userDefault(.yes), + commands: nil ) } @@ -994,7 +1120,9 @@ public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactU fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete), reactions: contactUserPrefToFeatureAllowed(contactUserPreferences.reactions), voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice), - calls: contactUserPrefToFeatureAllowed(contactUserPreferences.calls) + files: contactUserPrefToFeatureAllowed(contactUserPreferences.files), + calls: contactUserPrefToFeatureAllowed(contactUserPreferences.calls), + commands: contactUserPreferences.commands ) } @@ -1016,7 +1144,9 @@ public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeatu fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete), reactions: contactFeatureAllowedToPref(contactFeaturesAllowed.reactions), voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice), - calls: contactFeatureAllowedToPref(contactFeaturesAllowed.calls) + files: contactFeatureAllowedToPref(contactFeaturesAllowed.files), + calls: contactFeatureAllowedToPref(contactFeaturesAllowed.calls), + commands: contactFeaturesAllowed.commands ) } @@ -1057,6 +1187,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { public var simplexLinks: RoleGroupPreference public var reports: GroupPreference public var history: GroupPreference + public var commands: [ChatBotCommand] public init( timedMessages: TimedMessagesGroupPreference, @@ -1067,7 +1198,8 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { files: RoleGroupPreference, simplexLinks: RoleGroupPreference, reports: GroupPreference, - history: GroupPreference + history: GroupPreference, + commands: [ChatBotCommand] ) { self.timedMessages = timedMessages self.directMessages = directMessages @@ -1078,6 +1210,7 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { self.simplexLinks = simplexLinks self.reports = reports self.history = history + self.commands = commands } public static let sampleData = FullGroupPreferences( @@ -1089,7 +1222,8 @@ public struct FullGroupPreferences: Decodable, Equatable, Hashable { files: RoleGroupPreference(enable: .on, role: nil), simplexLinks: RoleGroupPreference(enable: .on, role: nil), reports: GroupPreference(enable: .on), - history: GroupPreference(enable: .on) + history: GroupPreference(enable: .on), + commands: [] ) } @@ -1103,6 +1237,7 @@ public struct GroupPreferences: Codable, Hashable { public var simplexLinks: RoleGroupPreference? public var reports: GroupPreference? public var history: GroupPreference? + public var commands: [ChatBotCommand]? public init( timedMessages: TimedMessagesGroupPreference? = nil, @@ -1113,7 +1248,8 @@ public struct GroupPreferences: Codable, Hashable { files: RoleGroupPreference? = nil, simplexLinks: RoleGroupPreference? = nil, reports: GroupPreference? = nil, - history: GroupPreference? = nil + history: GroupPreference? = nil, + commands: [ChatBotCommand]? = nil ) { self.timedMessages = timedMessages self.directMessages = directMessages @@ -1124,6 +1260,7 @@ public struct GroupPreferences: Codable, Hashable { self.simplexLinks = simplexLinks self.reports = reports self.history = history + self.commands = commands } public static let sampleData = GroupPreferences( @@ -1135,7 +1272,8 @@ public struct GroupPreferences: Codable, Hashable { files: RoleGroupPreference(enable: .on, role: nil), simplexLinks: RoleGroupPreference(enable: .on, role: nil), reports: GroupPreference(enable: .on), - history: GroupPreference(enable: .on) + history: GroupPreference(enable: .on), + commands: nil ) } @@ -1149,7 +1287,8 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group files: fullPreferences.files, simplexLinks: fullPreferences.simplexLinks, reports: fullPreferences.reports, - history: fullPreferences.history + history: fullPreferences.history, + commands: fullPreferences.commands ) } @@ -1368,6 +1507,19 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var sndReady: Bool { + switch self { + case let .direct(contact): contact.sndReady + case let .group(groupInfo, groupScope): + groupInfo.membership.memberActive + && (groupScope != nil || (!groupInfo.membership.memberPending && groupInfo.membership.memberRole != .observer)) + case .local: true + case .contactRequest: false + case .contactConnection: false + case .invalidJSON: false + } + } + public var chatDeleted: Bool { get { switch self { @@ -1502,6 +1654,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .fullDelete: return cups.fullDelete.enabled.forUser case .reactions: return cups.reactions.enabled.forUser case .voice: return cups.voice.enabled.forUser + case .files: return cups.files.enabled.forUser case .calls: return cups.calls.enabled.forUser } case let .group(groupInfo, _): @@ -1511,12 +1664,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .fullDelete: return prefs.fullDelete.on case .reactions: return prefs.reactions.on case .voice: return prefs.voice.on(for: groupInfo.membership) + case .files: return prefs.files.on(for: groupInfo.membership) case .calls: return false } case .local: switch feature { + case .timedMessages: return false + case .fullDelete: return false + case .reactions: return false case .voice: return true - default: return false + case .files: return true + case .calls: return false } default: return false } @@ -1619,6 +1777,22 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { if case .group = self { true } else { false } } + public var useCommands: Bool { + switch self { + case let .direct(c): c.isBot + case let .group(g, _): (g.groupProfile.groupPreferences?.commands?.count ?? 0) > 0 + default: false + } + } + + public var menuCommands: [ChatBotCommand] { + switch self { + case let .direct(c): c.isBot ? c.profile.preferences?.commands ?? [] : [] + case let .group(g, _): g.groupProfile.groupPreferences?.commands ?? [] + default: [] + } + } + public var chatTags: [Int64]? { switch self { case let .direct(contact): return contact.chatTags @@ -1803,16 +1977,26 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { (activeConn == nil || activeConn?.connStatus == .prepared) && profile.contactLink != nil && active && preparedContact == nil && contactRequestId == nil } + @inline(__always) + public var isBot: Bool { + profile.peerType == .bot + } + public var contactConnIncognito: Bool { activeConn?.customUserProfileId != nil } + public var chatIconName: String { + isBot ? "cube.fill" : "person.crop.circle.fill" + } + public func allowsFeature(_ feature: ChatFeature) -> Bool { switch feature { case .timedMessages: return mergedPreferences.timedMessages.contactPreference.allow != .no case .fullDelete: return mergedPreferences.fullDelete.contactPreference.allow != .no case .reactions: return mergedPreferences.reactions.contactPreference.allow != .no case .voice: return mergedPreferences.voice.contactPreference.allow != .no + case .files: return mergedPreferences.files.contactPreference.allow != .no case .calls: return mergedPreferences.calls.contactPreference.allow != .no } } @@ -1823,6 +2007,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { case .fullDelete: return mergedPreferences.fullDelete.userPreference.preference.allow != .no case .reactions: return mergedPreferences.reactions.userPreference.preference.allow != .no case .voice: return mergedPreferences.voice.userPreference.preference.allow != .no + case .files: return mergedPreferences.files.userPreference.preference.allow != .no case .calls: return mergedPreferences.calls.userPreference.preference.allow != .no } } @@ -4446,6 +4631,7 @@ public enum Format: Decodable, Equatable, Hashable { case colored(color: FormatColor) case uri case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) + case command(commandStr: String) case mention(memberName: String) case email case phone diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 2016094958..98ee9cd5d4 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -93,7 +93,7 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { public func chatIconName(_ cInfo: ChatInfo) -> String { switch cInfo { - case .direct: "person.crop.circle.fill" + case let .direct(contact): contact.chatIconName case let .group(groupInfo, _): groupInfo.chatIconName case .local: "folder.circle.fill" case .contactRequest: "person.crop.circle.fill" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index d61e44f528..962bd82dd3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1633,6 +1633,18 @@ sealed class ChatInfo: SomeChat, NamedChat { val sendMsgEnabled get() = userCantSendReason == null + val sndReady: Boolean get() = + when(this) { + is Direct -> contact.sndReady + is Group -> + groupInfo.membership.memberActive + && (groupChatScope != null || (!groupInfo.membership.memberPending && groupInfo.membership.memberRole != GroupMemberRole.Observer)) + is Local -> true + is ContactRequest -> false + is ContactConnection -> false + is InvalidJSON -> false + } + fun groupChatScope(): GroupChatScope? = when (this) { is Group -> groupChatScope?.toChatScope() else -> null @@ -1676,6 +1688,20 @@ sealed class ChatInfo: SomeChat, NamedChat { val hasMentions: Boolean get() = this is Group + val useCommands: Boolean get() = when(this) { + is Direct -> contact.isBot + is Group -> groupInfo.groupProfile.groupPreferences?.commands?.isNotEmpty() ?: false + else -> false + } + + val menuCommands: List get() = when(this) { + is Direct -> + if (contact.isBot) contact.profile.preferences?.commands ?: emptyList() + else emptyList() + is Group -> groupInfo.groupProfile.groupPreferences?.commands ?: emptyList() + else -> emptyList() + } + val contactCard: Boolean get() = when (this) { is Direct -> contact.isContactCard @@ -1759,6 +1785,7 @@ data class Contact( ChatFeature.FullDelete -> mergedPreferences.fullDelete.enabled.forUser ChatFeature.Reactions -> mergedPreferences.reactions.enabled.forUser ChatFeature.Voice -> mergedPreferences.voice.enabled.forUser + ChatFeature.Files -> mergedPreferences.files.enabled.forUser ChatFeature.Calls -> mergedPreferences.calls.enabled.forUser } override val timedMessagesTTL: Int? get() = with(mergedPreferences.timedMessages) { if (enabled.forUser) userPreference.pref.ttl else null } @@ -1775,7 +1802,6 @@ data class Contact( return profile.chatViewName.lowercase().contains(s) || profile.displayName.lowercase().contains(s) || profile.fullName.lowercase().contains(s) } - val directOrUsed: Boolean get() = if (activeConn != null) { (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed @@ -1783,16 +1809,22 @@ data class Contact( true } - val isContactCard: Boolean = + val isContactCard: Boolean get() = (activeConn == null || activeConn.connStatus == ConnStatus.Prepared) && profile.contactLink != null && active && preparedContact == null && contactRequestId == null - val contactConnIncognito = + val isBot: Boolean get() = profile.peerType == ChatPeerType.Bot + + val contactConnIncognito: Boolean get() = activeConn?.customUserProfileId != null + val chatIconName: ImageResource get() = + if (isBot) MR.images.ic_cube else MR.images.ic_account_circle_filled + fun allowsFeature(feature: ChatFeature): Boolean = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO ChatFeature.FullDelete -> mergedPreferences.fullDelete.contactPreference.allow != FeatureAllowed.NO ChatFeature.Voice -> mergedPreferences.voice.contactPreference.allow != FeatureAllowed.NO + ChatFeature.Files -> mergedPreferences.files.contactPreference.allow != FeatureAllowed.NO ChatFeature.Reactions -> mergedPreferences.reactions.contactPreference.allow != FeatureAllowed.NO ChatFeature.Calls -> mergedPreferences.calls.contactPreference.allow != FeatureAllowed.NO } @@ -1802,6 +1834,7 @@ data class Contact( ChatFeature.FullDelete -> mergedPreferences.fullDelete.userPreference.pref.allow != FeatureAllowed.NO ChatFeature.Reactions -> mergedPreferences.reactions.userPreference.pref.allow != FeatureAllowed.NO ChatFeature.Voice -> mergedPreferences.voice.userPreference.pref.allow != FeatureAllowed.NO + ChatFeature.Files -> mergedPreferences.files.userPreference.pref.allow != FeatureAllowed.NO ChatFeature.Calls -> mergedPreferences.calls.userPreference.pref.allow != FeatureAllowed.NO } @@ -1931,14 +1964,15 @@ data class Profile( override val image: String? = null, override val localAlias : String = "", val contactLink: String? = null, - val preferences: ChatPreferences? = null + val preferences: ChatPreferences? = null, + val peerType: ChatPeerType? = null ): NamedChat { val profileViewName: String get() { return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } - fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences) + fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType) companion object { val sampleData = Profile( @@ -1958,11 +1992,12 @@ data class LocalProfile( override val image: String? = null, override val localAlias: String, val contactLink: String? = null, - val preferences: ChatPreferences? = null + val preferences: ChatPreferences? = null, + val peerType: ChatPeerType? = null ): NamedChat { val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } - fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences) + fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType) companion object { val sampleData = LocalProfile( @@ -1976,6 +2011,12 @@ data class LocalProfile( } } +@Serializable +enum class ChatPeerType { + @SerialName("human") Human, + @SerialName("bot") Bot +} + @Serializable data class UserProfileUpdateSummary( val updateSuccesses: Int, @@ -2030,6 +2071,7 @@ data class GroupInfo ( ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on ChatFeature.Reactions -> fullGroupPreferences.reactions.on ChatFeature.Voice -> fullGroupPreferences.voice.on(membership) + ChatFeature.Files -> fullGroupPreferences.files.on(membership) ChatFeature.Calls -> false } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } @@ -2484,7 +2526,14 @@ class NoteFolder( override val nextConnectPrepared get() = false override val profileChangeProhibited get() = false override val incognito get() = false - override fun featureEnabled(feature: ChatFeature) = feature == ChatFeature.Voice + override fun featureEnabled(feature: ChatFeature) = when (feature) { + ChatFeature.TimedMessages -> false + ChatFeature.FullDelete -> false + ChatFeature.Reactions -> false + ChatFeature.Voice -> true + ChatFeature.Files -> true + ChatFeature.Calls -> false + } override val timedMessagesTTL: Int? get() = null override val displayName get() = generalGetString(MR.strings.note_folder_local_display_name) override val fullName get() = "" @@ -4349,6 +4398,7 @@ sealed class Format { @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() + @Serializable @SerialName("command") class Command(val commandStr: String): Format() @Serializable @SerialName("mention") class Mention(val memberName: String): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() @@ -4363,6 +4413,7 @@ sealed class Format { is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle is SimplexLink -> linkStyle + is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace) is Mention -> SpanStyle(fontWeight = FontWeight.Medium) is Email -> linkStyle is Phone -> linkStyle diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 9e10d249c0..b23869849d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex +import chat.simplex.common.model.GroupFeature.Files import chat.simplex.common.model.MsgContent.MCUnknown import chat.simplex.common.model.SMPProxyFallback.AllowProtected import chat.simplex.common.model.SMPProxyMode.Always @@ -2643,8 +2644,6 @@ object ChatController { if (cItem.isActiveReport) { chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } - } - withContext(Dispatchers.Main) { chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) } } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { @@ -4881,14 +4880,18 @@ data class FullChatPreferences( val fullDelete: SimpleChatPreference, val reactions: SimpleChatPreference, val voice: SimpleChatPreference, + val files: SimpleChatPreference, val calls: SimpleChatPreference, + val commands: List ) { fun toPreferences(): ChatPreferences = ChatPreferences( timedMessages = timedMessages, fullDelete = fullDelete, reactions = reactions, voice = voice, - calls = calls + files = files, + calls = calls, + commands = commands, ) companion object { @@ -4897,7 +4900,9 @@ data class FullChatPreferences( fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO), reactions = SimpleChatPreference(allow = FeatureAllowed.YES), voice = SimpleChatPreference(allow = FeatureAllowed.YES), + files = SimpleChatPreference(allow = FeatureAllowed.YES), calls = SimpleChatPreference(allow = FeatureAllowed.YES), + commands = listOf(), ) } } @@ -4908,7 +4913,9 @@ data class ChatPreferences( val fullDelete: SimpleChatPreference?, val reactions: SimpleChatPreference?, val voice: SimpleChatPreference?, + val files: SimpleChatPreference?, val calls: SimpleChatPreference?, + val commands: List?, ) { fun setAllowed(feature: ChatFeature, allowed: FeatureAllowed = FeatureAllowed.YES, param: Int? = null): ChatPreferences = when (feature) { @@ -4916,6 +4923,7 @@ data class ChatPreferences( ChatFeature.FullDelete -> this.copy(fullDelete = SimpleChatPreference(allow = allowed)) ChatFeature.Reactions -> this.copy(reactions = SimpleChatPreference(allow = allowed)) ChatFeature.Voice -> this.copy(voice = SimpleChatPreference(allow = allowed)) + ChatFeature.Files -> this.copy(files = SimpleChatPreference(allow = allowed)) ChatFeature.Calls -> this.copy(calls = SimpleChatPreference(allow = allowed)) } @@ -4925,7 +4933,9 @@ data class ChatPreferences( fullDelete = SimpleChatPreference(allow = FeatureAllowed.NO), reactions = SimpleChatPreference(allow = FeatureAllowed.YES), voice = SimpleChatPreference(allow = FeatureAllowed.YES), + files = SimpleChatPreference(allow = FeatureAllowed.YES), calls = SimpleChatPreference(allow = FeatureAllowed.YES), + commands = null, ) } } @@ -4953,6 +4963,12 @@ data class TimedMessagesPreference( } } +@Serializable +sealed class ChatBotCommand { + @Serializable @SerialName("command") class Command(val keyword: String, val label: String, val params: String?): ChatBotCommand() + @Serializable @SerialName("menu") class Menu(val label: String, val commands: List): ChatBotCommand() +} + @Serializable data class PresentedServersSummary( val statsStartedAt: Instant, @@ -5203,14 +5219,18 @@ data class ContactUserPreferences( val fullDelete: ContactUserPreference, val reactions: ContactUserPreference, val voice: ContactUserPreference, + val files: ContactUserPreference, val calls: ContactUserPreference, + val commands: List?, ) { fun toPreferences(): ChatPreferences = ChatPreferences( timedMessages = timedMessages.userPreference.pref, fullDelete = fullDelete.userPreference.pref, reactions = reactions.userPreference.pref, voice = voice.userPreference.pref, - calls = calls.userPreference.pref + files = files.userPreference.pref, + calls = calls.userPreference.pref, + commands = commands, ) companion object { @@ -5235,11 +5255,17 @@ data class ContactUserPreferences( userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)), contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES) ), + files = ContactUserPreference( + enabled = FeatureEnabled(forUser = true, forContact = true), + userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)), + contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES) + ), calls = ContactUserPreference( enabled = FeatureEnabled(forUser = true, forContact = true), userPreference = ContactUserPref.User(preference = SimpleChatPreference(allow = FeatureAllowed.YES)), contactPreference = SimpleChatPreference(allow = FeatureAllowed.YES) ), + commands = null, ) } } @@ -5329,6 +5355,7 @@ enum class ChatFeature: Feature { @SerialName("fullDelete") FullDelete, @SerialName("reactions") Reactions, @SerialName("voice") Voice, + @SerialName("files") Files, @SerialName("calls") Calls; val asymmetric: Boolean get() = when (this) { @@ -5348,6 +5375,7 @@ enum class ChatFeature: Feature { FullDelete -> generalGetString(MR.strings.full_deletion) Reactions -> generalGetString(MR.strings.message_reactions) Voice -> generalGetString(MR.strings.voice_messages) + Files -> generalGetString(MR.strings.files_and_media) Calls -> generalGetString(MR.strings.audio_video_calls) } @@ -5357,6 +5385,7 @@ enum class ChatFeature: Feature { FullDelete -> painterResource(MR.images.ic_delete_forever) Reactions -> painterResource(MR.images.ic_add_reaction) Voice -> painterResource(MR.images.ic_keyboard_voice) + Files -> painterResource(MR.images.ic_draft) Calls -> painterResource(MR.images.ic_call) } @@ -5366,6 +5395,7 @@ enum class ChatFeature: Feature { FullDelete -> painterResource(MR.images.ic_delete_forever_filled) Reactions -> painterResource(MR.images.ic_add_reaction_filled) Voice -> painterResource(MR.images.ic_keyboard_voice_filled) + Files -> painterResource(MR.images.ic_draft_filled) Calls -> painterResource(MR.images.ic_call_filled) } @@ -5386,11 +5416,16 @@ enum class ChatFeature: Feature { FeatureAllowed.YES -> generalGetString(MR.strings.allow_message_reactions_only_if) FeatureAllowed.NO -> generalGetString(MR.strings.prohibit_message_reactions) } - Voice -> when (allowed) { + Voice -> when (allowed) { FeatureAllowed.ALWAYS -> generalGetString(MR.strings.allow_your_contacts_to_send_voice_messages) FeatureAllowed.YES -> generalGetString(MR.strings.allow_voice_messages_only_if) FeatureAllowed.NO -> generalGetString(MR.strings.prohibit_sending_voice_messages) } + Files -> when (allowed) { + FeatureAllowed.ALWAYS -> generalGetString(MR.strings.allow_your_contacts_to_send_files_and_media) + FeatureAllowed.YES -> generalGetString(MR.strings.allow_files_and_media_only_if) + FeatureAllowed.NO -> generalGetString(MR.strings.prohibit_sending_files_and_media) + } Calls -> when (allowed) { FeatureAllowed.ALWAYS -> generalGetString(MR.strings.allow_your_contacts_to_call) FeatureAllowed.YES -> generalGetString(MR.strings.allow_calls_only_if) @@ -5424,6 +5459,12 @@ enum class ChatFeature: Feature { enabled.forContact -> generalGetString(MR.strings.only_your_contact_can_send_voice) else -> generalGetString(MR.strings.voice_prohibited_in_this_chat) } + Files -> when { + enabled.forUser && enabled.forContact -> generalGetString(MR.strings.both_you_and_your_contact_can_send_files) + enabled.forUser -> generalGetString(MR.strings.only_you_can_send_files) + enabled.forContact -> generalGetString(MR.strings.only_your_contact_can_send_files) + else -> generalGetString(MR.strings.files_prohibited_in_this_chat) + } Calls -> when { enabled.forUser && enabled.forContact -> generalGetString(MR.strings.both_you_and_your_contact_can_make_calls) enabled.forUser -> generalGetString(MR.strings.only_you_can_make_calls) @@ -5618,7 +5659,9 @@ data class ContactFeaturesAllowed( val fullDelete: ContactFeatureAllowed, val reactions: ContactFeatureAllowed, val voice: ContactFeatureAllowed, + val files: ContactFeatureAllowed, val calls: ContactFeatureAllowed, + val commands: List?, ) { companion object { val sampleData = ContactFeaturesAllowed( @@ -5627,7 +5670,9 @@ data class ContactFeaturesAllowed( fullDelete = ContactFeatureAllowed.UserDefault(FeatureAllowed.NO), reactions = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES), voice = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES), + files = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES), calls = ContactFeatureAllowed.UserDefault(FeatureAllowed.YES), + commands = null, ) } } @@ -5641,7 +5686,9 @@ fun contactUserPrefsToFeaturesAllowed(contactUserPreferences: ContactUserPrefere fullDelete = contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete), reactions = contactUserPrefToFeatureAllowed(contactUserPreferences.reactions), voice = contactUserPrefToFeatureAllowed(contactUserPreferences.voice), + files = contactUserPrefToFeatureAllowed(contactUserPreferences.files), calls = contactUserPrefToFeatureAllowed(contactUserPreferences.calls), + commands = contactUserPreferences.commands, ) } @@ -5661,7 +5708,9 @@ fun contactFeaturesAllowedToPrefs(contactFeaturesAllowed: ContactFeaturesAllowed fullDelete = contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete), reactions = contactFeatureAllowedToPref(contactFeaturesAllowed.reactions), voice = contactFeatureAllowedToPref(contactFeaturesAllowed.voice), + files = contactFeatureAllowedToPref(contactFeaturesAllowed.files), calls = contactFeatureAllowedToPref(contactFeaturesAllowed.calls), + commands = contactFeaturesAllowed.commands, ) fun contactFeatureAllowedToPref(contactFeatureAllowed: ContactFeatureAllowed): SimpleChatPreference? = @@ -5697,6 +5746,7 @@ data class FullGroupPreferences( val simplexLinks: RoleGroupPreference, val reports: GroupPreference, val history: GroupPreference, + val commands: List, ) { fun toGroupPreferences(): GroupPreferences = GroupPreferences( @@ -5709,6 +5759,7 @@ data class FullGroupPreferences( simplexLinks = simplexLinks, reports = reports, history = history, + commands = commands, ) companion object { @@ -5722,6 +5773,7 @@ data class FullGroupPreferences( simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), + commands = listOf() ) } } @@ -5737,6 +5789,7 @@ data class GroupPreferences( val simplexLinks: RoleGroupPreference? = null, val reports: GroupPreference? = null, val history: GroupPreference? = null, + val commands: List? = null ) { companion object { val sampleData = GroupPreferences( @@ -5749,6 +5802,7 @@ data class GroupPreferences( simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), reports = GroupPreference(GroupFeatureEnabled.ON), history = GroupPreference(GroupFeatureEnabled.ON), + commands = null, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index d19c19b83a..a3365b2087 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -102,19 +102,21 @@ fun ChatView( val chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } - val activeChatInfo = remember { derivedStateOf { - var chatInfo = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo + val activeChat = remember { derivedStateOf { + var chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } + val chatInfo = chat?.chatInfo if ( chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + && chat != null && chatInfo is ChatInfo.Group ) { val scopeInfo = chatsCtx.secondaryContextFilter.groupScopeInfo - chatInfo = chatInfo.copy(groupChatScope = scopeInfo) + chat = chat.copy(chatInfo = chatInfo.copy(groupChatScope = scopeInfo)) } - chatInfo + chat } } val user = chatModel.currentUser.value - val chatInfo = activeChatInfo.value + val chatInfo = activeChat.value?.chatInfo if (chat == null || chatInfo == null || user == null) { LaunchedEffect(Unit) { chatModel.chatId.value = null @@ -138,6 +140,7 @@ fun ChatView( val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val selectedChatItems = rememberSaveable { mutableStateOf(null as Set?) } + val showCommandsMenu = rememberSaveable { mutableStateOf(false) } if (appPlatform.isAndroid) { DisposableEffect(Unit) { onDispose { @@ -186,7 +189,6 @@ fun ChatView( chatsCtx.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } - val reportsCount = reportsCount(chatInfo.id) val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), @@ -214,7 +216,7 @@ fun ChatView( ChatLayout( chatsCtx = chatsCtx, remoteHostId = remoteHostId, - chatInfo = activeChatInfo, + chat = activeChat, unreadCount, composeState, composeView = { focusRequester -> @@ -237,7 +239,7 @@ fun ChatView( ) } ComposeView( - rhId = remoteHostId.value, chatModel, chatsCtx, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, + rhId = remoteHostId.value, chatModel, chatsCtx, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, showCommandsMenu, attachmentOption, showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }, focusRequester = focusRequester ) @@ -347,7 +349,7 @@ fun ChatView( ModalManager.end.showCustomModal { close -> val appBar = remember { mutableStateOf(null as @Composable (BoxScope.() -> Unit)?) } ModalView(close, appBar = appBar.value) { - val chatInfo = remember { activeChatInfo }.value + val chatInfo = remember { activeChat }.value?.chatInfo if (chatInfo is ChatInfo.Direct) { var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } var code: String? by remember { mutableStateOf(preloadedCode) } @@ -377,7 +379,7 @@ fun ChatView( } } LaunchedEffect(Unit) { - snapshotFlow { activeChatInfo.value?.id } + snapshotFlow { activeChat.value?.id } .drop(1) .collect { appBar.value = null @@ -389,37 +391,37 @@ fun ChatView( } }, showReports = { - val info = activeChatInfo.value ?: return@ChatLayout + val cInfo = activeChat.value?.chatInfo ?: return@ChatLayout if (ModalManager.end.hasModalsOpen()) { ModalManager.end.closeModals() return@ChatLayout } hideKeyboard(view) scope.launch { - showGroupReportsView(staleChatId, scrollToItemId, info) + showGroupReportsView(staleChatId, scrollToItemId, cInfo) } }, showSupportChats = { - val info = activeChatInfo.value ?: return@ChatLayout + val cInfo = activeChat.value?.chatInfo ?: return@ChatLayout if (ModalManager.end.hasModalsOpen()) { ModalManager.end.closeModals() return@ChatLayout } hideKeyboard(view) scope.launch { - if (info is ChatInfo.Group && info.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + if (cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ModalManager.end.showCustomModal { close -> MemberSupportView( chatRh, chat, - info.groupInfo, + cInfo.groupInfo, scrollToItemId, close ) } - } else if (info is ChatInfo.Group) { + } else if (cInfo is ChatInfo.Group) { val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) - val supportChatInfo = ChatInfo.Group(info.groupInfo, groupChatScope = scopeInfo) + val supportChatInfo = ChatInfo.Group(cInfo.groupInfo, groupChatScope = scopeInfo) scope.launch { showMemberSupportChatView( chatModel.chatId, @@ -723,7 +725,8 @@ fun ChatView( onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), - showSearch = showSearch + showSearch = showSearch, + showCommandsMenu = showCommandsMenu ) } } @@ -768,9 +771,7 @@ private fun connectingText(chatInfo: ChatInfo): String? { && !chatInfo.contact.sendMsgToConnect && !chatInfo.contact.nextAcceptContactRequest ) { - if (chatInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) { - generalGetString(MR.strings.contact_should_accept) - } else if (chatInfo.contact.contactGroupMemberId != null) { + if ((chatInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con && !chatInfo.contact.isBot) || chatInfo.contact.contactGroupMemberId != null) { generalGetString(MR.strings.contact_should_accept) } else { generalGetString(MR.strings.contact_connection_pending) @@ -807,7 +808,7 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) fun ChatLayout( chatsCtx: ChatModel.ChatsContext, remoteHostId: State, - chatInfo: State, + chat: State, unreadCount: State, composeState: MutableState, composeView: (@Composable (FocusRequester?) -> Unit), @@ -854,8 +855,10 @@ fun ChatLayout( onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, showViaProxy: Boolean, - showSearch: MutableState + showSearch: MutableState, + showCommandsMenu: MutableState ) { + val chatInfo = remember { derivedStateOf { chat.value?.chatInfo } } val scope = rememberCoroutineScope() val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } Box( @@ -886,19 +889,20 @@ fun ChatLayout( val composeViewHeight = remember { mutableStateOf(0.dp) } Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.secondaryContextFilter == null)) { val remoteHostId = remember { remoteHostId }.value - val chatInfo = remember { chatInfo }.value + val chat = remember { chat }.value + val chatInfo = chat?.chatInfo val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { - if (chatInfo != null) { + if (chat != null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - chatsCtx, remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, + chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, @@ -920,6 +924,11 @@ fun ChatLayout( ) } } + if (chatInfo != null && chatInfo.menuCommands.isNotEmpty()) { + Column(Modifier.align(Alignment.BottomStart).padding(bottom = composeViewHeight.value)) { + CommandsMenuView(chatsCtx, chat, composeState, showCommandsMenu) + } + } } } if (chatsCtx.contentTag == MsgContentTag.Report) { @@ -1362,7 +1371,7 @@ private var reportsListState: LazyListState? = null fun BoxScope.ChatItemsList( chatsCtx: ChatModel.ChatsContext, remoteHostId: Long?, - chatInfo: ChatInfo, + chat: Chat, unreadCount: State, composeState: MutableState, composeViewHeight: State, @@ -1399,6 +1408,7 @@ fun BoxScope.ChatItemsList( developerTools: Boolean, showViaProxy: Boolean ) { + val chatInfo = chat.chatInfo val loadingTopItems = remember { mutableStateOf(false) } val loadingBottomItems = remember { mutableStateOf(false) } // just for changing local var here based on request @@ -1561,7 +1571,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @@ -1769,10 +1779,12 @@ fun BoxScope.ChatItemsList( if (contact.nextConnectPrepared && preparedLinkType != null) { when (preparedLinkType) { ConnectionMode.Inv -> generalGetString(MR.strings.chat_banner_connect_to_chat) - ConnectionMode.Con -> generalGetString(MR.strings.chat_banner_send_request_to_connect) + ConnectionMode.Con -> generalGetString(if (contact.isBot) MR.strings.chat_banner_connect_to_use_bot else MR.strings.chat_banner_send_request_to_connect) } } else if (contact.nextAcceptContactRequest) { generalGetString(MR.strings.chat_banner_accept_contact_request) + } else if (contact.isBot) { + generalGetString(MR.strings.chat_banner_bot) } else { generalGetString(MR.strings.chat_banner_your_contact) } @@ -3304,7 +3316,7 @@ fun PreviewChatLayout() { ChatLayout( chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), remoteHostId = remember { mutableStateOf(null) }, - chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, + chat = remember { mutableStateOf(Chat.sampleData) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = { _ -> }, @@ -3351,7 +3363,8 @@ fun PreviewChatLayout() { onComposed = {}, developerTools = false, showViaProxy = false, - showSearch = remember { mutableStateOf(false) } + showSearch = remember { mutableStateOf(false) }, + showCommandsMenu = remember { mutableStateOf(false) } ) } } @@ -3383,7 +3396,7 @@ fun PreviewGroupChatLayout() { ChatLayout( chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), remoteHostId = remember { mutableStateOf(null) }, - chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, + chat = remember { mutableStateOf(Chat.sampleData) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = { _ -> }, @@ -3430,7 +3443,8 @@ fun PreviewGroupChatLayout() { onComposed = {}, developerTools = false, showViaProxy = false, - showSearch = remember { mutableStateOf(false) } + showSearch = remember { mutableStateOf(false) }, + showCommandsMenu = remember { mutableStateOf(false) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt new file mode 100644 index 0000000000..26b1fce741 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt @@ -0,0 +1,244 @@ +package chat.simplex.common.views.chat + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.group.* +import chat.simplex.common.views.chat.item.sendCommandMsg +import chat.simplex.common.views.helpers.commandMenuAnimSpec +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.launch + +private val COMMAND_MENU_ROW_SIZE = 48.dp +private val MAX_COMMAND_MENU_HEIGHT = COMMAND_MENU_ROW_SIZE * 6 - 8.dp + +@Composable +fun CommandsMenuView( + chatsCtx: ChatModel.ChatsContext, + chat: Chat, + composeState: MutableState, + showCommandsMenu: MutableState +) { + val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } + val offsetY = remember { Animatable(maxHeightInPx) } + val scope = rememberCoroutineScope() + + val currentCommands = remember { mutableStateOf>(emptyList()) } + val menuTreeBackPath = remember { mutableStateOf>>>(emptyList()) } + + fun filterShownCommands(commands: List, msg: CharSequence): List { + val cmds = mutableListOf() + for (cmd in commands) { + when (cmd) { + is ChatBotCommand.Command -> + if (cmd.keyword.startsWith(msg)) { + cmds.add(cmd) + } + is ChatBotCommand.Menu -> + cmds.addAll(filterShownCommands(cmd.commands, msg)) + } + } + return cmds + } + + suspend fun closeCommandsMenu() { + showCommandsMenu.value = false + currentCommands.value = emptyList() + menuTreeBackPath.value = emptyList() + if (offsetY.value != 0f) { + return + } + offsetY.animateTo( + targetValue = maxHeightInPx, + animationSpec = commandMenuAnimSpec() + ) + } + + fun messageChanged(message: String) { + val msg = message.trim() + menuTreeBackPath.value = emptyList() + if (msg == "/") { + currentCommands.value = chat.chatInfo.menuCommands + } else if (msg.startsWith("/")) { + currentCommands.value = filterShownCommands(chat.chatInfo.menuCommands, msg.drop(1)) + } else { + scope.launch { closeCommandsMenu() } + } + } + + LaunchedEffect(currentCommands.value.isNotEmpty()) { + if (currentCommands.value.isNotEmpty()) { + offsetY.animateTo( + targetValue = 0f, + animationSpec = commandMenuAnimSpec() + ) + } + } + + LaunchedEffect(composeState.value.message) { + messageChanged(composeState.value.message.text) + } + + LaunchedEffect(showCommandsMenu.value) { + if (showCommandsMenu.value) { + currentCommands.value = chat.chatInfo.menuCommands + menuTreeBackPath.value = emptyList() + } else { + closeCommandsMenu() + } + } + + @Composable + fun MenuLabelRow(prev: Pair>) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(COMMAND_MENU_ROW_SIZE) + .clickable { + if (menuTreeBackPath.value.isNotEmpty()) { + currentCommands.value = menuTreeBackPath.value.last().second + menuTreeBackPath.value = menuTreeBackPath.value.dropLast(1) + } + }, + contentAlignment = Alignment.Center + ) { + Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(MR.images.ic_arrow_back_ios_new), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Text( + text = prev.first, + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium, + maxLines = 1, + modifier = Modifier.weight(1f), + overflow = TextOverflow.Ellipsis + ) + } + } + } + + @Composable + fun CommandRow(cmd: ChatBotCommand) { + when (cmd) { + is ChatBotCommand.Command -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(COMMAND_MENU_ROW_SIZE) + .clickable { + if (cmd.params != null) { + val msg = "/${cmd.keyword} ${cmd.params}" + composeState.value = ComposeState(message = ComposeMessage(msg, TextRange(msg.length)), useLinkPreviews = true) + } else { + composeState.value = ComposeState(message = ComposeMessage(), useLinkPreviews = true) + sendCommandMsg(chatsCtx, chat,"/${cmd.keyword}") + } + scope.launch { closeCommandsMenu() } + }, + contentAlignment = Alignment.Center + ) { + Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { + Text( + text = cmd.label, + style = MaterialTheme.typography.body1, + maxLines = 1, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Start, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Text( + text = "/${cmd.keyword}", + style = MaterialTheme.typography.body2, + maxLines = 1, + color = MaterialTheme.colors.secondary + ) + } + } + } + is ChatBotCommand.Menu -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(COMMAND_MENU_ROW_SIZE) + .clickable { + menuTreeBackPath.value += Pair(cmd.label, currentCommands.value) + currentCommands.value = cmd.commands + }, + contentAlignment = Alignment.Center + ) { + Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { + Text( + text = cmd.label, + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Medium, + maxLines = 1, + modifier = Modifier.weight(1f), + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Icon( + painterResource(MR.images.ic_chevron_right), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + } + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, offsetY.value.toInt()) } + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { + scope.launch { closeCommandsMenu() } + }, + contentAlignment = Alignment.BottomStart + ) { + LazyColumnWithScrollBarNoAppBar( + Modifier + .heightIn(max = MAX_COMMAND_MENU_HEIGHT) + .background(MaterialTheme.colors.surface), + maxHeight = remember { mutableStateOf(MAX_COMMAND_MENU_HEIGHT) }, + containerAlignment = Alignment.BottomEnd + ) { + itemsIndexed(currentCommands.value, key = { i, cmd -> "$i ${cmd.hashCode()}" }) { i, command -> + if (i == 0) { + val prev = menuTreeBackPath.value.lastOrNull() + if (prev != null) { + Divider() + MenuLabelRow(prev) + } + } + Divider() + Box(Modifier.fillMaxWidth()) { CommandRow(command) } + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index ce55c62ae2..5f99ac77d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -17,7 +17,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextDecoration import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp @@ -345,6 +347,7 @@ fun ComposeView( chatsCtx: ChatModel.ChatsContext, chat: Chat, composeState: MutableState, + showCommandsMenu: MutableState, attachmentOption: MutableState, showChooseAttachment: () -> Unit, focusRequester: FocusRequester?, @@ -483,7 +486,7 @@ fun ComposeView( if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) + chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -593,7 +596,6 @@ fun ComposeView( } suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): List? { - val cInfo = chat.chatInfo val cs = composeState.value var sent: List? var lastMessageFailedToSend: ComposeState? = null @@ -1064,6 +1066,22 @@ fun ComposeView( val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason) val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) + @Composable + fun CommandsButton() { + val commandsEnabled = chat.chatInfo.sendMsgEnabled && chat.chatInfo.menuCommands.isNotEmpty() + IconButton( + onClick = { showCommandsMenu.value = !showCommandsMenu.value }, + enabled = commandsEnabled + ) { + Box( + modifier = Modifier.size(28.dp).clip(CircleShape), + contentAlignment = Alignment.Center + ) { + Text("//", style = MaterialTheme.typography.h3.copy(fontStyle = FontStyle.Italic, color = if (commandsEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary)) + } + } + } + @Composable fun AttachmentButton() { val isGroupAndProhibitedFiles = @@ -1087,7 +1105,6 @@ fun ComposeView( && !nextSendGrpInv.value IconButton( attachmentClicked, - Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), enabled = attachmentEnabled ) { Icon( @@ -1101,6 +1118,24 @@ fun ComposeView( } } + @Composable + fun AttachmentAndCommandsButtons() { + val cInfo = chat.chatInfo + Row( + Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), + horizontalArrangement = Arrangement.spacedBy((-8).dp) + ) { + val msg = composeState.value.message.text.trim() + val showAttachment = cInfo !is ChatInfo.Direct || cInfo.contact.profile.peerType != ChatPeerType.Bot || cInfo.featureEnabled(ChatFeature.Files) + if (cInfo.useCommands && (!showAttachment || msg.isEmpty() || msg.startsWith("/"))) { + CommandsButton() + } + if (showAttachment) { + AttachmentButton() + } + } + } + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } LaunchedEffect(allowedVoiceByPrefs) { if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { @@ -1280,7 +1315,7 @@ fun ComposeView( ) Text( text, - style = MaterialTheme.typography.caption, + style = MaterialTheme.typography.body2, color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } @@ -1433,7 +1468,7 @@ fun ComposeView( ContextSendMessageToConnect(generalGetString(MR.strings.compose_send_direct_message_to_connect)) Divider() Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - AttachmentButton() + AttachmentAndCommandsButtons() SendMsgView_( disableSendButton = disableSendButton, sendToConnect = { withApi { sendMemberContactInvitation() } } @@ -1453,11 +1488,19 @@ fun ComposeView( connect = { withApi { sendConnectPreparedContact() } } ) ConnectionMode.Con -> - SendContactRequestView( - disableSendButton = disableSendButton, - icon = MR.images.ic_person_add_filled, - sendRequest = { showSendConnectPreparedContactAlert() } - ) + if (chat.chatInfo.contact.isBot) { + ConnectButtonView( + text = stringResource(MR.strings.compose_view_connect), + icon = MR.images.ic_bolt_filled, + connect = { withApi { sendConnectPreparedContact() } } + ) + } else { + SendContactRequestView( + disableSendButton = disableSendButton, + icon = MR.images.ic_person_add_filled, + sendRequest = { showSendConnectPreparedContactAlert() } + ) + } } } else if ( chat.chatInfo is ChatInfo.Direct @@ -1480,7 +1523,7 @@ fun ComposeView( ) } else { Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - AttachmentButton() + AttachmentAndCommandsButtons() SendMsgView_(disableSendButton = disableSendButton) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index ac722783a3..03d0b99854 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -77,7 +77,7 @@ fun SelectedItemsButtonsToolbar( val forwardCountProhibited = remember { mutableStateOf(false) } Box { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty - ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) + ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(false) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) Row( Modifier .matchParentSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt index 62f1a4337c..81e7d80da3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -44,7 +44,7 @@ fun SelectedItemsMembersToolbar( ) { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty Box(Modifier.alpha(0f)) { - ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) + ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(false) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) } Row( Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 0f9b3151fe..6f873035f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -65,7 +65,7 @@ data class ChatItemReactionMenuItem ( fun ChatItemView( chatsCtx: ChatModel.ChatsContext, rhId: Long?, - cInfo: ChatInfo, + chat: Chat, cItem: ChatItem, composeState: MutableState, imageProvider: (() -> ImageGalleryProvider)? = null, @@ -109,6 +109,7 @@ fun ChatItemView( itemSeparation: ItemSeparation, preview: Boolean = false, ) { + val cInfo = chat.chatInfo val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart @@ -327,7 +328,7 @@ fun ChatItemView( ) { @Composable fun framedItemView() { - FramedItemView(chatsCtx, cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem) + FramedItemView(chatsCtx, chat, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem) } fun deleteMessageQuestionText(): String { @@ -1449,7 +1450,7 @@ fun PreviewChatItemView( ChatItemView( chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, - ChatInfo.Direct.sampleData, + Chat.sampleData, chatItem, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, @@ -1500,7 +1501,7 @@ fun PreviewChatItemViewDeletedContent() { ChatItemView( chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, - ChatInfo.Direct.sampleData, + Chat.sampleData, ChatItem.getDeletedContentSampleData(), useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index ad02b8d812..f36da6c908 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -21,14 +21,17 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* import chat.simplex.res.MR +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlin.math.ceil @Composable fun FramedItemView( chatsCtx: ChatModel.ChatsContext, - chatInfo: ChatInfo, + chat: Chat, ci: ChatItem, uriHandler: UriHandler? = null, imageProvider: (() -> ImageGalleryProvider)? = null, @@ -43,6 +46,7 @@ fun FramedItemView( scrollToItemId: MutableState, scrollToQuotedItemFromItem: (Long) -> Unit = {}, ) { + val chatInfo = chat.chatInfo val sent = ci.chatDir.sent val chatTTL = chatInfo.timedMessagesTTL @@ -182,7 +186,7 @@ fun FramedItemView( fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(ci, chatInfo, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -295,7 +299,7 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVideo -> { @@ -303,26 +307,26 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCVoice -> { CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) if (mc.text != "") { - CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCFile -> ciFileView(ci, mc.text) is MsgContent.MCUnknown -> if (ci.file == null) { - CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } else { ciFileView(ci, mc.text) } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { - CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } is MsgContent.MCReport -> { @@ -331,9 +335,9 @@ fun FramedItemView( append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") } } - CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) } - else -> CIMarkdownText(ci, chatInfo, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + else -> CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } @@ -353,8 +357,9 @@ fun FramedItemView( @Composable fun CIMarkdownText( + chatsCtx: ChatModel.ChatsContext, ci: ChatItem, - chatInfo: ChatInfo, + chat: Chat, chatTTL: Int?, linkMode: SimplexLinkMode, uriHandler: UriHandler?, @@ -364,9 +369,11 @@ fun CIMarkdownText( prefix: AnnotatedString? = null ) { Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { + val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, + sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, mentions = ci.mentions, userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId @@ -377,6 +384,32 @@ fun CIMarkdownText( } } +fun sendCommandMsg(chatsCtx: ChatModel.ChatsContext, chat: Chat, msg: String) { + if (chat.chatInfo.sndReady) { + withLongRunningApi(slow = 60_000) { + val cInfo = chat.chatInfo + val chatItems = + chatModel.controller.apiSendMessages( + rh = chat.remoteHostId, + type = cInfo.chatType, + id = cInfo.apiId, + scope = cInfo.groupChatScope(), + composedMessages = listOf(ComposedMessage(fileSource = null, quotedItemId = null, msgContent = MsgContent.MCText(msg), mentions = emptyMap())) + ) + if (!chatItems.isNullOrEmpty()) { + chatItems.forEach { aChatItem -> + withContext(Dispatchers.Main) { + chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + } + } + } + } + } else { + AlertManager.shared.showAlertMsg(MR.strings.cant_send_message_alert_title, MR.strings.cant_send_commands_alert_text) + } +} + + const val CHAT_IMAGE_LAYOUT_ID = "chatImage" const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble" const val CHAT_COMPOSE_LAYOUT_ID = "chatCompose" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index ad11eb4897..291eedb4ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.* import androidx.compose.ui.text.* +import androidx.compose.ui.text.AnnotatedString.Range import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -61,6 +62,7 @@ fun MarkdownText ( mentions: Map? = null, userMemberId: String? = null, toggleSecrets: Boolean, + sendCommandMsg: ((String) -> Unit)? = null, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, @@ -134,7 +136,9 @@ fun MarkdownText ( } Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } else { - var hasAnnotations = false + var hasLinks = false + var hasSecrets = false + var hasCommands = false val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) @@ -143,7 +147,7 @@ fun MarkdownText ( if (ft.format == null) append(ft.text) else if (toggleSecrets && ft.format is Format.Secret) { val ftStyle = ft.format.style - hasAnnotations = true + hasSecrets = true val key = i.toString() withAnnotation(tag = "SECRET", annotation = key) { if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } @@ -168,10 +172,21 @@ fun MarkdownText ( } else { append(ft.text) } + } else if (ft.format is Format.Command) { + if (sendCommandMsg == null) { + append(ft.text) + } else { + hasCommands = true + val ftStyle = ft.format.style + val cmd = ft.format.commandStr + withAnnotation(tag = "COMMAND", annotation = cmd) { + withStyle(ftStyle) { append("/$cmd") } + } + } } else { val link = ft.link(linkMode) if (link != null) { - hasAnnotations = true + hasLinks = true val ftStyle = ft.format.style withAnnotation(tag = if (ft.format is Format.SimplexLink) "SIMPLEX_URL" else "URL", annotation = link) { withStyle(ftStyle) { append(ft.viewText(linkMode)) } @@ -189,51 +204,53 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - if (hasAnnotations && uriHandler != null) { + if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { val icon = remember { mutableStateOf(PointerIcon.Default) } ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> - annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) } - annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) } + if (hasLinks) { + annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) } + annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) + .firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) } + } }, onClick = { offset -> - annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> + val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> + annotatedText.getStringAnnotations(tag, start = offset, end = offset).firstOrNull()?.let(f) + } + if (hasLinks && uriHandler != null) { + withAnnotation("URL") { a -> try { - uriHandler.openUri(annotation.item) + uriHandler.openUri(a.item) } catch (e: Exception) { // It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch // `tel:` scheme in url installed on a device (no phone app or contacts, maybe) Log.e(TAG, "Open url: ${e.stackTraceToString()}") } } - annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - uriHandler.openVerifiedSimplexUri(annotation.item) - } - annotatedText.getStringAnnotations(tag = "SECRET", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - val key = annotation.item + withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) } + } else if (hasSecrets) { + withAnnotation("SECRET") { a -> + val key = a.item showSecrets[key] = !(showSecrets[key] ?: false) } + } else if (hasCommands && sendCommandMsg != null) { + withAnnotation("COMMAND") { a -> sendCommandMsg("/${a.item}") } + } }, onHover = { offset -> - icon.value = annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { + val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) } + icon.value = + if (hasAnnotation("URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { PointerIcon.Hand - } ?: annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) - .firstOrNull()?.let { - PointerIcon.Hand - } ?: annotatedText.getStringAnnotations(tag = "SECRET", start = offset, end = offset) - .firstOrNull()?.let { - PointerIcon.Hand - } ?: PointerIcon.Default + } else { + PointerIcon.Default + } }, shouldConsumeEvent = { offset -> - annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any() - annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset).any() + annotatedText.hasStringAnnotations(tag = "URL", start = offset, end = offset) + || annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) } ) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index e6c74b7558..35681ff1d2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -176,12 +176,14 @@ fun ChatPreviewView( is ChatInfo.Direct -> if (cInfo.contact.isContactCard) { stringResource(MR.strings.contact_tap_to_connect) to MaterialTheme.colors.primary + } else if (cInfo.contact.isBot && cInfo.contact.nextConnectPrepared) { + stringResource(MR.strings.open_to_use_bot) to Color.Unspecified } else if (cInfo.contact.sendMsgToConnect) { stringResource(MR.strings.open_to_connect) to Color.Unspecified } else if (cInfo.contact.nextAcceptContactRequest) { stringResource(MR.strings.open_to_accept) to Color.Unspecified } else if (!cInfo.contact.sndReady && cInfo.contact.activeConn != null && cInfo.contact.active) { - if (cInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con) { + if ((cInfo.contact.preparedContact?.uiConnLinkType == ConnectionMode.Con && !cInfo.contact.isBot) || cInfo.contact.contactGroupMemberId != null) { stringResource(MR.strings.contact_should_accept) to Color.Unspecified } else { stringResource(MR.strings.contact_connection_pending) to Color.Unspecified diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt index 75f61dda04..a55b1a0d56 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt @@ -2,14 +2,14 @@ package chat.simplex.common.views.helpers import androidx.compose.animation.core.* -fun chatListAnimationSpec() = tween(durationMillis = 250, easing = FastOutSlowInEasing) - -fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing) +fun chatListAnimationSpec() = tween(durationMillis = 350, easing = FastOutSlowInEasing) fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = LinearEasing) -fun userPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) +fun userPickerAnimSpec() = tween(350, 0, FastOutSlowInEasing) -fun mentionPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) +fun mentionPickerAnimSpec() = tween(350, 0, FastOutSlowInEasing) -fun contextUserPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) +fun commandMenuAnimSpec() = tween(350, 0, FastOutSlowInEasing) + +fun contextUserPickerAnimSpec() = tween(350, 0, FastOutSlowInEasing) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 72fab4990b..72ef8c623d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -33,6 +33,7 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme when (chatInfo) { is ChatInfo.Group -> chatInfo.groupInfo.chatIconName is ChatInfo.Local -> MR.images.ic_folder_filled + is ChatInfo.Direct -> chatInfo.contact.chatIconName else -> MR.images.ic_account_circle_filled } ProfileImage(size, chatInfo.image, icon, if (chatInfo is ChatInfo.Local) NoteFolderIconColor else iconColor) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 786e3483c5..b1c5caedc9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -390,7 +390,7 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: ProfileImage( size = alertProfileImageSize, image = contact.profile.image, - icon = MR.images.ic_account_circle_filled + icon = contact.chatIconName ) }, confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat), @@ -474,7 +474,7 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (( ProfileImage( size = alertProfileImageSize, image = groupInfo.groupProfile.image, - icon = if (groupInfo.businessChat == null) MR.images.ic_supervised_user_circle_filled else MR.images.ic_work_filled_padded + icon = groupInfo.chatIconName ) }, confirmText = generalGetString( @@ -515,7 +515,10 @@ fun showPrepareContactAlert( ProfileImage( size = alertProfileImageSize, image = contactShortLinkData.profile.image, - icon = if (contactShortLinkData.business) MR.images.ic_work_filled_padded else MR.images.ic_account_circle_filled + icon = + if (contactShortLinkData.business) MR.images.ic_work_filled_padded + else if (contactShortLinkData.profile.peerType == ChatPeerType.Bot) MR.images.ic_cube + else MR.images.ic_account_circle_filled ) }, confirmText = generalGetString(MR.strings.connect_plan_open_new_chat), diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index e962c3a646..22cdd3a643 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -449,6 +449,7 @@ No chats found Tap to Connect Open to connect + Open to use bot Open to accept contact should accept… Connect with %1$s? @@ -489,8 +490,10 @@ Tap Connect to chat Tap Connect to send request + Tap Connect to use bot Accept contact request Your contact + Bot Tap Join group Your group Group @@ -562,6 +565,7 @@ you are observer reviewed by admins member has old version + To send commands you must be connected. Image @@ -2157,6 +2161,9 @@ Allow your contacts to send voice messages. Allow voice messages only if your contact allows them. Prohibit sending voice messages. + Allow your contacts to send files and media. + Allow files and media only if your contact allows them. + Prohibit sending files and media. Allow your contacts adding message reactions. Allow message reactions only if your contact allows them. Prohibit message reactions. @@ -2175,6 +2182,10 @@ Only you can send voice messages. Only your contact can send voice messages. Voice messages are prohibited in this chat. + Both you and your contact can send files and media. + Only you can send files and media. + Only your contact can send files and media. + Files and media are prohibited in this chat. Both you and your contact can add message reactions. Only you can add message reactions. Only your contact can add message reactions. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg new file mode 100644 index 0000000000..252afc1acc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 8107b664c4..d9f091a13a 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -11,10 +11,11 @@ import Data.Text (Text) import Options.Applicative import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) -import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, coreChatOptsP) +import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) data BroadcastBotOpts = BroadcastBotOpts { coreOptions :: CoreChatOpts, + botDisplayName :: Text, publishers :: [KnownContact], welcomeMessage :: Text, prohibitedMessage :: Text @@ -29,6 +30,12 @@ defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " broadcastBotOpts :: FilePath -> FilePath -> Parser BroadcastBotOpts broadcastBotOpts appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName + botDisplayName <- + strOption + ( long "display-name" + <> metavar "DISPLAY_NAME" + <> help "The display name of the broadcast bot" + ) publishers <- option parseKnownContacts @@ -55,6 +62,7 @@ broadcastBotOpts appDir defaultDbName = do pure BroadcastBotOpts { coreOptions, + botDisplayName, publishers, welcomeMessage = fromMaybe (defaultWelcomeMessage publishers) welcomeMessage_, prohibitedMessage = fromMaybe (defaultProhibitedMessage publishers) prohibitedMessage_ @@ -72,7 +80,7 @@ getBroadcastBotOpts appDir defaultDbName = versionAndUpdate = versionStr <> "\n" <> updateStr mkChatOpts :: BroadcastBotOpts -> ChatOpts -mkChatOpts BroadcastBotOpts {coreOptions} = +mkChatOpts BroadcastBotOpts {coreOptions, botDisplayName} = ChatOpts { coreOptions, chatCmd = "", @@ -86,5 +94,6 @@ mkChatOpts BroadcastBotOpts {coreOptions} = autoAcceptFileSize = 0, muteNotifications = True, markRead = False, + createBot = Just CreateBotOpts {botDisplayName, allowFiles = False}, maintenance = False } diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 2c26905e79..2eba633609 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -16,7 +16,7 @@ import qualified Data.Text as T import Options.Applicative import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) -import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, coreChatOptsP) +import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, @@ -119,7 +119,7 @@ directoryOpts appDir defaultDbName = do <> help "The display name of the directory service bot, without *'s and spaces (SimpleX-Directory)" <> value "SimpleX-Directory" ) - runCLI <- + runCLI <- switch ( long "run-cli" <> help "Run directory service as CLI" @@ -155,7 +155,7 @@ getDirectoryOpts appDir defaultDbName = versionAndUpdate = versionStr <> "\n" <> updateStr mkChatOpts :: DirectoryOpts -> ChatOpts -mkChatOpts DirectoryOpts {coreOptions} = +mkChatOpts DirectoryOpts {coreOptions, serviceName} = ChatOpts { coreOptions, chatCmd = "", @@ -169,5 +169,6 @@ mkChatOpts DirectoryOpts {coreOptions} = autoAcceptFileSize = 0, muteNotifications = True, markRead = False, + createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False}, maintenance = False } diff --git a/bots/README.md b/bots/README.md index 0b1943b7a7..9449e9d847 100644 --- a/bots/README.md +++ b/bots/README.md @@ -1,6 +1,8 @@ # SimpleX Chat bot API - [Why create a bot](#why-create-a-bot) +- [What is SimpleX bot](#what-is-simplex-bot) +- [How to configure bot profile](#how-to-configure-bot-profile) - [How to create a bot](#how-to-create-a-bot) - [Sending commands](#sending-commands) - [Processing events](#processing-events) @@ -12,8 +14,9 @@ ## Why create a bot You can implement SimpleX Chat for these and many other scenarios: -- customer support - both as a single- and a multi-agent support chat (using SimpleX Chat [business address]() feature), +- customer support - both as a single- and a multi-agent support chat (using SimpleX Chat [business address](https://simplex.chat/docs/business.html) feature), - information search and retrieval bots, with or without LLM integration, +- moderation bots, to moderate your group and communities. - broadcast bot, when messages from your trusted users are forwarded to all connected contacts - e.g., see our SimpleX Status bot in the app ([source code](../apps/simplex-broadcast-bot/)), - feedback bot, when messages from connected contacts are forwarded to a preset list of your trusted users, - P2P trading bots, connecting buyers and sellers, @@ -22,6 +25,65 @@ You can implement SimpleX Chat for these and many other scenarios: We will share all useful bots you create in the bottom of this page - please submit a PR to add it. +## What is SimpleX bot + +SimpleX bot is a participant of SimpleX network. Theoretically, bot can do everything that a usual SimpleX Chat user can do – send and receive messages and files, connect to addresses and join groups, etc. But to be useful, a bot should distinguish itself as a bot, and to provide an interface for the users to interact with it. + +## How to configure bot profile + +Starting from v6.4.3, SimpleX Chat apps support bot configuration to distinguish bots, to highlight commands in messages, and to show command menus. + +### Set up bot profile + +To distinguish SimpleX user profile as a bot, set its `peerType` property to `"bot"`. It can be done in one of these ways: +- using CLI options `--create-bot-display-name` and `create-bot-allow-files` when first starting CLI to create bot profile, +- using command `/create bot [files=on] [ ]` (if name contains spaces, it must be in single quotes), when creating additional bot profiles in the same database, +- by configuring bot commands that the users will see in the UI when they type `/` character or tap `//` button with `/set bot commands ...` CLI command (see syntax below), +- by using [APIUpdateProfile](./api/COMMANDS.md#apiupdateprofile) bot command to set `peerType` and configure bot commands at the same time. + +### Configure bot commands + +Bot commands are messages that start from `/` character. Normally, they would consist of lowercase latin letters, but commands can use any letters, digits and underscores. Commands can have parameters. + +All commands in messages will be highlighted in the chats with the bot, and when users tap them, they will be instantly sent. If the message has a single line and starts from `/` character, the whole message will be highlighted. Otherwise, if command is included as part of the message, it will be highlighted until the first space after `/` character: e.g., `/list` command in Directory service shows user's groups. + +*Please note*: commands in messages will be highlighted based purely on `/` character, regardless of whether they are supported by the bot or included in bot configuration. It allows bots to have "hidden" commands that bot would support, but that won't be shown in the menu. But it may also lead to mistakes if bot sends incorrect commands in the instructions to the users. + +Bots can also send highlighted commands with parameters. To do that, bots should surround both command and its parameters in single quotes: e.g., `/'role 2'`. Quotes won't show in the apps UI, and if the user taps this command, it will be sent as `/role 2`. + +Configured bot commands will be be offered to the users as a menu, and for quick lookup as the user types. + +Bot commands configuration is a property in `preferences` object in bot profile received by the user. These preferences can be configured both on the bot user profile level, to offer the same commands to all connected users, and as overrides for specific contacts, to offer different commands to different bot contacts. + +Configuring commands in bot user can be done either with [APIUpdateProfile](./api/COMMANDS.md#apiupdateprofile) or with `/set bot commands` CLI command: + +``` +/set bot commands +``` + +where: + +``` +commands = [,...] +commandOrMenu = command | menu +command = '