From 35975dcd518fe7f889a24d9b6698e38fc4158eb8 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:59:44 +0000 Subject: [PATCH] kotlin ui --- .../chat/simplex/common/model/ChatModel.kt | 21 +++++- .../chat/simplex/common/views/WelcomeView.kt | 4 +- .../simplex/common/views/chat/ChatInfoView.kt | 72 ++++++++++++++++--- .../simplex/common/views/chat/ChatView.kt | 20 ++---- .../common/views/chat/item/TextItemView.kt | 21 +++++- .../views/usersettings/UserProfileView.kt | 51 +++++++++++-- .../commonMain/resources/MR/base/strings.xml | 4 ++ 7 files changed, 155 insertions(+), 38 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ca436ce2f5..6aca3007cd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1294,6 +1294,7 @@ data class User( override val displayName: String get() = profile.displayName override val fullName: String get() = profile.fullName override val shortDescr: String? get() = profile.shortDescr + override val profileDescription: String? get() = profile.description override val image: String? get() = profile.image override val localAlias: String = "" @@ -1366,6 +1367,9 @@ interface NamedChat { val displayName: String val fullName: String val shortDescr: String? + // the full profile description (contacts/business/bot addresses); null for chats that have none. + // named distinctly from shortDescr and from PendingContactConnection.description (a connection-status string). + val profileDescription: String? get() = null val image: String? val localAlias: String val chatViewName: String @@ -1491,6 +1495,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val displayName get() = contact.displayName override val fullName get() = contact.fullName override val shortDescr get() = contact.profile.shortDescr + override val profileDescription get() = contact.profile.description override val image get() = contact.image override val localAlias: String get() = contact.localAlias override fun anyNameContains(searchAnyCase: String): Boolean = contact.anyNameContains(searchAnyCase) @@ -1573,6 +1578,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val displayName get() = contactRequest.displayName override val fullName get() = contactRequest.fullName override val shortDescr get() = contactRequest.profile.shortDescr + override val profileDescription get() = contactRequest.profile.description override val image get() = contactRequest.image override val localAlias get() = contactRequest.localAlias @@ -1863,6 +1869,7 @@ data class Contact( override val displayName get() = localAlias.ifEmpty { profile.displayName } override val fullName get() = profile.fullName override val shortDescr get() = profile.shortDescr + override val profileDescription get() = profile.description override val image get() = profile.image val contactLink: String? = profile.contactLink override val localAlias get() = profile.localAlias @@ -2032,6 +2039,7 @@ data class Profile( override val displayName: String, override val fullName: String, override val shortDescr: String?, + val description: String? = null, override val image: String? = null, override val localAlias : String = "", val contactLink: String? = null, @@ -2042,12 +2050,14 @@ data class Profile( val badge: BadgeProof? = null, val contactDomain: SimplexDomainClaim? = null ): NamedChat { + override val profileDescription: String? get() = description + val profileViewName: String get() { return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } - fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain) + fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, description, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain) companion object { val sampleData = Profile( @@ -2064,6 +2074,7 @@ data class LocalProfile( override val displayName: String, override val fullName: String, override val shortDescr: String?, + val description: String? = null, override val image: String? = null, override val localAlias: String, val contactLink: String? = null, @@ -2073,9 +2084,11 @@ data class LocalProfile( val contactDomain: SimplexDomainClaim? = null, val contactDomainVerified: Boolean? = null ): NamedChat { + override val profileDescription: String? get() = description + val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } - fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain) + fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, description, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain) companion object { val sampleData = LocalProfile( @@ -2553,6 +2566,7 @@ data class GroupMember ( } override val fullName: String get() = memberProfile.fullName override val shortDescr: String? get() = memberProfile.shortDescr + override val profileDescription: String? get() = memberProfile.description override val image: String? get() = memberProfile.image val contactLink: String? = memberProfile.contactLink val verified get() = activeConn?.connectionCode != null @@ -4858,6 +4872,8 @@ sealed class Format { @Serializable @SerialName("simplexName") class SimplexName(val nameInfo: SimplexNameInfo): Format() @Serializable @SerialName("command") class Command(val commandStr: String): Format() @Serializable @SerialName("mention") class Mention(val memberName: String): Format() + // app-only span (not sent over the wire): a tappable "modal" reference; the renderer resolves modalName to content in the current context + @Serializable @SerialName("modal") class Modal(val modalName: String): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() @Serializable @SerialName("unknown") class Unknown: Format() @@ -4878,6 +4894,7 @@ sealed class Format { is Mention -> SpanStyle(fontWeight = FontWeight.Medium) is Email -> linkStyle is Phone -> linkStyle + is Modal -> linkStyle is Unknown -> SpanStyle() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 49c2f95667..1400822604 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -370,7 +370,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, shortDesc withBGApi { val rhId = chatModel.remoteHostId() val user = chatModel.controller.apiCreateActiveUser( - rhId, Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image) + rhId, Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image) ) ?: return@withBGApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { @@ -389,7 +389,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, shortDesc fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) { withBGApi { chatModel.currentUser.value = chatModel.controller.apiCreateActiveUser( - null, Profile(displayName.trim(), "", null, null) + null, Profile(displayName.trim(), "", shortDescr = null) ) ?: return@withBGApi chatModel.localUserCreated.value = true // new users don't need the local file encryption indicator (all files are encrypted); existing users keep it on diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index a5cce3c258..5fe4d19ac1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -775,19 +775,69 @@ fun ChatInfoDescription(c: NamedChat, displayName: String, copyNameToClipboard: modifier = Modifier.padding(top = DEFAULT_PADDING_HALF).combinedClickable(onClick = copyFullName, onLongClick = copyFullName).onRightClick(copyFullName) ) } - val descr = c.shortDescr?.trim() - if (descr != null && descr != "") { + ProfileDescriptionText( + shortDescr = c.shortDescr, + description = c.profileDescription, + style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center), + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) +} + +// A compact profile description: shows shortDescr, or a truncated first line of the full description, +// with a tappable "Read more" that opens the full description in a modal. Used for contacts and, later, +// business/bot addresses. Groups keep showing only shortDescr (their welcome message is shown separately). +@Composable +fun ProfileDescriptionText(shortDescr: String?, description: String?, style: TextStyle, modifier: Modifier = Modifier) { + val short = shortDescr?.trim()?.ifEmpty { null } + val descr = description?.trim()?.ifEmpty { null } + val uriHandler = LocalUriHandler.current + val linkMode = chatModel.simplexLinkMode.value + if (descr == null) { + // no full description: render shortDescr as before (or nothing) + if (short != null) { + MarkdownText( + short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4, + overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode + ) + } + return + } + val firstLine = descr.lineSequence().first() + val truncated = firstLine.length > 100 + val multiline = descr.length > firstLine.length + // when there is no shortDescr and the whole description already fits on one short line, show it inline + if (short == null && !truncated && !multiline) { MarkdownText( - descr, - parseToMarkdown(descr), - toggleSecrets = true, - style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center), - maxLines = 4, - overflow = TextOverflow.Ellipsis, - uriHandler = LocalUriHandler.current, - modifier = Modifier.padding(top = DEFAULT_PADDING_HALF), - linkMode = chatModel.simplexLinkMode.value + descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4, + overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) + return + } + val teaser = short ?: (if (truncated) firstLine.take(100).trimEnd() + "…" else firstLine + "…") + val readMore = stringResource(MR.strings.whats_new_read_more) + val formatted = (parseToMarkdown(teaser) ?: FormattedText.plain(teaser)) + + FormattedText(" ") + FormattedText(readMore, Format.Modal("description")) + MarkdownText( + "$teaser $readMore", formatted, toggleSecrets = true, style = style, maxLines = 4, + overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode, + onModalClick = { showFullProfileDescription(descr) } + ) +} + +private fun showFullProfileDescription(description: String) { + ModalManager.end.showModalCloseable { _ -> + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.profile_description__field)) + MarkdownText( + description, + parseToMarkdown(description), + toggleSecrets = true, + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), + uriHandler = LocalUriHandler.current, + linkMode = chatModel.simplexLinkMode.value, + modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index c1603f2bdc..e762c5610d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -2328,20 +2328,12 @@ fun BoxScope.ChatItemsList( ) } - val descr = chatInfo.shortDescr?.trim() - if (descr != null && descr != "") { - MarkdownText( - descr, - parseToMarkdown(descr), - toggleSecrets = true, - style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center), - maxLines = 4, - overflow = TextOverflow.Ellipsis, - uriHandler = LocalUriHandler.current, - modifier = Modifier.padding(top = DEFAULT_PADDING_HALF), - linkMode = linkMode - ) - } + ProfileDescriptionText( + shortDescr = chatInfo.shortDescr, + description = chatInfo.profileDescription, + style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center), + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) + ) when (chatInfo) { is ChatInfo.Direct -> ContactSimplexNameView(chatInfo.contact, verifiable = false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index c7c96cd731..ed06a559fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -113,6 +113,7 @@ fun MarkdownText ( linkMode: SimplexLinkMode, inlineContent: Pair Unit, Map>? = null, onLinkLongClick: (link: String) -> Unit = {}, + onModalClick: ((modalName: String) -> Unit)? = null, showViaProxy: Boolean = false, showTimestamp: Boolean = true, prefix: AnnotatedString? = null, @@ -193,6 +194,7 @@ fun MarkdownText ( var hasLinks = false var hasSecrets = false var hasCommands = false + var hasModals = false val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) @@ -302,6 +304,17 @@ fun MarkdownText ( withStyle(ftStyle) { append(ft.text) } } } + is Format.Modal -> { + if (onModalClick == null) { + append(ft.text) + } else { + hasModals = true + val ftStyle = Format.linkStyle + withAnnotation(tag = "MODAL", annotation = ft.format.modalName) { + withStyle(ftStyle) { append(ft.text) } + } + } + } is Format.Unknown -> append(ft.text) } } @@ -315,7 +328,7 @@ fun MarkdownText ( else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } - if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { + if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || (hasModals && onModalClick != null)) { val icon = remember { mutableStateOf(PointerIcon.Text) } ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> @@ -353,11 +366,14 @@ fun MarkdownText ( if (hasCommands && sendCommandMsg != null) { withAnnotation("COMMAND") { a -> sendCommandMsg("/${a.item}") } } + if (hasModals && onModalClick != null) { + withAnnotation("MODAL") { a -> onModalClick(a.item) } + } }, onHover = { offset -> val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) } icon.value = - if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { + if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND") || hasAnnotation("MODAL")) { PointerIcon.Hand } else { PointerIcon.Text @@ -367,6 +383,7 @@ fun MarkdownText ( annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) + || annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset) }, onTextLayout = { onTextLayoutResult?.invoke(it) } ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index 45cdee6108..c63226f341 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -24,6 +24,7 @@ import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.platform.* import chat.simplex.common.views.* import chat.simplex.res.MR +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.net.URI @@ -40,9 +41,9 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { UserProfileLayout( profile = profile, close, - saveProfile = { displayName, fullName, shortDescr, image -> + saveProfile = { displayName, fullName, shortDescr, description, image -> withBGApi { - val updatedProfile = profile.copy(displayName = displayName.trim(), fullName = fullName.trim(), shortDescr = shortDescr.trim().ifEmpty { null }, image = image) + val updatedProfile = profile.copy(displayName = displayName.trim(), fullName = fullName.trim(), shortDescr = shortDescr.trim().ifEmpty { null }, description = description.trim().ifEmpty { null }, image = image) val updated = chatModel.controller.apiUpdateProfile(user.remoteHostId, updatedProfile) if (updated != null) { val (newProfile, _) = updated @@ -60,12 +61,13 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { fun UserProfileLayout( profile: Profile, close: () -> Unit, - saveProfile: (String, String, String, String?) -> Unit, + saveProfile: (String, String, String, String, String?) -> Unit, ) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val displayName = remember { mutableStateOf(profile.displayName) } val fullName = remember { mutableStateOf(profile.fullName) } val shortDescr = remember { mutableStateOf(profile.shortDescr ?: "") } + val description = remember { mutableStateOf(profile.description ?: "") } val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(profile.image) } val scope = rememberCoroutineScope() @@ -90,12 +92,13 @@ fun UserProfileLayout( displayName.value.trim() == profile.displayName && fullName.value.trim() == profile.fullName && shortDescr.value.trim() == (profile.shortDescr ?: "") && + description.value.trim() == (profile.description ?: "") && profile.image == profileImage.value val closeWithAlert = { if (dataUnchanged || !canSaveProfile(displayName.value, shortDescr.value, profile)) { close() } else { - showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, shortDescr.value, profileImage.value) }, close) + showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, shortDescr.value, description.value, profileImage.value) }, close) } } ModalView(close = closeWithAlert) { @@ -167,9 +170,20 @@ fun UserProfileLayout( } ProfileNameField(shortDescr) + Spacer(Modifier.height(DEFAULT_PADDING)) + // opens the description editor on top of the profile editor; the edited value is saved + // together with the rest of the profile by the "Save and notify contacts" button below. + Text( + stringResource(if (description.value.isBlank()) MR.strings.add_description else MR.strings.edit_description), + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable { + ModalManager.start.showModalCloseable { ProfileDescriptionEditor(description) } + } + ) + Spacer(Modifier.height(DEFAULT_PADDING)) val enabled = !dataUnchanged && canSaveProfile(displayName.value, shortDescr.value, profile) - val saveModifier: Modifier = Modifier.clickable(enabled) { saveProfile(displayName.value, fullName.value, shortDescr.value, profileImage.value) } + val saveModifier: Modifier = Modifier.clickable(enabled) { saveProfile(displayName.value, fullName.value, shortDescr.value, description.value, profileImage.value) } val saveColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Text( stringResource(MR.strings.save_and_notify_contacts), @@ -218,6 +232,29 @@ fun DeleteImageButton(click: () -> Unit) { } } +// Editor for the full profile description, opened on top of the profile editor. It edits the shared +// `description` state in place; there is no separate save here — the profile "Save and notify contacts" +// button persists it together with the rest of the profile. +@Composable +private fun ProfileDescriptionEditor(description: MutableState) { + val focusRequester = remember { FocusRequester() } + ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.profile_description__field), withPadding = false) + TextEditor( + description, + Modifier.heightIn(min = 100.dp), + placeholder = stringResource(MR.strings.enter_description_optional), + contentPadding = PaddingValues(), + focusRequester = focusRequester, + ) + SectionBottomSpacer() + } + LaunchedEffect(Unit) { + delay(200) + focusRequester.requestFocus() + } +} + private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.save_preferences_question), @@ -248,7 +285,7 @@ fun PreviewUserProfileLayoutEditOff() { UserProfileLayout( profile = Profile.sampleData, close = {}, - saveProfile = { _, _, _, _ -> } + saveProfile = { _, _, _, _, _ -> } ) } } @@ -264,7 +301,7 @@ fun PreviewUserProfileLayoutEditOn() { UserProfileLayout( profile = Profile.sampleData, close = {}, - saveProfile = { _, _, _, _ -> } + saveProfile = { _, _, _, _, _ -> } ) } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index f78be5967b..b047bdb2c1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1247,6 +1247,10 @@ Full name: Bio: Bio too large + Description + Add description + Edit description + Enter description (optional) Your current profile Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Edit image