From 371ee6dcdfd684543c8e4527200823fad14fbf8e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:33:12 +0400 Subject: [PATCH] wip --- apps/ios/Shared/Model/AppAPITypes.swift | 6 ++--- apps/ios/Shared/Model/SimpleXAPI.swift | 4 ++-- .../Chat/ComposeMessage/ComposeView.swift | 15 +++++++++--- .../Chat/ComposeMessage/SendMessageView.swift | 24 +++++++++++++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 8 +++---- .../simplex/common/views/chat/ComposeView.kt | 7 +++++- .../simplex/common/views/chat/SendMsgView.kt | 21 ++++++++++++++++ .../commonMain/resources/MR/base/strings.xml | 4 ++++ 8 files changed, 76 insertions(+), 13 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 9e27c6fa16..6877fa3141 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -45,7 +45,7 @@ enum ChatCommand: ChatCmdProtocol { case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) - case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, sign: Bool, composedMessages: [ComposedMessage]) case apiCreateChatTag(tag: ChatTagData) case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) case apiDeleteChatTag(tagId: Int64) @@ -240,11 +240,11 @@ enum ChatCommand: ChatCmdProtocol { return "/_get chat \(chatId)\(scopeRef(scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") case let .apiGetChatContentTypes(chatId, scope): return "/_get content types \(chatId)\(scopeRef(scope))" case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" - case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, sign, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" let asGroup = sendAsGroup ? "(as_group=on)" : "" - return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) sign=\(onOff(sign)) json \(msgs)" case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d7ee777d5b..4f2fcb410b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -542,8 +542,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws { try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds)) } -func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages) +func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, sign: Bool = false, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, sign: sign, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 9c40b2b395..6b198c5529 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -54,6 +54,8 @@ struct ComposeState { var progressByTimeout = false var useLinkPreviews = true var mentions: MentionedMembers = [:] + // per-send opt-in to sign the message (relay channels only); resets on send/chat change, not persisted + var sign = false init( message: String = "", @@ -62,7 +64,8 @@ struct ComposeState { preview: ComposePreview = .noPreview, contextItem: ComposeContextItem = .noContextItem, voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording, - mentions: MentionedMembers = [:] + mentions: MentionedMembers = [:], + sign: Bool = false ) { self.message = message self.parsedMessage = parsedMessage @@ -71,6 +74,7 @@ struct ComposeState { self.contextItem = contextItem self.voiceMessageRecordingState = voiceMessageRecordingState self.mentions = mentions + self.sign = sign } init(editingItem: ChatItem) { @@ -107,7 +111,8 @@ struct ComposeState { preview: ComposePreview? = nil, contextItem: ComposeContextItem? = nil, voiceMessageRecordingState: VoiceMessageRecordingState? = nil, - mentions: MentionedMembers? = nil + mentions: MentionedMembers? = nil, + sign: Bool? = nil ) -> ComposeState { ComposeState( message: message ?? self.message, @@ -116,7 +121,8 @@ struct ComposeState { preview: preview ?? self.preview, contextItem: contextItem ?? self.contextItem, voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState, - mentions: mentions ?? self.mentions + mentions: mentions ?? self.mentions, + sign: sign ?? self.sign ) } @@ -1058,6 +1064,8 @@ struct ComposeView: View { finishVoiceMessageRecording: finishVoiceMessageRecording, allowVoiceMessagesToContact: allowVoiceMessagesToContact, timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), + showSign: chat.chatInfo.groupInfo?.useRelays == true, + sendAsGroup: chat.chatInfo.sendAsGroup, onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, @@ -1682,6 +1690,7 @@ struct ComposeView: View { sendAsGroup: chat.chatInfo.sendAsGroup, live: live, ttl: ttl, + sign: composeState.sign, composedMessages: msgs ) { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 713f462c27..ffe5f5cbed 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -32,6 +32,8 @@ struct SendMessageView: View { var finishVoiceMessageRecording: (() -> Void)? = nil var allowVoiceMessagesToContact: (() -> Void)? = nil var timedMessageAllowed: Bool = false + var showSign: Bool = false + var sendAsGroup: Bool = false var onMediaAdded: ([UploadContent]) -> Void @State private var holdingVMR = false @Namespace var namespace @@ -43,6 +45,7 @@ struct SendMessageView: View { @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 @State private var showCustomDisappearingMessageDialogue = false + @State private var showSignInfoAlert = false @State private var showCustomTimePicker = false @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false @@ -205,6 +208,16 @@ struct SendMessageView: View { disappearingMessageCustomTimePicker() } } + .alert("Sign message", isPresented: $showSignInfoAlert) { + Button("OK") {} + Button("Don't sign", role: .cancel) { composeState.sign = false } + } message: { + Text( + sendAsGroup + ? "A signature is transferable, non-repudiable proof that you authored this message — it confirms authorship and integrity, not timing, order, or completeness. Signing a post sent as the channel also reveals you as its author." + : "A signature is transferable, non-repudiable proof that you authored this message — it confirms authorship and integrity, not timing, order, or completeness." + ) + } } private func disappearingMessageCustomTimePicker() -> some View { @@ -243,6 +256,17 @@ struct SendMessageView: View { Label("Disappearing message", systemImage: "stopwatch") } } + if showSign { + Button { + composeState.sign.toggle() + if composeState.sign { showSignInfoAlert = true } + } label: { + Label( + composeState.sign ? "Signing message" : "Sign message", + systemImage: composeState.sign ? "checkmark.seal.fill" : "checkmark.seal" + ) + } + } } } 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 bf0afe4546..71af57ab38 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 @@ -1096,8 +1096,8 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, sign: Boolean = false, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, sign, composedMessages) return processSendMessageCmd(rh, cmd) } @@ -3782,7 +3782,7 @@ sealed class CC { class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatContentTypes(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val sign: Boolean, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3980,7 +3980,7 @@ sealed class CC { is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} sign=${onOff(sign)} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" 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 c37aa0a77f..eec4512370 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 @@ -115,7 +115,9 @@ data class ComposeState( val useLinkPreviews: Boolean, val mentions: MentionedMembers = emptyMap(), // the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch - val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP) + val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP), + // per-send opt-in to sign the message (relay channels only); resets on send/chat change, not persisted + val sign: Boolean = false ) { constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( ComposeMessage( @@ -559,6 +561,7 @@ fun ComposeView( sendAsGroup = cInfo.sendAsGroup, live = live, ttl = ttl, + sign = composeState.value.sign, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) ) if (!chatItems.isNullOrEmpty()) { @@ -1390,6 +1393,8 @@ fun ComposeView( allowVoiceToContact = ::allowVoiceToContact, sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, + showSign = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.useRelays == true, + sendAsGroup = chat.chatInfo.sendAsGroup, customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, placeholder = if (userCantSendReason.value != null) "" else placeholder ?: composeState.value.placeholder, sendMessage = { ttl -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 0948551c7e..cfd8bcf8b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -49,6 +49,8 @@ fun SendMsgView( sendButtonColor: Color = MaterialTheme.colors.primary, allowVoiceToContact: () -> Unit, timedMessageAllowed: Boolean = false, + showSign: Boolean = false, + sendAsGroup: Boolean = false, customDisappearingMessageTimePref: SharedPreference? = null, placeholder: String, sendMessage: (Int?) -> Unit, @@ -207,6 +209,25 @@ fun SendMsgView( ) } } + if (showSign && !cs.editing) { + menuItems.add { + ItemAction( + generalGetString(if (cs.sign) MR.strings.signing_message else MR.strings.sign_message), + painterResource(MR.images.ic_verified), + onClick = { + val nowOn = !composeState.value.sign + composeState.value = composeState.value.copy(sign = nowOn) + if (nowOn) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.sign_message), + text = generalGetString(if (sendAsGroup) MR.strings.sign_message_as_channel_desc else MR.strings.sign_message_desc) + ) + } + showDropdown.value = false + } + ) + } + } } return menuItems 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 96c7226c36..90dac35754 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -752,6 +752,10 @@ 5 minutes Custom time Send + Sign message + Signing message + A signature is transferable, non-repudiable proof that you authored this message — it confirms authorship and integrity, not timing, order, or completeness. + A signature is transferable, non-repudiable proof that you authored this message — it confirms authorship and integrity, not timing, order, or completeness. Signing a post sent as the channel also reveals you as its author. Live message! Send a live message - it will update for the recipient(s) as you type it Send