diff --git a/README.md b/README.md index 601ab5a161..5583fad0b5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SimpleX logo -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` per `ChatView` + instance, `rememberSaveable` with no keys, reused for every chat that + the view displays. +- `Utils.kt:43-46` — `withLongRunningApi` launches on + `CoroutineScope(Dispatchers.Default)`, a standalone scope with no tie + to the composition or to the chat, and `sendMessage` + (`ComposeView.kt:972-976`) uses it. Leaving the chat never cancels an + in-flight send. + +Two writes then act on the wrong chat: + +- **On the chat switch** — `ComposeView.kt:1343-1347`: the `cs.inProgress` + branch used to keep the message in the shared compose state + (`composeState.value = cs.copy(inProgress = false, progressByTimeout = false)`) + and only cleared the *previous* chat's saved draft. The text and the + quote were therefore sitting in the input of the chat opened next, and + `ComposeView.kt:1348-1358` (`!cs.empty`) then saved them as *that* + chat's draft on the next switch. Symptom 1. +- **When the send completes** — `ComposeView.kt:943-968`, running in the + detached coroutine after the switch: `clearState(live)` on success, or + `composeState.value = lastFailed` on failure, where `lastFailed = + cs.copy(inProgress = false, preview = preview)` + (`ComposeView.kt:729`) **keeps `contextItem`, i.e. the reply**. On + success this wipes whatever is in the input now — including a message + typed after coming back (symptom 2); on failure it dumps the old + message into whichever chat is open (symptom 1 again). + +The same function was already inconsistent about which chat it acts on: +its draft bookkeeping (`clearCurrentDraft()`, and the forwarding +condition) uses the **captured** `chat` — the chat the message was +composed in — while its `composeState` writes hit whatever chat is +displayed at that moment. + +## Fix + +Two changes, both in `ComposeView.kt`. + +**1. Do not keep the message being sent in the shared compose state** +(`ComposeView.kt:1343-1347`). On switching away with a send in flight the +compose state is cleared, so nothing leaks into the chat opened next: + +```kotlin +} else if (cs.inProgress) { + clearPrevDraft(prevChatId) + // 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()` is used rather than assigning an empty `ComposeState` so that +the link preview state is reset too (`pendingLinkUrl` still points at the +sent message's link, and its fetch would otherwise set a preview on the +input of the chat opened next), and so that the attachment size limit is +carried over the same way as everywhere else. + +In-flight content is deliberately **not** saved as a draft here: the +message has been submitted and will most likely be sent, and a draft is +for messages that are not sent yet. + +`clearState()` alone would leave the chat opened next with an empty input +even when it has a draft: this branch, like the live message one above it, +returns before the branch that loads a draft +(`chatModel.draftChatId.value == draftChatId(chatModel.chatId.value, chatScope)`), +so that draft was never shown - and, being still in the slot but not in +any compose state, it was then dropped by `clearPrevDraft` on the next +chat switch. It is loaded here instead. This is not the one-slot +limitation below: nothing else is competing for the slot, the draft is +simply lost. + +**2. Only touch the compose state if it still holds the message that was +sent** (`ComposeView.kt:936-968`): + +```kotlin +withContext(Dispatchers.Main) { + val chatIsOpen = chatModel.chatId.value == chat.id + 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() + if (!sentMessageInCompose && 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) + } + } + } +} +``` + +Both the checks and the changes run on `Dispatchers.Main` (the block has +no suspension points), so they cannot be interleaved with the user +switching chats or typing - `KeyChangeEffect`, which does change 1, runs +there too. + +`inProgress` is the marker that the compose state is still the submitted +message: it is set by `sending()` (`ComposeView.kt:596-598`), preserved by +`copy` while sending (the only other write during a send is +`progressByTimeout` at `ComposeView.kt:1610-1617`), reset when switching +away (change 1), and never set by typing a new message. So a chat switch +*or* newly typed text both make the guard false. + +A **failed** send is different from an in-flight one - the message was not +sent, so it is an unsent message. It is put back into the input if that +chat is open and nothing else is being composed there, and kept as that +chat's draft otherwise, so it never appears in another chat (see the +limitations below for when it is still dropped). Staying in the chat is +unaffected: the guard is true there +and the failed message is restored into the input as before, keeping +"preserving long message when failed to send" (`e61babdc8`) working. + +Deliberately unchanged: + +- The condition of the forwarding branch. Gating the whole branch would + send a forward that completed after the user left to `clearCurrentDraft()` + instead, **deleting** the destination chat's draft that the branch + exists to preserve - only the compose write inside it is gated. +- Live message sends (`live`, or `cs.liveMessage != null` for the send + that finalises a live message when leaving the chat, `ComposeView.kt:1338-1342`), + as long as their chat is the one open. They never call `sending()`, so a + guard based on `inProgress` would change their behaviour: failed live + sends would stop restoring and would write a draft on every failing + keystroke send. That is why `liveSend` is an alternative to `inProgress` + inside the guard, and why it is excluded from the restore/draft branch - + not gating it there would produce exactly that draft-per-keystroke. + + What they are **not** exempt from is `chatIsOpen`. An earlier revision + had `live || cs.liveMessage != null` outside it, which holds only while + a live message is always sent to the chat that is open. #7323 removes + that: the live message committed by a chat switch is sent to the chat it + was composed in, while this view already shows another one, so an + unguarded clause here would clear *that* chat's compose state - the leak + this fix exists to prevent. Standalone this changes nothing except a + live send that completes after its chat was left, which now leaves the + opened chat alone. `sendMessageAsync` reads `composeState` inside the + coroutine (`ComposeView.kt:684`), so that branch cannot clear the state + itself without racing the send; #7323 adds the `composed` parameter that + makes the captured state explicit. + +**3. The same check where the flag is shared** (`ComposeView.kt:600-602` +and the three senders that connect a prepared chat). They call the same +`sending()`, so an unguarded `clearState()` or `inProgress` reset from +one of them corrupts the state of a send started in the chat opened next. + +## Behaviour after the fix + +| situation | before | after | +| --- | --- | --- | +| send, stay in chat, succeeds | input cleared | input cleared (unchanged) | +| send, stay in chat, fails | message restored in input | message restored in input (unchanged) | +| send, switch chats, succeeds | message left in the other chat's input, saved as its draft | other chat untouched | +| send, switch chats, fails | message dumped into the other chat's input | message restored in the chat it was composed in, or kept as its draft | +| send hangs, switch away and back, type, then it succeeds | typed message erased | typed message kept | +| forward send, still in destination chat | destination chat's draft restored | unchanged | +| live message sent on leaving the chat | compose state cleared by the send | unchanged | + +## Limitations + +Kept deliberately, to not grow the change: + +- A message that failed to send is dropped, rather than kept, when the + "Message draft" privacy setting is off, when the destination chat of a + failed forward already has a draft (its own draft is preserved + instead), and when the single draft slot is later taken by another + chat - drafts are one global slot, so the last write wins. +- The three senders that connect a prepared chat share the same + `sending()` flag, so they use the same check (`ComposeView.kt:604-616`, + `618-640`, `659-685`). Without it a connect completing after the chat + was switched would clear `inProgress` for a send started in the chat + opened next, and that sent message would then stay in the input. They + have no failed-message restore, so their typed message is dropped when + the chat is switched instead of being carried into the next chat. +- Typing in the same chat while its own send is in flight is still + cleared when the send completes: `inProgress` is preserved by `copy`, + so the guard stays true. Unchanged from before, and different from the + reported symptom, which needs the chat to be switched. +## Verification + +- `./gradlew :common:compileKotlinDesktop` — passes. +- Manual (needs a slow or failing send — e.g. airplane mode, or a large + file). On desktop any chat switch exercises it; on Android only an + in-place switch does (member info → open chat), because leaving to the + chat list destroys the view: + 1. Reply + type in A, send, switch to B while sending. B's input must + stay empty; leaving B must not create a draft in B. If the send + failed, A must hold the message (with the reply) as its draft. + 2. Send in A with the network off so the circle keeps spinning, switch + to B and back to A, type a new message, restore the network. The + typed message must survive the old send completing. + 3. Regression: ordinary send in A (input clears), failed send while + staying in A (message comes back in the input), forward into a chat + that has a draft (draft restored after sending). + +Rebased onto the scope-aware draft ids introduced by #7309: the draft +written here for a message that failed to send uses +`draftChatId(chat.id, chatScope)`, like every other draft write. + +Related: `plans/2026-07-25-fix-forward-moves-draft-to-target-chat.md` +(PR #7307) — different cause (stale `chat` captured by the desktop +`onDispose`), same shared-compose-state design. diff --git a/plans/2026-07-31-fix-db-passphrase-toggle-clipped.md b/plans/2026-07-31-fix-db-passphrase-toggle-clipped.md new file mode 100644 index 0000000000..16767d62aa --- /dev/null +++ b/plans/2026-07-31-fix-db-passphrase-toggle-clipped.md @@ -0,0 +1,124 @@ +# Fix "Save passphrase in settings" toggle unreachable on desktop + +## Symptom + +On 7.0 desktop (reproduced on the Linux AppImage and on Windows), in +Chat data → Database passphrase & export → Database passphrase, the +"Save passphrase in settings" toggle cannot be switched off. The reporter sees +the switch sitting slightly past the right edge of the section card. Because the +setting stays on, the passphrase remains stored in `settings.properties` and the +app never prompts for it on start — on desktop that file holds the passphrase in +clear text, since `Cryptor.desktop.kt` is an identity implementation. + +This is distinct from the case where the switch is *rendered disabled* +(`DatabaseEncryptionView.kt:127`, `enabled = (!initialRandomDBPassphrase && !progressIndicator) || migration`), +which is intended behaviour for a database still using the initial random +passphrase. The reports here are from users who set their own passphrase, so +`initialRandomDBPassphrase == false` and the switch is enabled — just not +reachable. + +## Root cause + +1. `SavePassphraseSetting` is hand-rolled in both platform actuals + (`DatabaseEncryptionView.desktop.kt:43-53`, `.android.kt:43-53`) and is the + only toggle row in the app whose label carries no `weight`: + + ```kotlin + Text(stringResource(MR.strings.save_passphrase_in_settings), Modifier.padding(end = 24.dp)) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch(checked = useKeychain, onCheckedChange = onCheckedChange, enabled = enabled) + ``` + + `Row` measures unweighted children first against the full available width, then + divides what is left among weighted ones. A label that does not comfortably fit + consumes the remainder, the weighted `Spacer` collapses to zero, and + `DefaultSwitch` is placed past the row's right edge. + +2. Every other toggle row goes through `SettingsActionItemWithContent` + (`SettingsView.kt:380`), which gives the label `Modifier.weight(1f)`. There the + label truncates and the trailing control keeps its size and position, so the + same string length is harmless. + +3. Before #6777 this row had enough slack and no clipping. `SectionView` was a + plain `Column` with no horizontal inset, and `SectionItemView` used + `DEFAULT_PADDING` (20.dp) per side — 348.dp of content width on the desktop + start pane (`DEFAULT_START_MODAL_WIDTH` = 388.dp). Any overflow still drew and + still received pointer events. + +4. #6777 introduced `LocalCardScreen` / `CardColumnLayout` (`Section.kt`), which + wraps section content in `Modifier.padding(horizontal = CARD_PADDING /* 18.dp */)` + … `.clip(SectionCardShape)`, and switches `itemHPadding` from `DEFAULT_PADDING` + to `CARD_PADDING`. `DatabaseEncryptionView` is opened with `cardScreen = true` + (`DatabaseView.kt:235`), so its row content width drops 348.dp → **316.dp**. + +5. `Modifier.clip` clips pointer input as well as drawing. The displaced switch is + therefore both cut off visually and unhittable — the toggle stops working rather + than merely looking wrong. + +Budget arithmetic on the desktop start pane: fixed cost in the row is ~96.dp +(24 icon + 8 spacer + 24 label end-padding + ~40 switch), leaving ~220.dp for a +27-character label at 16.sp, which needs ~215.dp in English. Borderline at 100% +font scale and over budget as soon as the label is longer — a longer localization, +or a larger font size, since the label scales with `fontSizeSqrtMultiplier` while +`CARD_PADDING` does not. + +The widths above are derived from the layout constants, not measured against a +running client; the reporter's observation that the switch sits slightly past the +card edge is what confirms the row overflows in practice. + +## Fix + +Move the weight onto the label and drop the weighted spacer, in both actuals: + +```kotlin +Text(stringResource(MR.strings.save_passphrase_in_settings), Modifier.weight(1f).padding(end = 24.dp)) +DefaultSwitch(checked = useKeychain, onCheckedChange = onCheckedChange, enabled = enabled) +``` + +The label now truncates instead of displacing the switch, matching what +`SettingsActionItemWithContent` does for every other toggle row. + +The spacer has to go: leaving both the label and the spacer weighted would split +the remaining space between them and starve the label instead, which trades one +layout bug for another. + +## Why this fix and not alternatives + +- **Widening the card or shrinking `CARD_PADDING`** would buy back the ~32.dp lost + in #6777, but only until the next longer localization or font-size step. The row + would stay the one place in the app where a long label can push a control out of + reach. +- **Removing `clip` from `CardColumnLayout`** would restore clickability of + overflowing content, but the clip is what gives section cards their rounded + corners; dropping it would regress the design and leave the switch drawn outside + its card. +- **Shortening the string** is a translation-wide problem, not a fix, and does not + help at larger font sizes. + +## Impact + +- Desktop and Android only. Both actuals carry the identical defect; Android's row + is in fact narrower still (~288.dp on a 360.dp-wide screen), so it is affected at + least as much — it simply has not been reported. +- iOS is unaffected: `DatabaseEncryptionView.swift` uses a SwiftUI `Toggle` inside + `settingsRow`, where the label truncates and the toggle cannot be displaced. The + `initialRandomDBPassphrase` disabled-state logic is the same on iOS + (`DatabaseEncryptionView.swift:80`) and is unchanged by this fix. +- Users already stuck in the bad state have `StoreDBPassphrase=true` in + `settings.properties` with the passphrase stored alongside it. After this fix + they can turn the setting off in the UI, which removes the stored passphrase via + `removePassphraseFromKeyChain` and restores the prompt on start. +- No behaviour change beyond the row layout: no logic, preference, or string was + touched. + +## Verification + +- `bash ~/build/linux.sh` on this branch: cold `dist-newstyle`, `libsimplex.so` + rebuilt from master's sources, `:common:compileKotlinDesktop` executed, + `BUILD SUCCESSFUL`, AppImage produced. +- `bash ~/build/android.sh` on this branch: `BUILD SUCCESSFUL`, arm64-v8a debug APK + produced (native libs are the prebuilt ones, so this exercises the Kotlin change + only). +- Not done: the rendered row has not been checked in a running client. Worth + confirming at a raised font size and in a locale with a longer label, which is + the case that made the overflow visible in the first place. diff --git a/website/langs/en.json b/website/langs/en.json index 3cbcb12ee2..482fe50042 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -264,6 +264,8 @@ "index-hero-h1": "Be
Free", "index-hero-h2": "In Your Network", "index-hero-p1": "The first network without user IDs.
You own your contacts, groups and channels.", + "index-hero-invest": "Invest in SimpleX Chat.", + "index-hero-invest-cta": "Learn more on Wefunder.", "index-hero-download-desktop-btn-title": "Download SimpleX Desktop App", "index-testflight-title": "SimpleX iOS beta-release on TestFlight", "index-f-droid-title": "SimpleX app via F-Droid", diff --git a/website/src/css/design3.css b/website/src/css/design3.css index ea6f20273b..4b645804eb 100644 --- a/website/src/css/design3.css +++ b/website/src/css/design3.css @@ -434,6 +434,16 @@ section.cover div.content p { max-width: calc(var(--sec-vwu) * 53); } +section.cover div.content p.invest { + font-weight: 300; +} + +section.cover div.content p.invest a { + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + .publications-btns { position: absolute; bottom: 24px; @@ -960,7 +970,7 @@ main .section-bg { @media (max-width: 959px) { section.cover div.content { gap: calc(var(--sec-vhu) * 2.5); - transform: translateY(calc(var(--sec-vhu) * 5)); + transform: translateY(calc(var(--sec-vhu) * 3)); } .publications-btns, @@ -994,6 +1004,14 @@ main .section-bg { max-width: calc(var(--sec-vwu) * 93); } + section.cover div.content p.invest { + font-weight: 400; + } + + section.cover div.content p.invest a { + font-weight: 600; + } + /* --- MAIN SECTIONS --- */ .page .text-container { justify-content: flex-end; diff --git a/website/src/index.html b/website/src/index.html index ba4494ab1d..3ce15f6d56 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -100,6 +100,7 @@ active_home: true

{{ "index-hero-h1" | i18n({}, lang) | safe }}

{{ "index-hero-h2" | i18n({}, lang) | safe }}

{{ "index-hero-p1" | i18n({}, lang) | safe }}

+

{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "index-hero-invest-cta" | i18n({}, lang) | safe }}