android, desktop: add CF page

This commit is contained in:
shum
2026-08-14 10:36:10 +00:00
parent abd5954675
commit 156483a57b
24 changed files with 307 additions and 60 deletions
@@ -1,6 +1,11 @@
package chat.simplex.common.views.onboarding
import SectionBottomSpacer
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 +13,26 @@ 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.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.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.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
@@ -32,9 +48,11 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.UserAddressView
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
import chat.simplex.common.views.usersettings.showAddShortLinkAlert
import chat.simplex.common.views.usersettings.simplexTeamUri
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 +932,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 +975,243 @@ 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/simplexchat"
// 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(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_00),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 8.dp)
.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_00,
"The first and the only messaging network without any user IDs",
null,
"SimpleX users have been more than doubling every year and you can now acquire a stake in the company that builds it."
),
CrowdfundingSlide(
MR.images.crowdfunding_04,
"480,000+ users joined on their own",
null,
"All these users found SimpleX Chat without any paid marketing and donated over \$650,000, paying for something they could use for free."
),
CrowdfundingSlide(
MR.images.crowdfunding_05,
"SimpleX is a network, not an app",
"Users, creators, businesses, developers and operators all arrived organically the cold start solved.",
"Each group of users makes the network more valuable to the rest, driving organic growth."
),
CrowdfundingSlide(
MR.images.crowdfunding_06,
"Developers already bet on SimpleX success",
"Independent developers created moderation and AI bots, Telegram bridges, and a public server registry.",
"Some projects describe themselves as SimpleX-first, running all communications of their applications over SimpleX Network."
),
CrowdfundingSlide(
MR.images.crowdfunding_07,
"Why SimpleX can't be copied",
"Only SimpleX combines scalable delivery, ownership that can't be revoked, and participants that can't be identified.",
"Other networks rely on user IDs to deliver messages, and large platforms monetize them. Removing IDs would require rebuilding."
),
CrowdfundingSlide(
MR.images.crowdfunding_10,
"An open network others can build on",
"The SimpleX Network Consortium agreement ensures that no single company controls the network, while protecting SimpleX Chat business.",
"The protocol is licensed to the foundation permanently the network remains available regardless of who owns the company."
),
CrowdfundingSlide(
MR.images.crowdfunding_11,
"We are building a network that people own",
"We invite you to invest and become part of it.",
"Read about how we plan to make SimpleX Chat and network profitable, and about all the investment terms on Wefunder."
),
)
@Composable
fun GetStakeView(close: () -> Unit) {
val uriHandler = LocalUriHandler.current
val stopped = chatModel.chatRunning.value == false
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle("Get a stake in SimpleX Chat", withPadding = false)
Text("By investing, you can benefit from the company growth, and help us build the future of private and secure communications.")
getStakeSlides.forEach { slide ->
Column(Modifier.padding(top = DEFAULT_PADDING * 1.5f)) {
if (BuildConfigCommon.SIMPLEX_ASSETS) {
Image(
painterResource(slide.image),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).pinchZoom().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))
}
}
Text(slide.text, Modifier.padding(top = 8.dp))
}
}
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.openExternalLink(WEFUNDER_URL) }
)
if (!chatModel.desktopNoUserNoRemote) {
TextButtonBelowOnboardingButton(
"or ask SimpleX team",
onClick = if (stopped) null else ({
close()
uriHandler.openVerifiedSimplexUri(simplexTeamUri)
})
)
}
}
SectionBottomSpacer()
}
}
// 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_SLIDE_ZOOM = 5f
/**
* The slide images contain small text that is unreadable at screen width, so they can be pinch-zoomed in place.
* 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 {
awaitFirstDown(requireUnconsumed = false)
var taken: Boolean? = null
do {
val event = awaitPointerEvent()
val multiTouch = event.changes.count { it.pressed } > 1
if (multiTouch || scale > 1f) {
scale = (scale * event.calculateZoom()).coerceIn(1f, MAX_SLIDE_ZOOM)
val pan = event.calculatePan()
// the content 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 enclosing column reach its own slop first and scroll the page. A one finger drag
// is left to the column 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(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">You can now invest in SimpleX Chat</string>
<string name="v7_0_invest_descr">Crowdfunding on Wefunder.</string>
<string name="v7_0_crowdfunding">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

@@ -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: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 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: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB