mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-15 03:46:23 +00:00
ios: use core markdown parser, also make messages in android selectable (#372)
* ios: use core markdown parser, also make messages in android selectable * remove bold font from members in previews * markdown help * text selection
This commit is contained in:
committed by
GitHub
parent
1aa2643c18
commit
1cf3b776d7
@@ -8,7 +8,7 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import kotlinx.datetime.*
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -601,7 +601,7 @@ sealed class Format {
|
||||
is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
|
||||
is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
|
||||
is Secret -> SpanStyle(color = HighOrLowlight, background = HighOrLowlight)
|
||||
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
|
||||
is Colored -> SpanStyle(color = this.color.uiColor)
|
||||
is Uri -> linkStyle
|
||||
is Email -> linkStyle
|
||||
|
||||
@@ -9,6 +9,7 @@ val Teal200 = Color(0xFF03DAC5)
|
||||
val Gray = Color(0x22222222)
|
||||
val SimplexBlue = Color(0, 136, 255, 255)
|
||||
val SimplexGreen = Color(98, 196, 103, 255)
|
||||
val SecretColor = Color(0x40808080)
|
||||
val LightGray = Color(241, 242, 246, 255)
|
||||
val DarkGray = Color(43, 44, 46, 255)
|
||||
val HighOrLowlight = Color(134, 135, 139, 255)
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.chat.item
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -71,7 +72,9 @@ fun MarkdownText (
|
||||
append(chatItem.content.text)
|
||||
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
|
||||
}
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
SelectionContainer {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
}
|
||||
} else {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
appendGroupMember(this, chatItem, groupMemberBold)
|
||||
@@ -91,14 +94,18 @@ fun MarkdownText (
|
||||
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
|
||||
}
|
||||
if (uriHandler != null) {
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
}
|
||||
)
|
||||
SelectionContainer {
|
||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||
onClick = { offset ->
|
||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
SelectionContainer {
|
||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,15 +676,28 @@ enum Format: Decodable {
|
||||
case phone
|
||||
}
|
||||
|
||||
enum FormatColor: Decodable {
|
||||
case red
|
||||
case green
|
||||
case blue
|
||||
case yellow
|
||||
case cyan
|
||||
case magenta
|
||||
case black
|
||||
case white
|
||||
enum FormatColor: String, Decodable {
|
||||
case red = "red"
|
||||
case green = "green"
|
||||
case blue = "blue"
|
||||
case yellow = "yellow"
|
||||
case cyan = "cyan"
|
||||
case magenta = "magenta"
|
||||
case black = "black"
|
||||
case white = "white"
|
||||
|
||||
// TODO custom decoding, it won't parse as is
|
||||
var uiColor: Color {
|
||||
get {
|
||||
switch (self) {
|
||||
case .red: return .red
|
||||
case .green: return .green
|
||||
case .blue: return .blue
|
||||
case .yellow: return .yellow
|
||||
case .cyan: return .cyan
|
||||
case .magenta: return .purple
|
||||
case .black: return .primary
|
||||
case .white: return .primary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,10 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private let emailRegex = try! NSRegularExpression(pattern: "^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$", options: .caseInsensitive)
|
||||
|
||||
private let phoneRegex = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
|
||||
|
||||
private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
|
||||
private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
|
||||
private let linkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
private let linkColor = Color(uiColor: uiLinkColor)
|
||||
|
||||
struct TextItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@@ -58,73 +55,12 @@ struct TextItemView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func messageText(_ chatItem: ChatItem) -> Text {
|
||||
let s = chatItem.content.text
|
||||
var res: Text
|
||||
if s == "" {
|
||||
res = Text("")
|
||||
} else {
|
||||
let parts = s.split(separator: " ")
|
||||
res = wordToText(parts[0])
|
||||
var i = 1
|
||||
while i < parts.count {
|
||||
res = res + Text(" ") + wordToText(parts[i])
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
if case let .groupRcv(groupMember) = chatItem.chatDir {
|
||||
let member = Text(groupMember.memberProfile.displayName).font(.headline)
|
||||
return member + Text(": ") + res
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ meta: String) -> Text {
|
||||
Text(" \(meta)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.clear)
|
||||
}
|
||||
|
||||
private func wordToText(_ s: String.SubSequence) -> Text {
|
||||
let str = String(s)
|
||||
switch true {
|
||||
case s.starts(with: "http://") || s.starts(with: "https://"):
|
||||
return linkText(str, prefix: "")
|
||||
case match(str, emailRegex):
|
||||
return linkText(str, prefix: "mailto:")
|
||||
case match(str, phoneRegex):
|
||||
return linkText(str, prefix: "tel:")
|
||||
default:
|
||||
if (s.count > 1) {
|
||||
switch true {
|
||||
case s.first == "*" && s.last == "*": return mdText(s).bold()
|
||||
case s.first == "_" && s.last == "_": return mdText(s).italic()
|
||||
case s.first == "+" && s.last == "+": return mdText(s).underline()
|
||||
case s.first == "~" && s.last == "~": return mdText(s).strikethrough()
|
||||
default: return Text(s)
|
||||
}
|
||||
} else {
|
||||
return Text(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func match(_ s: String, _ regex: NSRegularExpression) -> Bool {
|
||||
regex.firstMatch(in: s, options: [], range: NSRange(location: 0, length: s.count)) != nil
|
||||
}
|
||||
|
||||
private func linkText(_ s: String, prefix: String) -> Text {
|
||||
Text(AttributedString(s, attributes: AttributeContainer([
|
||||
.link: NSURL(string: prefix + s) as Any,
|
||||
.foregroundColor: linkColor as Any
|
||||
]))).underline()
|
||||
}
|
||||
|
||||
private func mdText(_ s: String.SubSequence) -> Text {
|
||||
Text(s[s.index(s.startIndex, offsetBy: 1)..<s.index(s.endIndex, offsetBy: -1)])
|
||||
}
|
||||
|
||||
private func msgDeliveryError(_ err: String) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Message delivery error",
|
||||
@@ -133,6 +69,57 @@ struct TextItemView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func messageText(_ chatItem: ChatItem, preview: Bool = false) -> Text {
|
||||
let s = chatItem.content.text
|
||||
var res: Text
|
||||
if let ft = chatItem.formattedText, ft.count > 0 {
|
||||
res = formattedText(ft[0], preview)
|
||||
var i = 1
|
||||
while i < ft.count {
|
||||
res = res + formattedText(ft[i], preview)
|
||||
i = i + 1
|
||||
}
|
||||
} else {
|
||||
res = Text(s)
|
||||
}
|
||||
|
||||
if case let .groupRcv(groupMember) = chatItem.chatDir {
|
||||
let m = Text(groupMember.memberProfile.displayName)
|
||||
return (preview ? m : m.font(.headline)) + Text(": ") + res
|
||||
} else {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedText(_ ft: FormattedText, _ preview: Bool) -> Text {
|
||||
let t = ft.text
|
||||
if let f = ft.format {
|
||||
switch (f) {
|
||||
case .bold: return Text(t).bold()
|
||||
case .italic: return Text(t).italic()
|
||||
case .strikeThrough: return Text(t).strikethrough()
|
||||
case .snippet: return Text(t).font(.body.monospaced())
|
||||
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
|
||||
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
|
||||
case .uri: return linkText(t, t, preview, prefix: "")
|
||||
case .email: return linkText(t, t, preview, prefix: "mailto:")
|
||||
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
|
||||
}
|
||||
} else {
|
||||
return Text(t)
|
||||
}
|
||||
}
|
||||
|
||||
private func linkText(_ s: String, _ link: String,
|
||||
_ preview: Bool, prefix: String) -> Text {
|
||||
preview
|
||||
? Text(s).foregroundColor(linkColor).underline(color: linkColor)
|
||||
: Text(AttributedString(s, attributes: AttributeContainer([
|
||||
.link: NSURL(string: prefix + link) as Any,
|
||||
.foregroundColor: uiLinkColor as Any
|
||||
]))).underline()
|
||||
}
|
||||
|
||||
struct TextItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group{
|
||||
|
||||
@@ -51,7 +51,7 @@ struct ChatPreviewView: View {
|
||||
|
||||
if let cItem = cItem {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
(itemStatusMark(cItem) + Text(chatItemText(cItem)))
|
||||
(itemStatusMark(cItem) + messageText(cItem, preview: true))
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 36)
|
||||
@@ -91,14 +91,6 @@ struct ChatPreviewView: View {
|
||||
default: return Text("")
|
||||
}
|
||||
}
|
||||
|
||||
private func chatItemText(_ cItem: ChatItem) -> String {
|
||||
let t = cItem.content.text
|
||||
if case let .groupRcv(groupMember) = cItem.chatDir {
|
||||
return groupMember.memberProfile.displayName + ": " + t
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatPreviewView_Previews: PreviewProvider {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// MarkdownHelp.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by Evgeny on 24/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MarkdownHelp: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("You can use markdown to format messages:")
|
||||
.padding(.bottom)
|
||||
mdFormat("*bold*", Text("bold text").bold())
|
||||
mdFormat("_italic_", Text("italic text").italic())
|
||||
mdFormat("~strike~", Text("strikethrough text").strikethrough())
|
||||
mdFormat("`code`", Text("`a = b + c`").font(.body.monospaced()))
|
||||
mdFormat("!1 colored!", Text("red text").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")"))
|
||||
(
|
||||
mdFormat("#secret#", Text("secret text")
|
||||
.foregroundColor(.clear)
|
||||
.underline(color: .primary) + Text(" (can be copied)"))
|
||||
)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func mdFormat(_ format: String, _ example: Text) -> some View {
|
||||
HStack {
|
||||
Text(format).frame(width: 88, alignment: .leading)
|
||||
example
|
||||
}
|
||||
}
|
||||
|
||||
private func color(_ s: String, _ c: Color) -> Text {
|
||||
Text(s).foregroundColor(c) + Text(", ")
|
||||
}
|
||||
|
||||
struct MarkdownHelp_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MarkdownHelp()
|
||||
}
|
||||
}
|
||||
@@ -50,14 +50,9 @@ struct SettingsView: View {
|
||||
|
||||
Section("Help") {
|
||||
NavigationLink {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Welcome \(user.displayName)!")
|
||||
.font(.largeTitle)
|
||||
.padding(.leading)
|
||||
Divider()
|
||||
ChatHelp(showSettings: $showSettings)
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
ChatHelp(showSettings: $showSettings)
|
||||
.navigationTitle("Welcome \(user.displayName)!")
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "questionmark.circle")
|
||||
@@ -65,6 +60,17 @@ struct SettingsView: View {
|
||||
Text("How to use SimpleX Chat")
|
||||
}
|
||||
}
|
||||
NavigationLink {
|
||||
MarkdownHelp()
|
||||
.navigationTitle("How to use markdown")
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "textformat")
|
||||
.padding(.trailing, 4)
|
||||
Text("Markdown in messages")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "number")
|
||||
.padding(.trailing, 8)
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
|
||||
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
|
||||
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
|
||||
5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
|
||||
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
|
||||
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; };
|
||||
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
|
||||
@@ -137,6 +139,7 @@
|
||||
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = "<group>"; };
|
||||
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
|
||||
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
|
||||
5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = "<group>"; };
|
||||
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
|
||||
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
|
||||
@@ -381,6 +384,7 @@
|
||||
5CB924D627A8563F00ACCCDD /* SettingsView.swift */,
|
||||
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
|
||||
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
|
||||
);
|
||||
path = UserSettings;
|
||||
sourceTree = "<group>";
|
||||
@@ -600,6 +604,7 @@
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
|
||||
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
|
||||
5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */,
|
||||
5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
|
||||
@@ -643,6 +648,7 @@
|
||||
5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
|
||||
5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */,
|
||||
5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */,
|
||||
5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */,
|
||||
5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
|
||||
|
||||
Reference in New Issue
Block a user