This commit is contained in:
spaced4ndy
2026-07-06 20:53:43 +04:00
parent c65b7ac4d1
commit 57ba0a9feb
7 changed files with 75 additions and 65 deletions
@@ -54,8 +54,6 @@ 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 = "",
@@ -64,8 +62,7 @@ struct ComposeState {
preview: ComposePreview = .noPreview,
contextItem: ComposeContextItem = .noContextItem,
voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording,
mentions: MentionedMembers = [:],
sign: Bool = false
mentions: MentionedMembers = [:]
) {
self.message = message
self.parsedMessage = parsedMessage
@@ -74,7 +71,6 @@ struct ComposeState {
self.contextItem = contextItem
self.voiceMessageRecordingState = voiceMessageRecordingState
self.mentions = mentions
self.sign = sign
}
init(editingItem: ChatItem) {
@@ -111,8 +107,7 @@ struct ComposeState {
preview: ComposePreview? = nil,
contextItem: ComposeContextItem? = nil,
voiceMessageRecordingState: VoiceMessageRecordingState? = nil,
mentions: MentionedMembers? = nil,
sign: Bool? = nil
mentions: MentionedMembers? = nil
) -> ComposeState {
ComposeState(
message: message ?? self.message,
@@ -121,8 +116,7 @@ struct ComposeState {
preview: preview ?? self.preview,
contextItem: contextItem ?? self.contextItem,
voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState,
mentions: mentions ?? self.mentions,
sign: sign ?? self.sign
mentions: mentions ?? self.mentions
)
}
@@ -1045,6 +1039,10 @@ struct ComposeView: View {
sendMessage(ttl: ttl)
resetLinkPreview()
},
sendSignedMessage: {
sendMessage(ttl: nil, sign: true)
resetLinkPreview()
},
sendLiveMessage: chat.chatInfo.chatType != .local ? sendLiveMessage : nil,
updateLiveMessage: updateLiveMessage,
cancelLiveMessage: {
@@ -1065,7 +1063,6 @@ struct ComposeView: View {
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,
@@ -1464,16 +1461,16 @@ struct ComposeView: View {
}
// Spec: spec/client/compose.md#sendMessage
private func sendMessage(ttl: Int?) {
private func sendMessage(ttl: Int?, sign: Bool = false) {
logger.debug("ChatView sendMessage")
Task {
logger.debug("ChatView sendMessage: in Task")
_ = await sendMessageAsync(nil, live: false, ttl: ttl)
_ = await sendMessageAsync(nil, live: false, ttl: ttl, sign: sign)
}
}
// Spec: spec/client/compose.md#sendMessageAsync
private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? {
private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?, sign: Bool = false) async -> ChatItem? {
var sent: ChatItem?
let msgText = text ?? composeState.message
let liveMessage = composeState.liveMessage
@@ -1486,7 +1483,7 @@ struct ComposeView: View {
// Composed text is send as a reply to the last forwarded item
sent = await forwardItems(chatItems, fromChatInfo, ttl).last
if !composeState.message.isEmpty {
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions)
_ = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl, mentions: mentions, sign: sign)
}
} else if case let .editingItem(ci) = composeState.contextItem {
sent = await updateMessage(ci, live: live)
@@ -1502,13 +1499,13 @@ struct ComposeView: View {
switch (composeState.preview) {
case .noPreview:
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign)
case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign)
case let .chatLinkPreview(chatLink, ownerSig):
let linkStr = chatLink.connLinkStr
let text = msgText.isEmpty ? linkStr : msgText + "\n" + linkStr
sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions)
sent = await send(.chat(text: text, chatLink: chatLink, ownerSig: ownerSig), quoted: quoted, live: live, ttl: ttl, mentions: mentions, sign: sign)
case let .mediaPreviews(media):
// TODO: CHECK THIS
let last = media.count - 1
@@ -1530,15 +1527,15 @@ struct ComposeView: View {
if msgs.isEmpty {
msgs = [ComposedMessage(quotedItemId: quoted, msgContent: .text(msgText))]
}
sent = await send(msgs, live: live, ttl: ttl).last
sent = await send(msgs, live: live, ttl: ttl, sign: sign).last
case let .voicePreview(recordingFileName, duration):
stopPlayback.toggle()
let file = voiceCryptoFile(recordingFileName)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions)
sent = await send(.voice(text: msgText, duration: duration), quoted: quoted, file: file, ttl: ttl, mentions: mentions, sign: sign)
case let .filePreview(_, file):
if let savedFile = saveFileFromURL(file) {
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions)
sent = await send(.file(msgText), quoted: quoted, file: savedFile, live: live, ttl: ttl, mentions: mentions, sign: sign)
}
}
}
@@ -1672,15 +1669,16 @@ struct ComposeView: View {
)
}
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64]) async -> ChatItem? {
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?, mentions: [String: Int64], sign: Bool = false) async -> ChatItem? {
await send(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc, mentions: mentions)],
live: live,
ttl: ttl
ttl: ttl,
sign: sign
).first
}
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?) async -> [ChatItem] {
func send(_ msgs: [ComposedMessage], live: Bool, ttl: Int?, sign: Bool = false) async -> [ChatItem] {
if let chatItems = chat.chatInfo.chatType == .local
? await apiCreateChatItems(noteFolderId: chat.chatInfo.apiId, composedMessages: msgs)
: await apiSendMessages(
@@ -1690,7 +1688,7 @@ struct ComposeView: View {
sendAsGroup: chat.chatInfo.sendAsGroup,
live: live,
ttl: ttl,
sign: composeState.sign,
sign: sign,
composedMessages: msgs
) {
await MainActor.run {
@@ -19,6 +19,7 @@ struct SendMessageView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.isEnabled) var isEnabled
var sendMessage: (Int?) -> Void
var sendSignedMessage: () -> Void = {}
var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil
var cancelLiveMessage: (() -> Void)? = nil
@@ -33,7 +34,6 @@ struct SendMessageView: View {
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
@@ -45,10 +45,10 @@ 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
@UserDefault(DEFAULT_SIGN_MESSAGE_ALERT_SHOWN) private var signMessageAlertShown = false
var body: some View {
let composeShape = RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
@@ -208,16 +208,6 @@ 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 {
@@ -258,13 +248,9 @@ struct SendMessageView: View {
}
if showSign {
Button {
composeState.sign.toggle()
if composeState.sign { showSignInfoAlert = true }
startSignedMessage()
} label: {
Label(
composeState.sign ? "Signing message" : "Sign message",
systemImage: composeState.sign ? "checkmark.seal.fill" : "checkmark.seal"
)
Label("Sign message", systemImage: "checkmark.seal")
}
}
}
@@ -376,6 +362,22 @@ struct SendMessageView: View {
.padding([.bottom, .horizontal], 4)
}
private func startSignedMessage() {
if signMessageAlertShown {
sendSignedMessage()
} else {
AlertManager.shared.showAlert(Alert(
title: Text("Sign message"),
message: Text("A signature is transferable, non-repudiable proof that you authored this message."),
primaryButton: .default(Text("Send")) {
signMessageAlertShown = true
sendSignedMessage()
},
secondaryButton: .cancel()
))
}
}
private func startLiveMessage(send: @escaping () async -> Void, update: @escaping () async -> Void) {
if liveMessageAlertShown {
start()
@@ -57,6 +57,7 @@ let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
let DEFAULT_SIGN_MESSAGE_ALERT_SHOWN = "signMessageAlertShown"
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert"
let DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "showReportsInSupportChatAlert"
@@ -117,6 +118,7 @@ let appDefaults: [String: Any] = [
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SIGN_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
DEFAULT_SHOW_MUTE_PROFILE_ALERT: true,
DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT: true,
@@ -145,6 +147,7 @@ let hintDefaults = [
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
DEFAULT_SIGN_MESSAGE_ALERT_SHOWN,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT,
@@ -186,6 +186,7 @@ class AppPreferences {
val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
val signMessageAlertShown = mkBoolPreference(SHARED_PREFS_SIGN_MESSAGE_ALERT_SHOWN, false)
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false)
@@ -271,6 +272,7 @@ class AppPreferences {
hintPref(oneHandUICardShown, false),
hintPref(addressCreationCardShown, false),
hintPref(liveMessageAlertShown, false),
hintPref(signMessageAlertShown, false),
hintPref(showHiddenProfilesNotice, true),
hintPref(showMuteProfileAlert, true),
hintPref(showReportsInSupportChatAlert, true),
@@ -454,6 +456,7 @@ class AppPreferences {
private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
private const val SHARED_PREFS_INCOGNITO = "Incognito"
private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
private const val SHARED_PREFS_SIGN_MESSAGE_ALERT_SHOWN = "SignMessageAlertShown"
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown"
private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown"
@@ -115,9 +115,7 @@ 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),
// per-send opt-in to sign the message (relay channels only); resets on send/chat change, not persisted
val sign: Boolean = false
val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP)
) {
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
ComposeMessage(
@@ -544,7 +542,7 @@ fun ComposeView(
}
}
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map<String, Long>): ChatItem? {
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map<String, Long>, sign: Boolean = false): ChatItem? {
val cInfo = chat.chatInfo
val chatItems = if (chat.chatInfo.chatType == ChatType.Local)
chatModel.controller.apiCreateChatItems(
@@ -561,7 +559,7 @@ fun ComposeView(
sendAsGroup = cInfo.sendAsGroup,
live = live,
ttl = ttl,
sign = composeState.value.sign,
sign = sign,
composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions))
)
if (!chatItems.isNullOrEmpty()) {
@@ -678,7 +676,7 @@ fun ComposeView(
}
}
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): List<ChatItem>? {
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?, sign: Boolean = false): List<ChatItem>? {
val cs = composeState.value
var sent: List<ChatItem>?
var lastMessageFailedToSend: ComposeState? = null
@@ -803,7 +801,7 @@ fun ComposeView(
if (cs.message.text.isNotEmpty()) {
sent?.mapIndexed { index, message ->
if (index == sent!!.lastIndex) {
send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions)
send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions, sign = sign)
} else {
message
}
@@ -920,7 +918,8 @@ fun ComposeView(
val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file,
live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false,
ttl = ttl,
mentions = cs.memberMentions
mentions = cs.memberMentions,
sign = sign
)
sent = if (sendResult != null) listOf(sendResult) else null
if (sent == null && index == msgs.lastIndex && cs.liveMessage == null) {
@@ -947,9 +946,9 @@ fun ComposeView(
return sent
}
fun sendMessage(ttl: Int?) {
fun sendMessage(ttl: Int?, sign: Boolean = false) {
withLongRunningApi(slow = 120_000) {
sendMessageAsync(null, false, ttl)
sendMessageAsync(null, false, ttl, sign)
}
}
@@ -1394,13 +1393,17 @@ fun ComposeView(
sendButtonColor = sendButtonColor,
timedMessageAllowed = timedMessageAllowed,
showSign = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.useRelays == true,
sendAsGroup = chat.chatInfo.sendAsGroup,
signMessageAlertShown = chatModel.controller.appPrefs.signMessageAlertShown,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
placeholder = if (userCantSendReason.value != null) "" else placeholder ?: composeState.value.placeholder,
sendMessage = { ttl ->
sendMessage(ttl)
resetLinkPreview()
},
sendSignedMessage = {
sendMessage(null, sign = true)
resetLinkPreview()
},
sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null,
updateLiveMessage = ::updateLiveMessage,
cancelLiveMessage = {
@@ -50,10 +50,11 @@ fun SendMsgView(
allowVoiceToContact: () -> Unit,
timedMessageAllowed: Boolean = false,
showSign: Boolean = false,
sendAsGroup: Boolean = false,
signMessageAlertShown: SharedPreference<Boolean> = SharedPreference(get = { false }, set = {}),
customDisappearingMessageTimePref: SharedPreference<Int>? = null,
placeholder: String,
sendMessage: (Int?) -> Unit,
sendSignedMessage: () -> Unit = {},
sendLiveMessage: (suspend () -> Unit)? = null,
updateLiveMessage: (suspend () -> Unit)? = null,
cancelLiveMessage: (() -> Unit)? = null,
@@ -212,17 +213,20 @@ fun SendMsgView(
if (showSign && !cs.editing) {
menuItems.add {
ItemAction(
generalGetString(if (cs.sign) MR.strings.signing_message else MR.strings.sign_message),
generalGetString(MR.strings.sign_message),
painterResource(MR.images.ic_verified),
onClick = {
val nowOn = !composeState.value.sign
composeState.value = composeState.value.copy(sign = nowOn)
if (nowOn) {
if (signMessageAlertShown.state.value) {
sendSignedMessage()
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.sign_message),
text = generalGetString(if (sendAsGroup) MR.strings.sign_message_as_channel_desc else MR.strings.sign_message_desc),
dismissText = generalGetString(MR.strings.dont_sign_message),
onDismiss = { composeState.value = composeState.value.copy(sign = false) }
text = generalGetString(MR.strings.sign_message_desc),
confirmText = generalGetString(MR.strings.send_verb),
onConfirm = {
signMessageAlertShown.set(true)
sendSignedMessage()
}
)
}
showDropdown.value = false
@@ -755,10 +755,7 @@
<string name="send_disappearing_message_custom_time">Custom time</string>
<string name="send_disappearing_message_send">Send</string>
<string name="sign_message">Sign message</string>
<string name="signing_message">Signing message</string>
<string name="dont_sign_message">Don\'t sign</string>
<string name="sign_message_desc">A signature is transferable, non-repudiable proof that you authored this message — it confirms authorship and integrity, not timing, order, or completeness.</string>
<string name="sign_message_as_channel_desc">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.</string>
<string name="sign_message_desc">A signature is transferable, non-repudiable proof that you authored this message.</string>
<string name="live_message">Live message!</string>
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
<string name="send_verb">Send</string>