From ac5a2ee39987bd023015ba5c00264977cf4fe8b4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 2 Aug 2025 12:18:19 +0100 Subject: [PATCH] ui: use markdown when showing profile bio and group purpose (#6126) * ios: use markdown when showing profile bio and group purpose * android, desktop: use markdown when showing profile bio and group purpose --- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 6 ++- .../Views/Chat/ChatItem/MsgContentView.swift | 50 ++++++++++++++++--- .../Shared/Views/Chat/ChatItemInfoView.swift | 10 ++-- apps/ios/Shared/Views/Chat/ChatView.swift | 7 +-- .../Views/Chat/Group/GroupChatInfoView.swift | 6 ++- .../Views/Chat/Group/GroupWelcomeView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 2 - .../simplex/common/views/chat/ChatInfoView.kt | 15 +++--- .../simplex/common/views/chat/ChatView.kt | 15 +++--- .../simplex/common/views/chat/ComposeView.kt | 1 + 11 files changed, 80 insertions(+), 36 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 42a14c2395..77c1db341a 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -111,6 +111,7 @@ struct ChatInfoView: View { @State private var sendReceiptsUserDefault = true @State private var progressIndicator = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @State private var showSecrets: Set = [] enum ChatInfoViewAlert: Identifiable { case clearChatAlert @@ -397,10 +398,11 @@ struct ChatInfoView: View { .padding(.bottom, 2) } if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" { - Text(descr) - .font(.subheadline) + let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true) .multilineTextAlignment(.center) .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .center) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index e04584dfff..3212f3f512 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -120,13 +120,14 @@ struct MsgContentView: View { } } -func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding>? = nil) -> some View { +func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding>? = nil, centered: Bool = false, smallFont: Bool = false) -> some View { t.if(r.hasSecrets, transform: hiddenSecretsView) - .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) } + .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets, centered: centered, smallFont: smallFont)) } } +// smallFont parameter is used to pad height, otherwise CTFrameGetLines fails to see them as lines - it's needed if font is not .body @inline(__always) -private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding>? = nil) -> some View { +private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding>? = nil, centered: Bool, smallFont: Bool) -> some View { return GeometryReader { g in Rectangle() .fill(Color.clear) @@ -135,17 +136,29 @@ private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding 100 { return } let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString) - let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil) + let paddedSize = smallFont ? CGSize(width: g.size.width, height: g.size.height + 1.0) : g.size + let path = CGPath(rect: CGRect(origin: .zero, size: paddedSize), transform: nil) let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, s.length), path, nil) let point = CGPoint(x: event.location.x, y: g.size.height - event.location.y) // Flip y for UIKit var index: CFIndex? if let lines = CTFrameGetLines(frame) as? [CTLine] { var origins = [CGPoint](repeating: .zero, count: lines.count) CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) + var maxWidth: CGFloat = 0 + if centered { + for line in lines { + let bounds = CTLineGetBoundsWithOptions(line, .useOpticalBounds) + if bounds.width > maxWidth { + maxWidth = bounds.width + } + } + } for i in 0 ..< lines.count { let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds) - if bounds.offsetBy(dx: origins[i].x, dy: origins[i].y).contains(point) { - index = CTLineGetStringIndexForPosition(lines[i], point) + let offsetX = centered ? (maxWidth - bounds.width) / 2 : 0 + if bounds.offsetBy(dx: origins[i].x + offsetX, dy: origins[i].y).contains(point) { + let relativePoint = centered ? CGPoint(x: point.x - origins[i].x - offsetX, y: point.y - origins[i].y) : point + index = CTLineGetStringIndexForPosition(lines[i], relativePoint) break } } @@ -207,6 +220,31 @@ private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret") typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool) +@inline(__always) +func markdownText( + _ s: String, + textStyle: UIFont.TextStyle = .body, + sender: String? = nil, + preview: Bool = false, + mentions: [String: CIMention]? = nil, + userMemberId: String? = nil, + showSecrets: Set? = nil, + backgroundColor: Color +) -> MsgTextResult { + messageText( + s, + parseSimpleXMarkdown(s), + textStyle: textStyle, + sender: sender, + preview: preview, + mentions: mentions, + userMemberId: userMemberId, + showSecrets: showSecrets, + backgroundColor: UIColor(backgroundColor) + ) +} + + func messageText( _ text: String, _ formattedText: [FormattedText]?, diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index cd75d1b0cd..87c6ba92f8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -230,7 +230,7 @@ struct ChatItemInfoView: View { private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { let backgroundColor = chatItemFrameColor(ci, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: UIColor(backgroundColor)) + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(backgroundColor) @@ -258,7 +258,7 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: UIColor) -> some View { + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View { if text != "" { TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) } else { @@ -275,11 +275,11 @@ struct ChatItemInfoView: View { var sender: String? = nil var mentions: [String: CIMention]? var userMemberId: String? - var backgroundColor: UIColor + var backgroundColor: Color @State private var showSecrets: Set = [] var body: some View { - let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: backgroundColor) + let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) } } @@ -305,7 +305,7 @@ struct ChatItemInfoView: View { private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { let backgroundColor = quotedMsgFrameColor(qi, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: UIColor(backgroundColor)) + textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(quotedMsgFrameColor(qi, theme)) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 3328f6c231..712a88114f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -403,7 +403,7 @@ struct ChatView: View { private func connectInProgressView(_ s: String) -> some View { VStack(spacing: 0) { Divider() - + HStack(spacing: 12) { ProgressView() Text(s) @@ -823,6 +823,7 @@ struct ChatView: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness @Binding @ObservedObject var chat: Chat + @State private var showSecrets: Set = [] var body: some View { let v = VStack(spacing: 8) { @@ -846,8 +847,8 @@ struct ChatView: View { } if let shortDescr = chat.chatInfo.shortDescr { - Text(shortDescr) - .font(.subheadline) + let r = markdownText(shortDescr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true) .multilineTextAlignment(.center) .lineLimit(4) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 376e83c2d8..872e65c7a3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -34,6 +34,7 @@ struct GroupChatInfoView: View { @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var searchText: String = "" @FocusState private var searchFocussed + @State private var showSecrets: Set = [] enum GroupChatInfoViewAlert: Identifiable { case deleteGroupAlert @@ -253,10 +254,11 @@ struct GroupChatInfoView: View { .padding(.bottom, 2) } if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" { - Text(descr) - .font(.subheadline) + let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true) .multilineTextAlignment(.center) .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, alignment: .center) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index 5e7b8b9329..f58f2c213d 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -59,7 +59,7 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - let r = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: showSecrets, backgroundColor: UIColor(theme.colors.background)) + let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background) return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 1e2fda365f..79f72e539a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -274,7 +274,7 @@ struct ChatPreviewView: View { private func messageDraft(_ draft: ComposeState) -> (Text, Bool) { let msg = draft.message - let r = messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(theme.colors.background)) + let r = markdownText(msg, preview: true, mentions: draft.mentions, backgroundColor: theme.colors.background) return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) + attachment() + Text(AttributedString(r.string)), 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 8dce803f27..365f85cfbc 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 @@ -4320,7 +4320,6 @@ sealed class MsgChatLink { @Serializable class FormattedText(val text: String, val format: Format? = null) { - // TODO make it dependent on simplexLinkMode preference fun link(mode: SimplexLinkMode): String? = when (format) { is Format.Uri -> if (text.startsWith("http://", ignoreCase = true) || text.startsWith("https://", ignoreCase = true)) text else "https://$text" is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri @@ -4329,7 +4328,6 @@ class FormattedText(val text: String, val format: Format? = null) { else -> null } - // TODO make it dependent on simplexLinkMode preference fun viewText(mode: SimplexLinkMode): String = if (format is Format.SimplexLink && mode == SimplexLinkMode.DESCRIPTION) simplexLinkText(format.linkType, format.smpHosts) else text 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 05d9e6564c..c7b8cb2c81 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 @@ -41,6 +41,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.group.ChatTTLOption +import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @@ -759,16 +760,16 @@ fun ChatInfoDescription(c: NamedChat, displayName: String, copyNameToClipboard: } val descr = c.shortDescr?.trim() if (descr != null && descr != "") { - val copyDescr = { copyNameToClipboard(descr) } - Text( + MarkdownText( descr, - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onBackground, - textAlign = TextAlign.Center, + parseToMarkdown(descr), + toggleSecrets = true, + style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center), maxLines = 4, overflow = TextOverflow.Ellipsis, - lineHeight = 21.sp, - modifier = Modifier.padding(top = DEFAULT_PADDING_HALF).combinedClickable(onClick = copyDescr, onLongClick = copyDescr).onRightClick(copyDescr) + uriHandler = LocalUriHandler.current, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF), + linkMode = chatModel.simplexLinkMode.value ) } } 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 7f38874f92..d19c19b83a 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 @@ -1853,16 +1853,16 @@ fun BoxScope.ChatItemsList( val descr = chatInfo.shortDescr?.trim() if (descr != null && descr != "") { - Text( + MarkdownText( descr, - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onBackground, - textAlign = TextAlign.Center, + parseToMarkdown(descr), + toggleSecrets = true, + style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center), maxLines = 4, overflow = TextOverflow.Ellipsis, - lineHeight = 21.sp, - modifier = Modifier - .padding(top = DEFAULT_PADDING_HALF) + uriHandler = LocalUriHandler.current, + modifier = Modifier.padding(top = DEFAULT_PADDING_HALF), + linkMode = linkMode ) } @@ -1871,6 +1871,7 @@ fun BoxScope.ChatItemsList( Text( contextStr, style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary, modifier = Modifier.padding(top = DEFAULT_PADDING) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 379814c0ef..ce55c62ae2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1248,6 +1248,7 @@ fun ComposeView( SimpleButtonIconEnded( text = stringResource(MR.strings.compose_view_connect), icon = painterResource(icon), + style = MaterialTheme.typography.body2, color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, disabled = composeState.value.inProgress, click = { withApi { sendRequest() } }