fix commands, ui

This commit is contained in:
Evgeny @ SimpleX Chat
2026-06-10 10:54:24 +00:00
parent f39498e4f6
commit 752dba8bb2
11 changed files with 236 additions and 82 deletions
@@ -2022,7 +2022,8 @@ data class LocalProfile(
override val localAlias: String,
val contactLink: String? = null,
val preferences: ChatPreferences? = null,
val peerType: ChatPeerType? = null
val peerType: ChatPeerType? = null,
val localBadge: LocalBadge? = null
): NamedChat {
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
@@ -2046,6 +2047,57 @@ enum class ChatPeerType {
@SerialName("bot") Bot
}
// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status.
// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter.
@Serializable(with = BadgeTypeSerializer::class)
sealed class BadgeType {
@Serializable @SerialName("supporter") object Supporter: BadgeType()
@Serializable @SerialName("legend") object Legend: BadgeType()
@Serializable @SerialName("investor") object Investor: BadgeType()
@Serializable @SerialName("unknown") data class Unknown(val type: String): BadgeType()
// the disclosed (signed) type name, shown to the user for verified badges
val text: String
get() = when (this) {
is Supporter -> "supporter"
is Legend -> "legend"
is Investor -> "investor"
is Unknown -> type
}
}
object BadgeTypeSerializer : KSerializer<BadgeType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeType", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): BadgeType =
when (val v = decoder.decodeString()) {
"supporter" -> BadgeType.Supporter
"legend" -> BadgeType.Legend
"investor" -> BadgeType.Investor
else -> BadgeType.Unknown(v)
}
override fun serialize(encoder: Encoder, value: BadgeType) = encoder.encodeString(value.text)
}
@Serializable
enum class BadgeStatus {
@SerialName("active") Active,
@SerialName("expired") Expired,
@SerialName("failed") Failed
}
@Serializable
data class BadgeInfo(
val badgeType: BadgeType,
val badgeExpiry: Instant? = null,
val badgeExtra: String = ""
)
@Serializable
data class LocalBadge(
val badge: BadgeInfo,
val status: BadgeStatus
)
@Serializable
data class UserProfileUpdateSummary(
val updateSuccesses: Int,
@@ -708,7 +708,7 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
Modifier.padding(horizontal = DEFAULT_PADDING),
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight, tappableBadge = true)
val displayName = contact.profile.displayName.trim()
val text = buildAnnotatedString {
if (contact.verified) {
@@ -733,7 +733,7 @@ fun GroupMemberInfoHeader(member: GroupMember) {
Modifier.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight, tappableBadge = true)
val displayName = member.displayName.trim() // alias if set
val text = buildAnnotatedString {
if (member.verified) {
@@ -900,16 +900,20 @@ fun MemberProfileImage(
mem: GroupMember,
color: Color = MaterialTheme.colors.secondaryVariant,
backgroundColor: Color? = null,
async: Boolean = false
async: Boolean = false,
tappableBadge: Boolean = false
) {
ProfileImage(
size = size,
image = mem.image,
color = color,
backgroundColor = backgroundColor,
blurred = mem.blocked,
async = async
)
val badge = mem.memberProfile.localBadge
BadgedProfileImage(size, badge, onBadgeClick = if (tappableBadge) badge?.let { b -> { showBadgeInfoAlert(b) } } else null) {
ProfileImage(
size = size,
image = mem.image,
color = color,
backgroundColor = backgroundColor,
blurred = mem.blocked,
async = async
)
}
}
fun updateMembersRole(newRole: GroupMemberRole, rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onFailure: () -> Unit = {}, onSuccess: () -> Unit = {}) {
@@ -464,10 +464,10 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ProfileImage(
image = u.image,
size = 54.dp * fontSizeSqrtMultiplier
)
val avatarSize = 54.dp * fontSizeSqrtMultiplier
BadgedProfileImage(avatarSize, u.profile.localBadge) {
ProfileImage(image = u.image, size = avatarSize)
}
Text(
u.displayName,
modifier = Modifier
@@ -3,6 +3,7 @@ package chat.simplex.common.views.helpers
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.Icon
@@ -14,10 +15,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.BadgeStatus
import chat.simplex.common.model.BadgeType
import chat.simplex.common.model.ChatInfo
import chat.simplex.common.model.LocalBadge
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR
@@ -25,7 +30,7 @@ import dev.icerock.moko.resources.ImageResource
import kotlin.math.max
@Composable
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) {
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false, tappableBadge: Boolean = false) {
val icon =
when (chatInfo) {
is ChatInfo.Group -> chatInfo.groupInfo.chatIconName
@@ -33,7 +38,10 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme
is ChatInfo.Direct -> chatInfo.contact.chatIconName
else -> MR.images.ic_account_circle_filled
}
ProfileImage(size, chatInfo.image, icon, if (chatInfo is ChatInfo.Local) NoteFolderIconColor else iconColor)
val badge = if (chatInfo is ChatInfo.Direct) chatInfo.contact.profile.localBadge else null
BadgedProfileImage(size, badge, onBadgeClick = if (tappableBadge) badge?.let { b -> { showBadgeInfoAlert(b) } } else null) {
ProfileImage(size, chatInfo.image, icon, if (chatInfo is ChatInfo.Local) NoteFolderIconColor else iconColor)
}
}
@Composable
@@ -103,6 +111,71 @@ fun ProfileImage(
}
}
// Overlays a supporter badge on an avatar with zero layout impact: a custom Layout measures the badge but
// reports only the avatar's size, so the badge overflows bottom-right (not clamped) and nothing around it shifts.
@Composable
fun BadgedProfileImage(size: Dp, badge: LocalBadge?, onBadgeClick: (() -> Unit)? = null, avatar: @Composable () -> Unit) {
if (badge == null) {
avatar()
return
}
Layout(content = {
avatar()
ProfileBadge(size, badge, onBadgeClick)
}) { measurables, constraints ->
val a = measurables[0].measure(constraints)
val b = measurables[1].measure(Constraints())
layout(a.width, a.height) {
a.place(0, 0)
// phone center sits 0.33*S right and down of the avatar center, overflowing the avatar bounds
val off = (0.33f * a.width).toInt()
b.place(x = a.width / 2 + off - b.width / 2, y = a.height / 2 + off - b.height / 2)
}
}
}
// the phone glyph (or warning triangle) scales inversely to the avatar so it stays readable when the avatar is small.
@Composable
private fun ProfileBadge(size: Dp, badge: LocalBadge, onBadgeClick: (() -> Unit)?) {
val s = size.value
val mult = 1f + 0.5f * ((192f - s) / 156f).coerceIn(0f, 1f)
val phoneH = 0.28f * size * mult
val phoneW = phoneH * 0.7617f
val mod = Modifier.size(width = phoneW, height = phoneH).let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it }
if (badge.status == BadgeStatus.Failed) {
Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod)
} else {
Image(
painterResource(badgeImage(badge.badge.badgeType)),
contentDescription = null,
contentScale = ContentScale.Fit,
alpha = if (badge.status == BadgeStatus.Expired) 0.4f else 1f,
modifier = mod
)
}
}
fun showBadgeInfoAlert(badge: LocalBadge) {
if (badge.status == BadgeStatus.Failed) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badge_unverified_title),
text = generalGetString(MR.strings.badge_unverified_desc)
)
} else {
// a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is shown
AlertManager.shared.showAlertMsg(
title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() },
text = generalGetString(MR.strings.badge_verified_desc)
)
}
}
private fun badgeImage(t: BadgeType): ImageResource = when (t) {
is BadgeType.Legend -> MR.images.badge_legend
is BadgeType.Investor -> MR.images.badge_investor
else -> MR.images.badge_supporter // Supporter + Unknown
}
@Composable
fun ProfileImage(size: Dp, image: ImageResource) {
Image(
@@ -3105,4 +3105,7 @@
<string name="tray_tooltip_unread">SimpleX — %d unread</string>
<string name="appearance_minimize_to_tray">Minimize to tray when closing window</string>
<string name="appearance_minimize_to_tray_desc">Keep SimpleX running in the background to receive messages.</string>
<string name="badge_verified_desc">This is a verified badge - its type is signed and cannot be faked.</string>
<string name="badge_unverified_title">Unverified badge</string>
<string name="badge_unverified_desc">This badge could not be verified and may not be genuine.</string>
</resources>