android, desktop: add CF page (#7373)

* android, desktop: add CF page

* android, desktop: update CF page

* android, desktop: sync ios changes

* android, desktop: sync ios CF page changes

* remove utm_source

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
sh
2026-08-17 12:03:56 +01:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent abd5954675
commit bdaa36e847
16 changed files with 308 additions and 61 deletions
@@ -31,7 +31,8 @@ fun AppBarTitle(
val connection = if (enableAlphaChanges) handler?.connection else null
LaunchedEffect(title) {
if (enableAlphaChanges) {
handler?.title?.value = title
// the app bar shows a single line, so the line breaks of the large title are replaced with spaces
handler?.title?.value = title.replace("\n", " ")
} else {
handler?.connection?.scrollTrackingEnabled = false
}
@@ -1,6 +1,10 @@
package chat.simplex.common.views.onboarding
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -8,15 +12,29 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalUriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.BuildConfigCommon
@@ -35,6 +53,7 @@ import chat.simplex.common.views.usersettings.showAddShortLinkAlert
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlin.math.absoluteValue
@Composable
fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) {
@@ -914,14 +933,15 @@ private val versionDescriptions: List<VersionDescription> = listOf(
)
),
VersionDescription(
version = "v7.0",
// the trailing space differs from the previously released "v7.0", so that What's new is shown again
version = "v7.0 ",
post = null,
features = listOf(
// VersionFeature.FeatureView(
// icon = null,
// titleId = MR.strings.v7_0_invest,
// view = { _ -> InvestInSimpleXChatView() }
// ),
VersionFeature.FeatureView(
icon = null,
titleId = MR.strings.v7_0_invest,
view = { modalManager -> InvestInSimpleXChatView(modalManager) }
),
VersionFeature.FeatureDescription(
icon = MR.images.ic_alternate_email,
titleId = MR.strings.v7_0_simplex_names,
@@ -956,57 +976,253 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean {
return v != lastVersion
}
// private const val WEFUNDER_URL = "https://wefunder.com/simplexchat"
//
// @Composable
// private fun InvestInSimpleXChatView() {
// if (platform.androidIsPlayStoreBuild) {
// LaunchedEffect(Unit) { if (androidPlayStoreCountry.value == null) platform.androidLoadPlayStoreCountry() }
// if (androidPlayStoreCountry.value != "US") return
// }
// val uriHandler = LocalUriHandler.current
// Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp)) {
// Column(modifier = Modifier.weight(1f)) {
// Row(
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(8.dp),
// modifier = Modifier.padding(bottom = 4.dp)
// ) {
// Icon(painterResource(MR.images.ic_redeem), stringResource(MR.strings.v7_0_invest), tint = MaterialTheme.colors.secondary)
// Text(
// generalGetString(MR.strings.v7_0_invest),
// maxLines = 2,
// overflow = TextOverflow.Ellipsis,
// style = MaterialTheme.typography.h4,
// fontWeight = FontWeight.Medium,
// modifier = Modifier.padding(bottom = 6.dp)
// )
// }
// Text(generalGetString(MR.strings.v7_0_invest_descr), fontSize = 15.sp, modifier = Modifier.padding(bottom = 4.dp))
// Row(
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(8.dp),
// modifier = Modifier
// .clickable(
// interactionSource = remember { MutableInteractionSource() },
// indication = null
// ) {
// uriHandler.openExternalLink(WEFUNDER_URL)
// }
// ) {
// Text(stringResource(MR.strings.v7_0_invest_learn_more), color = MaterialTheme.colors.primary, fontSize = 15.sp)
// Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.v7_0_invest_learn_more), tint = MaterialTheme.colors.primary)
// }
// }
// if (BuildConfigCommon.SIMPLEX_ASSETS) {
// Image(
// painterResource(if (isInDarkTheme()) MR.images.own_stake_light else MR.images.own_stake),
// contentDescription = null,
// modifier = Modifier.width(80.dp)
// )
// }
// }
// }
private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat"
private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im"
// the center modal takes the remaining width of the window, so the image is limited to its design width
private val MAX_CROWDFUNDING_IMAGE_WIDTH = DEFAULT_MIN_CENTER_MODAL_WIDTH
// Google Play policy restricts promoting investments, so Play builds only show it in the US
@Composable
fun crowdfundingAvailable(): Boolean {
if (!platform.androidIsPlayStoreBuild) return true
LaunchedEffect(Unit) {
if (androidPlayStoreCountry.value == null) platform.androidLoadPlayStoreCountry()
}
return androidPlayStoreCountry.value == "US"
}
@Composable
private fun InvestInSimpleXChatView(modalManager: ModalManager) {
if (!crowdfundingAvailable()) return
val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = false, close = close) } }
Column(modifier = Modifier.padding(bottom = 12.dp)) {
Text(
generalGetString(MR.strings.v7_0_invest),
style = MaterialTheme.typography.h4,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 6.dp)
)
Text(
buildAnnotatedString {
append(generalGetString(MR.strings.v7_0_invest_descr))
append(" ")
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append(generalGetString(MR.strings.learn_more))
}
},
fontSize = 15.sp,
modifier = Modifier
.pointerHoverIcon(PointerIcon.Hand)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = showGetStake
)
)
if (BuildConfigCommon.SIMPLEX_ASSETS) {
Image(
painterResource(MR.images.crowdfunding_1),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 8.dp)
.widthIn(max = MAX_CROWDFUNDING_IMAGE_WIDTH)
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.pointerHoverIcon(PointerIcon.Hand)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = showGetStake
)
)
}
}
}
private class CrowdfundingSlide(
val image: ImageResource,
val heading: String,
val info: String?,
val text: String,
)
// not localized: the page is only shown to US investors, and the text duplicates the images
private val getStakeSlides: List<CrowdfundingSlide> = listOf(
CrowdfundingSlide(
MR.images.crowdfunding_1,
"The first and the only messaging network without any user IDs",
null,
"By investing, you can benefit from the company growth, and help us build the future of private and secure communications."
),
CrowdfundingSlide(
MR.images.crowdfunding_2,
"480,000+ users joined on their own",
null,
"SimpleX users have been more than doubling every year without any paid marketing, and donated over \$650,000."
),
CrowdfundingSlide(
MR.images.crowdfunding_3,
"Developers already bet on SimpleX success",
"Independent developers created moderation and AI bots, Telegram bridges, and a public server registry.",
"Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat."
),
CrowdfundingSlide(
MR.images.crowdfunding_4,
"Revenue plan: free for users, channels & businesses pay",
"SimpleX Chat plans to earn from the infrastructure and services that creators, businesses and large communities need as they grow.",
"Read about how we plan to make SimpleX Chat and network profitable, and about all the investment terms on Wefunder."
),
)
@Composable
fun GetStakeView(fromSettings: Boolean, close: () -> Unit) {
val uriHandler = LocalUriHandler.current
val stopped = chatModel.chatRunning.value == false
@Composable
fun slideImage(slide: CrowdfundingSlide) {
if (BuildConfigCommon.SIMPLEX_ASSETS) {
Image(
painterResource(slide.image),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).fullScreenOnClick(slide.image)
)
} else {
Text(slide.heading, style = MaterialTheme.typography.h4, fontWeight = FontWeight.Medium)
if (slide.info != null) {
Text(slide.info, Modifier.padding(top = 4.dp), lineHeight = 24.sp)
}
}
}
ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle("Get a stake in\nSimpleX Chat", withPadding = false)
// What's new already shows the image of the first slide, above the link that opens this page
if (fromSettings) {
slideImage(getStakeSlides[0])
}
Text(
buildAnnotatedString {
append(getStakeSlides[0].text)
// only the link is clickable, the rest of the paragraph is not
withLink(LinkAnnotation.Url(WEFUNDER_URL) { uriHandler.openUriCatching(WEFUNDER_URL) }) {
withStyle(SpanStyle(color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold)) {
append(" Learn more and invest on Wefunder.")
}
}
},
Modifier.padding(top = if (fromSettings) 8.dp else 0.dp),
lineHeight = 24.sp
)
getStakeSlides.drop(1).forEach { slide ->
Column(Modifier.padding(top = DEFAULT_PADDING * 1.5f)) {
slideImage(slide)
Text(slide.text, Modifier.padding(top = 8.dp), lineHeight = 24.sp)
}
}
Column(
Modifier.fillMaxWidth().padding(top = DEFAULT_PADDING * 2),
horizontalAlignment = Alignment.CenterHorizontally
) {
OnboardingActionButton(
if (appPlatform.isAndroid) Modifier.fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.v7_0_invest_learn_more,
onboarding = null,
onclick = { uriHandler.openUriCatching(WEFUNDER_URL) }
)
if (!chatModel.desktopNoUserNoRemote) {
TextButtonBelowOnboardingButton(
"or ask SimpleX team",
onClick = if (stopped) null else ({
close()
uriHandler.openVerifiedSimplexUri(CROWDFUNDING_CONTACT_URI)
})
)
}
}
}
}
// there is no pinch gesture with a mouse, so on desktop a slide is opened full screen instead
@Composable
private fun Modifier.fullScreenOnClick(image: ImageResource): Modifier {
if (!appPlatform.isDesktop) return this
return pointerHoverIcon(PointerIcon.Hand).clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
ModalManager.fullscreen.showCustomModal { close ->
BackHandler(onBack = close)
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = close),
contentAlignment = Alignment.Center
) {
Image(painterResource(image), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize())
}
}
}
}
private const val MAX_PAGE_ZOOM = 5f
/**
* The slide images contain small text that is unreadable at screen width, so the page can be pinch-zoomed.
* Android only: pinch is unavailable with a mouse.
*/
@Composable
private fun Modifier.pinchZoom(): Modifier {
if (!appPlatform.isAndroid) return this
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var size by remember { mutableStateOf(IntSize.Zero) }
return this
.onGloballyPositioned { size = it.size }
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offsetX
translationY = offsetY
}
.pointerInput(Unit) {
awaitEachGesture {
// the initial pass, as the scroll of the same column is applied after this modifier and would take the gesture first
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
var taken: Boolean? = null
do {
val event = awaitPointerEvent(PointerEventPass.Initial)
val multiTouch = event.changes.count { it.pressed } > 1
if (multiTouch || scale > 1f) {
scale = (scale * event.calculateZoom()).coerceIn(1f, MAX_PAGE_ZOOM)
val pan = event.calculatePan()
// the page is scaled around its center, so it can be panned by half of the overflow in each direction
val maxX = size.width * (scale - 1f) / 2
val maxY = size.height * (scale - 1f) / 2
val pannedY = offsetY + pan.y * scale
// the clamp is applied even when the gesture is not taken: at scale 1 both bounds
// are 0, which resets the offsets after zooming back out
offsetX = (offsetX + pan.x * scale).coerceIn(-maxX, maxX)
offsetY = pannedY.coerceIn(-maxY, maxY)
// two fingers always mean zoom, taken without a touch slop: waiting for one would let
// the scroll reach its own slop first and scroll the page. A one finger drag is left
// to the scroll at the edges, decided once so it cannot alternate mid drag
if (multiTouch) taken = true
else if (taken == null && pan.y != 0f) taken = pannedY.absoluteValue < maxY
if (taken == true) event.changes.forEach { if (it.pressed) it.consume() }
}
} while (event.changes.any { it.pressed })
}
}
}
@Composable
fun CreateUpdateAddressShortLinkView(modalManager: ModalManager) {
@@ -29,8 +29,10 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.database.DatabaseView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromDeviceView
import chat.simplex.common.views.onboarding.GetStakeView
import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.crowdfundingAvailable
import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView
import chat.simplex.res.MR
@@ -110,6 +112,17 @@ fun SettingsLayout(
AppShutdownItem()
AppVersionItem(showVersion)
}
if (crowdfundingAvailable()) {
SectionDividerSpaced()
SectionView(stringResource(MR.strings.v7_0_invest)) {
SettingsActionItem(
painterResource(MR.images.ic_redeem),
stringResource(MR.strings.v7_0_crowdfunding),
{ ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = true, close = close) } }
)
}
}
SectionBottomSpacer()
}
}
@@ -2736,9 +2736,10 @@
<string name="v6_5_safe_web_links_descr">- opt-in to send link previews.\n- use SOCKS proxy if enabled.\n- prevent hyperlink phishing.\n- remove link tracking.</string>
<string name="v6_5_non_profit_governance">Non-profit governance</string>
<string name="v6_5_non_profit_governance_descr">To make SimpleX Network last.</string>
<!-- <string name="v7_0_invest">Invest in SimpleX Chat</string> -->
<!-- <string name="v7_0_invest_descr">Equity crowdfunding launched!</string> -->
<!-- <string name="v7_0_invest_learn_more">Learn more on Wefunder</string> -->
<string name="v7_0_invest" translatable="false">You can now invest in SimpleX Chat! 🚀</string>
<string name="v7_0_invest_descr" translatable="false">Crowdfunding on Wefunder.</string>
<string name="v7_0_crowdfunding" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_invest_learn_more" translatable="false">Learn more on Wefunder</string>
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
<string name="v7_0_channels">Better channels 📢</string>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB