-Invest in SimpleX Chat. [Register now](https://simplexchat.typeform.com/crowdfunding).
+Invest in SimpleX Chat. [Learn more on Wefunder](https://wefunder.com/simplexchat).
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt
index 83677f3318..1826b2114e 100644
--- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt
+++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt
@@ -42,10 +42,9 @@ actual fun SavePassphraseSetting(
}
Text(
stringResource(MR.strings.save_passphrase_in_keychain),
- Modifier.padding(end = 24.dp),
+ Modifier.weight(1f).padding(end = 24.dp),
color = Color.Unspecified
)
- Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
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 40d7927264..a2bebb9feb 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
@@ -597,6 +597,10 @@ fun ComposeView(
composeState.value = composeState.value.copy(inProgress = true)
}
+ // composeState and its inProgress flag are shared between the chats opened in this view, and sending is not cancelled
+ // when the chat is switched - a send may only clear or reset the state while it still holds the message that was sent
+ fun composeHasSentMessage(): Boolean = chatModel.chatId.value == chat.id && composeState.value.inProgress
+
suspend fun sendMemberContactInvitation() {
val mc = checkLinkPreview()
sending()
@@ -604,10 +608,10 @@ fun ComposeView(
if (contact != null) {
withContext(Dispatchers.Main) {
chatsCtx.updateContact(chat.remoteHostId, contact)
- clearState()
+ if (composeHasSentMessage()) clearState()
}
- } else {
- composeState.value = composeState.value.copy(inProgress = false)
+ } else withContext(Dispatchers.Main) {
+ if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false)
}
}
@@ -624,10 +628,10 @@ fun ComposeView(
if (contact != null) {
withContext(Dispatchers.Main) {
chatsCtx.updateContact(chat.remoteHostId, contact)
- clearState()
+ if (composeHasSentMessage()) clearState()
}
- } else {
- composeState.value = composeState.value.copy(inProgress = false)
+ } else withContext(Dispatchers.Main) {
+ if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false)
}
}
@@ -669,10 +673,10 @@ fun ComposeView(
chatModel.channelRelayHostnames.remove(groupInfo.groupId)
chatModel.groupMembers.value = relayResults.map { it.relayMember }
chatModel.populateGroupMembersIndexes()
- clearState()
+ if (composeHasSentMessage()) clearState()
}
- } else {
- composeState.value = composeState.value.copy(inProgress = false)
+ } else withContext(Dispatchers.Main) {
+ if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false)
}
}
@@ -932,16 +936,38 @@ fun ComposeView(
val wasForwarding = cs.forwarding
val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItems)?.fromChatInfo?.id
val lastFailed = lastMessageFailedToSend
- if (lastFailed == null) {
- clearState(live)
- } else {
- composeState.value = lastFailed
- }
- val draft = chatModel.draft.value
- if (wasForwarding && chatModel.draftChatId.value == draftChatId(chat.chatInfo.id, chatScope) && forwardingFromChatId != chat.chatInfo.id && draft != null) {
- composeState.value = draft
- } else {
- clearCurrentDraft()
+ // composeState is shared between the chats opened in this view, and this runs after the send API call, so the user
+ // could have switched chats or typed another message in the meantime - only the message that was sent may be
+ // cleared or restored. On Main, so that these checks and changes are not interleaved with the user switching
+ // chats or typing.
+ withContext(Dispatchers.Main) {
+ val chatIsOpen = chatModel.chatId.value == chat.id
+ // a live message is held in the compose state of the chat it is sent to, but only while that chat is the one open
+ val liveSend = live || cs.liveMessage != null
+ val sentMessageInCompose = chatIsOpen && (liveSend || composeState.value.inProgress)
+ if (sentMessageInCompose) {
+ if (lastFailed == null) {
+ clearState(live)
+ } else {
+ composeState.value = lastFailed
+ }
+ }
+ val draft = chatModel.draft.value
+ if (wasForwarding && chatModel.draftChatId.value == draftChatId(chat.chatInfo.id, chatScope) && forwardingFromChatId != chat.chatInfo.id && draft != null) {
+ if (sentMessageInCompose) composeState.value = draft
+ } else {
+ clearCurrentDraft()
+ // liveSend excluded: a failing keystroke send would otherwise write a draft on every attempt
+ if (!sentMessageInCompose && !liveSend && lastFailed != null) {
+ // the message was not sent, so it is restored in the chat it was composed in, or kept as its draft if another chat is open
+ if (chatIsOpen && composeState.value.empty) {
+ composeState.value = lastFailed
+ } else if (saveLastDraft) {
+ chatModel.draft.value = lastFailed
+ chatModel.draftChatId.value = draftChatId(chat.id, chatScope)
+ }
+ }
+ }
}
return sent
}
@@ -1319,7 +1345,15 @@ fun ComposeView(
deleteUnusedFiles()
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
- composeState.value = cs.copy(inProgress = false, progressByTimeout = false)
+ // the message being sent must not be kept in the compose state, it is shared with the chat opened next;
+ // if it fails to send it is restored in this chat or saved as its draft
+ clearState()
+ // clearState() does not load the draft of the chat opened next, and without this it is never shown and is
+ // dropped when that chat is left
+ val draft = chatModel.draft.value
+ if (draft != null && chatModel.draftChatId.value == draftChatId(chatModel.chatId.value, chatScope)) {
+ composeState.value = draft
+ }
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
recState.value = RecordingState.NotStarted
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt
index eb93e7c510..4535857696 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt
@@ -42,10 +42,9 @@ actual fun SavePassphraseSetting(
}
Text(
stringResource(MR.strings.save_passphrase_in_settings),
- Modifier.padding(end = 24.dp),
+ Modifier.weight(1f).padding(end = 24.dp),
color = Color.Unspecified
)
- Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
diff --git a/images/github-banner.jpg b/images/github-banner.jpg
index f7a0d730b8..ef3cb5e6f5 100644
Binary files a/images/github-banner.jpg and b/images/github-banner.jpg differ
diff --git a/plans/2026-07-25-fix-inflight-send-writes-other-chat-compose.md b/plans/2026-07-25-fix-inflight-send-writes-other-chat-compose.md
new file mode 100644
index 0000000000..7a27fdd9d1
--- /dev/null
+++ b/plans/2026-07-25-fix-inflight-send-writes-other-chat-compose.md
@@ -0,0 +1,244 @@
+# Fix: message being sent leaks into another chat's compose/draft, and erases what is typed there
+
+Branch: `nd/fix-inflight-send-writes-other-chat-compose` (off `origin/stable`)
+Date: 2026-07-25
+PR: #7308
+
+Line references are against `origin/stable` at `8dc387cb5`, with this fix
+applied. Android and desktop only
+(`multiplatform/.../views/chat/ComposeView.kt`); iOS has the same defect
+but is not addressed here.
+
+## Problem
+
+Two reported symptoms, one cause. Both need a send that is still in
+flight when the chat is switched (slow network, large file, or the send
+just hanging with the progress circle showing):
+
+1. **The message ends up in another chat's draft.** Reply to a message
+ (or just type), press send, switch to another chat while it is
+ sending: the text *and the reply context* appear in that chat's input,
+ and leaving it saves them as that chat's draft. No forwarding
+ involved.
+2. **A late send erases what you typed.** Press send, the progress circle
+ keeps spinning, switch to another chat and back, type a new message —
+ when the original send finally succeeds, the newly typed message is
+ erased.
+
+## Cause
+
+The compose state is shared, and the send outlives the chat:
+
+- `ChatView.kt:134` — one `MutableState{{ "index-hero-p1" | i18n({}, lang) | safe }}
+{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "index-hero-invest-cta" | i18n({}, lang) | safe }}