diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index de38fc063c..37bb174d68 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 4e6b8fc805..f2f2c0b486 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -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) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index 5887593220..c64fb7f38b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -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) + } } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3049b583f0..f57f2fbbe1 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -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 + } + } + } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift index be3cfdaada..e8f9b5b551 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift @@ -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).. 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{ diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 0d7d2608ee..39f076b5b5 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift new file mode 100644 index 0000000000..d9a73372dd --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift @@ -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() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index e48dececc7..0dd85b7aae 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -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) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index b56a9f7ae0..8ab82ccd14 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; + 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; @@ -381,6 +384,7 @@ 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, + 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, ); path = UserSettings; sourceTree = ""; @@ -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 */,