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
This commit is contained in:
Evgeny
2025-08-02 12:18:19 +01:00
committed by GitHub
parent f981eed50a
commit ac5a2ee399
11 changed files with 80 additions and 36 deletions
@@ -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<Int> = []
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)
@@ -120,13 +120,14 @@ struct MsgContentView: View {
}
}
func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding<Set<Int>>? = nil) -> some View {
func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding<Set<Int>>? = 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<Set<Int>>? = nil) -> some View {
private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding<Set<Int>>? = 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<Set<In
let t = event.translation
if t.width * t.width + t.height * t.height > 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<Int>? = 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]?,
@@ -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<Int> = []
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))
+4 -3
View File
@@ -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<Int> = []
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)
@@ -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<Int> = []
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)
@@ -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)
@@ -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)),
@@ -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
@@ -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
)
}
}
@@ -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)
)
@@ -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() } }