Merge branch 'stable' into stable-android

This commit is contained in:
Evgeny Poberezkin committed 2026-06-15 23:00:43 +01:00
commit 477fca6645
122 files changed
+4046 -1564

No files matched your search

@@ -95,8 +95,9 @@ fun UserPickerUserBox(
}
}
val user = userInfo.user
Text(
NameWithBadge(
user.displayName,
user.profile.localBadge,
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1430,6 +1430,17 @@ data class Chat(
@Serializable
sealed class ChatInfo: SomeChat, NamedChat {
// the badge shown for a chat's name: an active contact's or a contact request's (groups have none).
// a badge that expired over a month ago (ExpiredOld) is not shown at all.
val nameBadge: LocalBadge? get() {
val badge = when {
this is Direct && contact.active -> contact.profile.localBadge
this is ContactRequest -> contactRequest.profile.localBadge
else -> null
}
return if (badge?.status == BadgeStatus.ExpiredOld) null else badge
}
@Serializable @SerialName("direct")
data class Direct(val contact: Contact): ChatInfo() {
override val chatType get() = ChatType.Direct
@@ -1994,7 +2005,10 @@ data class Profile(
override val localAlias : String = "",
val contactLink: String? = null,
val preferences: ChatPreferences? = null,
val peerType: ChatPeerType? = null
val peerType: ChatPeerType? = null,
// the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge),
// but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof
val badge: BadgeProof? = null
): NamedChat {
val profileViewName: String
get() {
@@ -2022,7 +2036,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 +2061,70 @@ 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,
// expired over a month ago - the badge is not shown at all
@SerialName("expiredOld") ExpiredOld,
@SerialName("failed") Failed,
// signed with a key index this app version does not know - shown as a warning
@SerialName("unknownKey") UnknownKey
}
@Serializable
data class BadgeInfo(
val badgeType: BadgeType,
val badgeExpiry: Instant? = null,
val badgeExtra: String = ""
)
@Serializable
data class LocalBadge(
val badge: BadgeInfo,
val status: BadgeStatus
)
// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact)
@Serializable
data class BadgeProof(
val badgeKeyIdx: Int,
val presHeader: String,
val proof: String,
val badgeInfo: BadgeInfo
)
@Serializable
data class UserProfileUpdateSummary(
val updateSuccesses: Int,
@@ -2278,7 +2357,9 @@ enum class MemberCriteria {
data class ContactShortLinkData (
val profile: Profile,
val message: MsgContent?,
val business: Boolean
val business: Boolean,
// set by the core when building the connection plan: the link profile's badge, verified and crypto-free
val localBadge: LocalBadge? = null
)
@Serializable
@@ -2409,6 +2490,11 @@ data class GroupMember (
override val image: String? get() = memberProfile.image
val contactLink: String? = memberProfile.contactLink
val verified get() = activeConn?.connectionCode != null
// the badge shown for a member's name; a badge that expired over a month ago (ExpiredOld) is not shown
val nameBadge: LocalBadge? get() {
val badge = memberProfile.localBadge
return if (badge?.status == BadgeStatus.ExpiredOld) null else badge
}
val blocked get() = blockedByAdmin || !memberSettings.showMessages
override val localAlias: String = memberProfile.localAlias
@@ -2727,7 +2813,7 @@ class UserContactRequest (
val contactRequestId: Long,
val cReqChatVRange: VersionRange,
override val localDisplayName: String,
val profile: Profile,
val profile: LocalProfile,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -2753,7 +2839,7 @@ class UserContactRequest (
contactRequestId = 1,
cReqChatVRange = VersionRange(1, 1),
localDisplayName = "alice",
profile = Profile.sampleData,
profile = LocalProfile.sampleData,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -710,19 +710,32 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
val displayName = contact.profile.displayName.trim()
val badge = cInfo.nameBadge
val text = buildAnnotatedString {
if (contact.verified) {
appendInlineContent(id = "shieldIcon")
}
append(displayName)
}
val inlineContent: Map<String, InlineTextContent> = mapOf(
"shieldIcon" to InlineTextContent(
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
if (badge != null) {
append(" ")
appendInlineContent(id = "nameBadge")
}
)
}
val nameFontSize = MaterialTheme.typography.h1.fontSize
val uriHandler = LocalUriHandler.current
val inlineContent: Map<String, InlineTextContent> = buildMap {
put(
"shieldIcon",
InlineTextContent(
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
}
)
if (badge != null) {
put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) })
}
}
val clipboard = LocalClipboardManager.current
val copyNameToClipboard = fun (name: String) {
clipboard.setText(AnnotatedString(name))
@@ -170,9 +170,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun ForwardedFromSender(forwardedFromItem: AChatItem) {
@Composable
fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) {
Text(
fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground, badge: LocalBadge? = null) {
NameWithBadge(
text,
badge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1,
@@ -191,13 +192,13 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
if (forwardedFromItem.chatItem.chatDir.sent) {
ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic)
Spacer(Modifier.height(7.dp))
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary)
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge)
} else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) {
ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName)
ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName, badge = forwardedFromItem.chatItem.chatDir.groupMember.nameBadge)
Spacer(Modifier.height(7.dp))
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary)
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge)
} else {
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground)
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground, badge = forwardedFromItem.chatInfo.nameBadge)
}
}
}
@@ -344,9 +345,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
) {
MemberProfileImage(size = 36.dp, member)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Text(
NameWithBadge(
member.chatViewName,
modifier = Modifier.weight(10f, fill = true),
member.nameBadge,
Modifier.weight(10f, fill = true),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -21,6 +21,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
@@ -1564,8 +1565,8 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
ContactVerifiedShield()
}
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
NameWithBadge(
cInfo.displayName, cInfo.nameBadge, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
@@ -2016,8 +2017,10 @@ fun BoxScope.ChatItemsList(
} else {
null to 1
}
Text(
// the name and the badge are one element, so SpaceBetween separates them from the role, not from each other
NameWithBadge(
memberNames(member, prevMember, memCount),
if (prevMember == null && memCount == 1) member.nameBadge else null,
Modifier
.padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
.weight(1f, false),
@@ -2284,8 +2287,18 @@ fun BoxScope.ChatItemsList(
.background(MaterialTheme.appColors.receivedMessage)
) {
ChatInfoImage(chatInfo, size = alertProfileImageSize, iconColor = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f))
val bannerBadge = chatInfo.nameBadge
val uriHandler = LocalUriHandler.current
Text(
chatInfo.displayName,
buildAnnotatedString {
append(chatInfo.displayName)
if (bannerBadge != null) {
append(" ")
appendInlineContent(id = "nameBadge")
}
},
inlineContent =
if (bannerBadge != null) mapOf("nameBadge" to nameBadgeInline(bannerBadge, MaterialTheme.typography.h3.fontSize) { showBadgeInfoAlert(chatInfo.displayName, bannerBadge, uriHandler) }) else emptyMap(),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
@@ -113,7 +113,9 @@ data class ComposeState(
val inProgress: Boolean = false,
val progressByTimeout: Boolean = false,
val useLinkPreviews: Boolean,
val mentions: MentionedMembers = emptyMap()
val mentions: MentionedMembers = emptyMap(),
// the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch
val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP)
) {
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
ComposeMessage(
@@ -251,8 +253,6 @@ data class ComposeState(
}
}
private val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
sealed class RecordingState {
object NotStarted: RecordingState()
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
@@ -300,6 +300,7 @@ fun MutableState<ComposeState>.onFilesAttached(uris: List<URI>) {
fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
if (uri != null) {
val maxFileSize = value.maxFileSize
val fileSize = getFileSize(uri)
if (fileSize != null && fileSize <= maxFileSize) {
val fileName = getFileName(uri)
@@ -318,6 +319,7 @@ fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
}
suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text: String?) {
val maxFileSize = value.maxFileSize
val content = ArrayList<UploadContent>()
val imagesPreview = ArrayList<String>()
uris.forEach { uri ->
@@ -487,7 +489,7 @@ fun ComposeView(
if (live) {
composeState.value = composeState.value.copy(inProgress = false, progressByTimeout = false)
} else {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews, maxFileSize = composeState.value.maxFileSize)
resetLinkPreview()
}
recState.value = RecordingState.NotStarted
@@ -1094,7 +1096,7 @@ fun ComposeView(
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable }
if (lastEditable != null) {
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews).copy(maxFileSize = composeState.value.maxFileSize)
}
}
@@ -1322,6 +1324,11 @@ fun ComposeView(
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
// keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented
LaunchedEffect(chat.chatInfo) {
val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get()
composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile))
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
@@ -354,9 +354,10 @@ fun ContactCheckRow(
) {
ProfileImage(size = 36.dp, contact.image)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Text(
NameWithBadge(
contact.chatViewName,
modifier = Modifier.weight(10f, fill = true),
if (contact.active) contact.profile.localBadge else null,
Modifier.weight(10f, fill = true),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
@@ -94,8 +94,9 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole
if (member.verified) {
MemberVerifiedShield()
}
Text(
NameWithBadge(
member.chatViewName,
member.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
@@ -1078,8 +1078,10 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr
if (member.verified) {
MemberVerifiedShield()
}
Text(
if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
NameWithBadge(
if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName,
member.nameBadge,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -735,19 +736,32 @@ fun GroupMemberInfoHeader(member: GroupMember) {
) {
MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
val displayName = member.displayName.trim() // alias if set
val badge = member.nameBadge
val text = buildAnnotatedString {
if (member.verified) {
appendInlineContent(id = "shieldIcon")
}
append(displayName)
}
val inlineContent: Map<String, InlineTextContent> = mapOf(
"shieldIcon" to InlineTextContent(
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
if (badge != null) {
append(" ")
appendInlineContent(id = "nameBadge")
}
)
}
val nameFontSize = MaterialTheme.typography.h1.fontSize
val uriHandler = LocalUriHandler.current
val inlineContent: Map<String, InlineTextContent> = buildMap {
put(
"shieldIcon",
InlineTextContent(
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
}
)
if (badge != null) {
put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) })
}
}
val clipboard = LocalClipboardManager.current
val copyNameToClipboard = fun(name: String) {
clipboard.setText(AnnotatedString(name))
@@ -137,8 +137,8 @@ fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, ic
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.displayName, fontWeight = FontWeight.SemiBold,
NameWithBadge(
member.displayName, member.nameBadge, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
@@ -234,8 +234,8 @@ fun SupportChatRow(member: GroupMember) {
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
NameWithBadge(
member.chatViewName, member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
@@ -33,6 +33,7 @@ fun CIFileView(
edited: Boolean,
showMenu: MutableState<Boolean>,
smallView: Boolean = false,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
@@ -71,12 +72,12 @@ fun CIFileView(
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> {
if (fileSizeValid(file)) {
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
}
}
@@ -151,7 +152,7 @@ fun CIFileView(
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled))
is CIFileStatus.RcvInvitation ->
if (fileSizeValid(file))
if (fileSizeValid(file, senderProfile))
fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp())
else
fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange)
@@ -225,7 +226,9 @@ fun CIFileView(
}
}
fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol)
// whether a received file is within the size we accept from its sender
fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean =
file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile)
fun showFileErrorAlert(err: FileError, temporary: Boolean = false) {
val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error)
@@ -35,6 +35,7 @@ fun CIImageView(
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
smallView: Boolean,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
@@ -160,13 +161,6 @@ fun CIImageView(
}
}
fun fileSizeValid(): Boolean {
if (file != null) {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
suspend fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? {
val res = getLoadedImage(file)
if (res != null) {
@@ -213,12 +207,12 @@ fun CIImageView(
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted ->
if (fileSizeValid()) {
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
}
file.fileStatus is CIFileStatus.RcvAccepted ->
@@ -34,6 +34,7 @@ fun CIVideoView(
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
smallView: Boolean = false,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
@@ -84,7 +85,7 @@ fun CIVideoView(
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted ->
receiveFileIfValidSize(file, receiveFile)
receiveFileIfValidSize(file, senderProfile, receiveFile)
CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
FileProtocol.XFTP ->
@@ -114,7 +115,7 @@ fun CIVideoView(
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
}
if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) {
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) }
}
}
}
@@ -546,20 +547,13 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
private fun showDownloadButton(status: CIFileStatus?): Boolean =
status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted
private fun fileSizeValid(file: CIFile?): Boolean {
if (file != null) {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
if (fileSizeValid(file)) {
private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) {
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
}
}
@@ -447,7 +447,7 @@ fun ChatItemView(
}
if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) {
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) {
ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = {
withBGApi {
Log.d(TAG, "ChatItemView downloadFileAction")
@@ -201,7 +201,7 @@ fun FramedItemView(
@Composable
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile)
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
@@ -312,7 +312,7 @@ fun FramedItemView(
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile)
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
@@ -320,7 +320,7 @@ fun FramedItemView(
}
}
is MsgContent.MCVideo -> {
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile)
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
@@ -152,7 +152,15 @@ fun ChatPreviewView(
} else {
Color.Unspecified
}
chatPreviewTitleText(color = color)
NameWithBadge(
cInfo.chatViewName,
cInfo.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = color
)
}
}
is ChatInfo.Group -> {
@@ -316,13 +324,13 @@ fun ChatPreviewView(
}
}
is MsgContent.MCImage -> SmallContentPreview {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIImageView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
}
is MsgContent.MCVideo -> SmallContentPreview {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIVideoView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -334,7 +342,7 @@ fun ChatPreviewView(
}
}
is MsgContent.MCFile -> SmallContentPreviewFile {
CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true) {
CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIFileView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -27,8 +27,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
.padding(start = 8.dp, end = 8.sp.toDp())
.weight(1F)
) {
Text(
NameWithBadge(
contactRequest.chatViewName,
contactRequest.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
@@ -104,8 +104,8 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) {
} else {
ProfileImage(size = 42.dp, chat.chatInfo.image)
}
Text(
chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
NameWithBadge(
chat.chatInfo.chatViewName, chat.chatInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (disabled) MaterialTheme.colors.secondary else if (chat.chatInfo.incognito) Indigo else Color.Unspecified
)
}
@@ -210,7 +210,7 @@ fun UserPicker(
}
} else if (currentUser != null) {
SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped)
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped, badge = currentUser.profile.localBadge)
}
}
}
@@ -468,10 +468,11 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning
image = u.image,
size = 54.dp * fontSizeSqrtMultiplier
)
Text(
// the end padding is on the row, not the name, so the badge stays right after the name
NameWithBadge(
u.displayName,
modifier = Modifier
.padding(start = 10.dp, end = 8.dp),
u.profile.localBadge,
Modifier.padding(start = 10.dp, end = 8.dp),
color = if (enabled) MenuTextColor else MaterialTheme.colors.secondary,
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
)
@@ -54,8 +54,9 @@ fun ContactPreviewView(
if (cInfo.contact.verified) {
VerifiedIcon()
}
Text(
NameWithBadge(
cInfo.chatViewName,
cInfo.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor
@@ -63,8 +64,9 @@ fun ContactPreviewView(
}
is ChatInfo.ContactRequest ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
NameWithBadge(
cInfo.chatViewName,
cInfo.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
@@ -15,10 +16,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.LocalBadge
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR
@@ -272,6 +275,7 @@ class AlertManager {
profileName: String,
profileFullName: String,
profileImage: @Composable () -> Unit,
profileBadge: LocalBadge? = null,
subtitle: String? = null,
information: String? = null,
confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat),
@@ -299,8 +303,17 @@ class AlertManager {
) {
profileImage()
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
val nameFontSize = MaterialTheme.typography.h4.fontSize
Text(
profileName,
buildAnnotatedString {
append(profileName)
if (profileBadge != null) {
append(" ")
appendInlineContent(id = "nameBadge")
}
},
inlineContent =
if (profileBadge != null) mapOf("nameBadge" to nameBadgeInline(profileBadge, nameFontSize)) else emptyMap(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h4,
lineHeight = 20.sp,
@@ -3,10 +3,14 @@ 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.text.InlineTextContent
import androidx.compose.foundation.shape.*
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -14,15 +18,28 @@ 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.platform.LocalDensity
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.model.localDate
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import kotlin.math.max
import kotlin.math.roundToInt
@Composable
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) {
@@ -103,6 +120,137 @@ fun ProfileImage(
}
}
// badge height in em: calibrated visually so the badge top matches capital letters and digits
// (Inter's declared cap height is 2048/2816 = 0.727em, but the rendered text is taller than the metrics predict)
private const val fontCapHeightRatio = 0.95f
// fraction of the badge height pushed below the text baseline (like the undershoot of round letters)
private const val badgeBaselineOffsetRatio = 0.05f
// the badge glyph's width / height (the SVGs are cropped to the glyph: 300 x 399)
private const val badgeAspectRatio = 300f / 399f
// A contact/member name with the badge right after it: the badge is baseline-aligned with the name
// and sized to its font (fontSize if given, otherwise style.fontSize), and a truncated name keeps it visible.
@Composable
fun NameWithBadge(
name: String,
badge: LocalBadge?,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current
) {
Row(modifier) {
Text(
name,
Modifier.alignByBaseline().weight(1f, fill = false),
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
overflow = overflow,
maxLines = maxLines,
style = style
)
NameBadge(badge, if (fontSize.isSpecified) fontSize else style.fontSize)
}
}
// Badge next to the contact name in a Row: top aligned with capital letters, bottom just below the text baseline.
// Use NameWithBadge unless the row needs special arrangement; then the name Text must use Modifier.alignByBaseline().
@Composable
fun RowScope.NameBadge(badge: LocalBadge?, fontSize: TextUnit = LocalTextStyle.current.fontSize) {
// a badge that expired over a month ago (ExpiredOld) is not shown
if (badge == null || badge.status == BadgeStatus.ExpiredOld) return
val height = with(LocalDensity.current) { (if (fontSize.isSpecified) fontSize else 14.sp).toDp() } * fontCapHeightRatio
BadgeGlyph(
badge,
// the alignment line sits badgeBaselineOffsetRatio above the badge's bottom edge,
// so the Row places the badge that much below the text baseline;
// 6.dp matches the visible gap between the name and the verification shield:
// the shield has 3.dp end padding plus ~17% internal glyph margin, the badge artwork has none
Modifier.alignBy { (it.measuredHeight * (1 - badgeBaselineOffsetRatio)).roundToInt() }.padding(start = 6.dp).height(height).aspectRatio(badgeAspectRatio)
)
}
// badge inside a Text via appendInlineContent(id): bottom on the baseline, cap-height tall.
// precede with append(" ") for the space between the name and the badge.
fun nameBadgeInline(badge: LocalBadge, fontSize: TextUnit, onBadgeClick: (() -> Unit)? = null): InlineTextContent {
val height = fontSize * fontCapHeightRatio
return InlineTextContent(
Placeholder(height * badgeAspectRatio, height, PlaceholderVerticalAlign.AboveBaseline)
) {
// the placeholder bottom sits on the baseline and can't extend below it,
// so the badge is drawn shifted down by badgeBaselineOffsetRatio instead
BadgeGlyph(badge, Modifier.fillMaxSize().graphicsLayer { translationY = size.height * badgeBaselineOffsetRatio }, onBadgeClick)
}
}
@Composable
private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() -> Unit)? = null) {
val mod = modifier.let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it }
if (badge.status == BadgeStatus.Failed || badge.status == BadgeStatus.UnknownKey) {
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(name: String, badge: LocalBadge, uriHandler: UriHandler) {
// a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title
val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() }
when {
badge.status == BadgeStatus.Failed ->
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badge_unverified_title),
text = generalGetString(MR.strings.badge_unverified_desc)
)
badge.status == BadgeStatus.UnknownKey ->
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badge_unknown_key_title),
text = generalGetString(MR.strings.badge_unknown_key_desc)
)
badge.badge.badgeType is BadgeType.Investor ->
AlertManager.shared.showAlertDialog(
title = title,
text = String.format(generalGetString(MR.strings.badge_invested), name),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.learn_more),
onDismiss = { uriHandler.openUriCatching("https://simplex.chat/crowdfunding") }
)
else -> {
// Supporter, Legend and unknown types use the supporter wording
val expiry = badge.badge.badgeExpiry
val supports =
if (badge.status == BadgeStatus.Expired && expiry != null)
String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(expiry))
else
String.format(generalGetString(MR.strings.badge_supports_simplex), name)
AlertManager.shared.showAlertMsg(
title = title,
text = supports + "\n\n" + generalGetString(MR.strings.badge_support_from_v7)
)
}
}
}
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(
@@ -126,6 +126,10 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge
const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB
const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB
const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE
expect fun getAppFileUri(fileName: String): URI
@@ -383,7 +387,7 @@ fun uniqueCombine(fileName: String, dir: File): String {
val ext = orig.extension
fun tryCombine(n: Int): String {
val suffix = if (n == 0) "" else "_$n"
val f = "$name$suffix.$ext"
val f = if (ext.isEmpty()) "$name$suffix" else "$name$suffix.$ext"
return if (File(dir, f).exists()) tryCombine(n + 1) else f
}
return tryCombine(0)
@@ -442,14 +446,25 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
return fileCount to bytes
}
fun getMaxFileSize(fileProtocol: FileProtocol): Long {
return when (fileProtocol) {
FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL
fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) {
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL
// a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB
FileProtocol.XFTP -> {
val badge = senderProfile?.localBadge
if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP
else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND
else MAX_FILE_SIZE_XFTP_SUPPORTER
}
}
// the profile of whoever sent a received chat item - the group member, or the direct chat's contact
fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) {
is CIDirection.GroupRcv -> dir.groupMember.memberProfile
is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile
else -> null
}
expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration
fun showWrongUriAlert() {
@@ -474,6 +474,8 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close:
icon = contact.chatIconName
)
},
// the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too
profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null,
confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat),
onConfirm = {
openKnownContact(chatModel, rhId, close, contact)
@@ -633,6 +635,7 @@ fun showPrepareContactAlert(
else MR.images.ic_account_circle_filled
)
},
profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge,
information = ownerVerificationMessage(ownerVerification),
confirmText = generalGetString(MR.strings.connect_plan_open_new_chat),
onConfirm = {
@@ -230,7 +230,8 @@ private fun ProfilePickerOption(
disabled: Boolean,
onSelected: () -> Unit,
image: @Composable () -> Unit,
onInfo: (() -> Unit)? = null
onInfo: (() -> Unit)? = null,
badge: LocalBadge? = null
) {
Row(
Modifier
@@ -243,7 +244,7 @@ private fun ProfilePickerOption(
) {
image()
TextIconSpaced(false)
Text(title, modifier = Modifier.align(Alignment.CenterVertically))
NameWithBadge(title, badge, Modifier.align(Alignment.CenterVertically))
if (onInfo != null) {
Spacer(Modifier.padding(6.dp))
Column(Modifier
@@ -365,7 +366,8 @@ fun ActiveProfilePicker(
}
}
},
image = { ProfileImage(size = 42.dp, image = user.image) }
image = { ProfileImage(size = 42.dp, image = user.image) },
badge = user.profile.localBadge
)
}
@@ -309,12 +309,13 @@ fun AppVersionItem(showVersion: () -> Unit) {
Text(appVersionInfo.first + (if (appVersionInfo.second != null) " (" + appVersionInfo.second + ")" else ""))
}
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) {
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false, badge: LocalBadge? = null) {
ProfileImage(size = size, image = profileOf.image, color = iconColor)
Spacer(Modifier.padding(horizontal = 8.dp))
Column(Modifier.height(size), verticalArrangement = Arrangement.Center) {
Text(
NameWithBadge(
profileOf.displayName,
badge,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (stopped) MaterialTheme.colors.secondary else textColor,
@@ -3105,4 +3105,12 @@
<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_supports_simplex">%s supports SimpleX Chat.</string>
<string name="badge_supported_simplex">%1$s supported SimpleX Chat. The badge expired on %2$s.</string>
<string name="badge_support_from_v7">You can support SimpleX starting from v7 of the app.</string>
<string name="badge_invested">%s invested in SimpleX Chat crowdfunding.</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>
<string name="badge_unknown_key_title">Badge cannot be verified</string>
<string name="badge_unknown_key_desc">The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.</string>
</resources>
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffee9b"/>
<stop offset="5%" stop-color="#ffee9b"/>
<stop offset="50%" stop-color="#ffc233"/>
<stop offset="100%" stop-color="#e67700"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#26dee8"/>
<stop offset="50%" stop-color="#3064ea"/>
<stop offset="100%" stop-color="#001064"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#29f5ff"/>
<stop offset="95%" stop-color="#527eed"/>
<stop offset="100%" stop-color="#3669e9"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -67,15 +66,17 @@ actual fun UserPickerUsersSection(
}
}
Text(
user.displayName,
fontSize = 12.sp,
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(65.dp),
textAlign = TextAlign.Center
)
Row(Modifier.width(65.dp), horizontalArrangement = Arrangement.Center) {
Text(
user.displayName,
fontSize = 12.sp,
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alignByBaseline().weight(1f, fill = false)
)
NameBadge(user.profile.localBadge, 12.sp)
}
}
}
}