From 4485d463070ddc3ce2aec9842cdb28d7d1c8dd9d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 24 Nov 2022 17:14:56 +0000 Subject: [PATCH] mobile: simplex links in UI, core: trusted uri for simplex links (#1410) --- .../java/chat/simplex/app/model/ChatModel.kt | 25 +++++++++++++++++++ .../java/chat/simplex/app/model/SimpleXAPI.kt | 23 +++++++++++++++++ .../app/views/chat/item/TextItemView.kt | 2 +- .../app/src/main/res/values-de/strings.xml | 6 +++++ .../app/src/main/res/values-ru/strings.xml | 6 +++++ .../app/src/main/res/values/strings.xml | 6 +++++ .../Views/Chat/ChatItem/MsgContentView.swift | 10 ++++++++ .../Views/UserSettings/PrivacySettings.swift | 21 +++++++++++++++- .../Views/UserSettings/SettingsView.swift | 22 ++++++++++++++++ apps/ios/SimpleXChat/ChatTypes.swift | 16 ++++++++++++ src/Simplex/Chat/Markdown.hs | 11 +++++--- tests/ChatTests.hs | 12 ++++++--- tests/MarkdownTests.hs | 12 +++++---- 13 files changed, 158 insertions(+), 14 deletions(-) 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 a27bc3e32..6dc126346 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 @@ -1491,12 +1491,21 @@ object MsgContentSerializer : KSerializer { @Serializable class FormattedText(val text: String, val format: Format? = null) { + // TODO make it dependent on simplexLinkMode preference val link: String? = when (format) { is Format.Uri -> text + is Format.SimplexLink -> format.simplexUri is Format.Email -> "mailto:$text" is Format.Phone -> "tel:$text" else -> null } + + // TODO make it dependent on simplexLinkMode preference + val viewText: String = + if (format is Format.SimplexLink) simplexLinkText(format.linkType, format.smpHosts) else text + + fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List): String = + "${linkType.description} (${String.format(generalGetString(R.string.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})" } @Serializable @@ -1508,6 +1517,8 @@ sealed class Format { @Serializable @SerialName("secret") class Secret: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() + // TODO trustedUri: Boolean + @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() @@ -1519,6 +1530,7 @@ sealed class Format { is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor) is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle + is SimplexLink -> linkStyle is Email -> linkStyle is Phone -> linkStyle } @@ -1528,6 +1540,19 @@ sealed class Format { } } +@Serializable +enum class SimplexLinkType(val linkType: String) { + contact("contact"), + invitation("invitation"), + group("group"); + + val description: String get() = generalGetString(when (this) { + contact -> R.string.simplex_link_contact + invitation -> R.string.simplex_link_invitation + group -> R.string.simplex_link_group + }) +} + @Serializable enum class FormatColor(val color: String) { red("red"), diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 3e88900ca..592b6dc27 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -60,6 +60,16 @@ enum class CallOnLockScreen { } } +enum class SimplexLinkMode { + DESCRIPTION, + FULL, + BROWSER; + + companion object { + val default = SimplexLinkMode.DESCRIPTION + } +} + class AppPreferences(val context: Context) { private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) @@ -91,6 +101,18 @@ class AppPreferences(val context: Context) { val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyTransferImagesInline = mkBoolPreference(SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE, false) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) + private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name) + val simplexLinkMode: Preference = Preference( + get = fun(): SimplexLinkMode { + val value = _simplexLinkMode.get() ?: return SimplexLinkMode.default + return try { + SimplexLinkMode.valueOf(value) + } catch (e: Error) { + SimplexLinkMode.default + } + }, + set = fun(mode: SimplexLinkMode) { _simplexLinkMode.set(mode.name) } + ) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null) val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) @@ -181,6 +203,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" + private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" 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 e4339d3d7..7566adcf1 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 @@ -84,7 +84,7 @@ fun MarkdownText ( hasLinks = true val ftStyle = ft.format.style withAnnotation(tag = "URL", annotation = link) { - withStyle(ftStyle) { append(ft.text) } + withStyle(ftStyle) { append(ft.viewText) } } } else { withStyle(ft.format.style) { append(ft.text) } diff --git a/apps/android/app/src/main/res/values-de/strings.xml b/apps/android/app/src/main/res/values-de/strings.xml index 64377cfc1..4fdcf391f 100644 --- a/apps/android/app/src/main/res/values-de/strings.xml +++ b/apps/android/app/src/main/res/values-de/strings.xml @@ -41,6 +41,12 @@ über einen Einmal-Link Inkognito über einen Einmal-Link + + SimpleX Kontaktadressen-Link + SimpleX Einmal-Link + SimpleX Gruppen-Link + über %1$s + Fehler beim Speichern der SMP-Server Stellen Sie sicher, dass die SMP-Server Adressen das richtige Format haben, zeilenweise angeordnet und nicht kopiert sind. diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 94611ed48..12e72d519 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -41,6 +41,12 @@ через одноразовую ссылку инкогнито через одноразовую ссылку + + SimpleX ссылка-контакт + SimpleX одноразовая ссылка + SimpleX ссылка группы + через %1$s + Ошибка при сохранении SMP серверов Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 7a2704a3c..0a4700a5d 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -41,6 +41,12 @@ via one-time link incognito via one-time link + + SimpleX contact address + SimpleX 1-time invitation + SimpleX group link + via %1$s + Error saving SMP servers Make sure SMP server addresses are in correct format, line separated and are not duplicated. diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 24961e562..50ff527df 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -70,6 +70,12 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text { 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 let .simplexLink(linkType, simplexUri, smpHosts): + switch privacySimplexLinkModeDefault.get() { + case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") + case .full: return linkText(t, simplexUri, preview, prefix: "") + case .browser: 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:") } @@ -88,6 +94,10 @@ private func linkText(_ s: String, _ link: String, ]))).underline() } +private func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { + linkType.description + " " + "(via \(smpHosts.first ?? "?"))" +} + struct MsgContentView_Previews: PreviewProvider { static var previews: some View { let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index d46adf0f0..65cb39711 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -14,6 +14,7 @@ struct PrivacySettings: View { @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE, store: groupDefaults) private var transferImagesInline = false + @State private var simplexLinkMode = privacySimplexLinkModeDefault.get() var body: some View { VStack { @@ -21,7 +22,8 @@ struct PrivacySettings: View { Section("Device") { SimplexLockSetting() } - Section("Chats") { + + Section { settingsRow("photo") { Toggle("Auto-accept images", isOn: $autoAcceptImages) .onChange(of: autoAcceptImages) { @@ -36,6 +38,23 @@ struct PrivacySettings: View { settingsRow("network") { Toggle("Send link previews", isOn: $useLinkPreviews) } + settingsRow("link") { + Picker("SimpleX links", selection: $simplexLinkMode) { + ForEach(SimpleXLinkMode.values) { mode in + Text(mode.text) + } + } + } + .frame(height: 36) + .onChange(of: simplexLinkMode) { mode in + privacySimplexLinkModeDefault.set(mode) + } + } header: { + Text("Chats") + } footer: { + if case .browser = simplexLinkMode { + Text("Opening the link in the browser may reduce connection privacy and security.") + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 238664379..91dabfbd1 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -23,6 +23,7 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" +let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls" let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName" let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime" @@ -43,6 +44,7 @@ let appDefaults: [String: Any] = [ DEFAULT_WEBRTC_POLICY_RELAY: true, DEFAULT_PRIVACY_ACCEPT_IMAGES: true, DEFAULT_PRIVACY_LINK_PREVIEWS: true, + DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: "description", DEFAULT_EXPERIMENTAL_CALLS: false, DEFAULT_CHAT_V3_DB_MIGRATION: "offer", DEFAULT_DEVELOPER_TOOLS: false, @@ -54,6 +56,24 @@ let appDefaults: [String: Any] = [ DEFAULT_CONNECT_VIA_LINK_TAB: "scan" ] +enum SimpleXLinkMode: String, Identifiable { + case description + case full + case browser + + static var values: [SimpleXLinkMode] = [.description, .full, .browser] + + public var id: Self { self } + + var text: LocalizedStringKey { + switch self { + case .description: return "Description" + case .full: return "Full link" + case .browser: return "Via browser" + } + } +} + private var indent: CGFloat = 36 let chatArchiveTimeDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CHAT_ARCHIVE_TIME) @@ -64,6 +84,8 @@ let encryptionStartedAtDefault = DateDefault(defaults: UserDefaults.standard, fo let connectViaLinkTabDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CONNECT_VIA_LINK_TAB, withDefault: .scan) +let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) + func setGroupDefaults() { privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a98e9a728..de52d7270 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1713,10 +1713,26 @@ public enum Format: Decodable, Equatable { case secret case colored(color: FormatColor) case uri + // TODO trustedUri: Bool + case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) case email case phone } +public enum SimplexLinkType: String, Decodable { + case contact + case invitation + case group + + public var description: String { + switch self { + case .contact: return NSLocalizedString("SimpleX contact address", comment: "simplex link type") + case .invitation: return NSLocalizedString("SimpleX 1-time invitation", comment: "simplex link type") + case .group: return NSLocalizedString("SimpleX group link", comment: "simplex link type") + } + } +} + public enum FormatColor: String, Decodable { case red = "red" case green = "green" diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index cfd665c97..b0beccb86 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -31,7 +31,7 @@ import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, sumTypeJSON) -import Simplex.Messaging.Protocol (ProtocolServer (..)) +import Simplex.Messaging.Protocol (ProtocolServer (..), SrvLoc (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email @@ -47,7 +47,7 @@ data Format | Secret | Colored {color :: FormatColor} | Uri - | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} + | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, trustedUri :: Bool, smpHosts :: NonEmpty Text} | Email | Phone deriving (Eq, Show, Generic) @@ -222,12 +222,15 @@ markdownP = mconcat <$> A.many' fragmentP simplexUriFormat = \case ACR _ (CRContactUri crData) -> let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex} - in SimplexLink (linkType' crData) uri $ uriHosts crData + in SimplexLink (linkType' crData) uri (trustedUri' crData) $ uriHosts crData ACR _ (CRInvitationUri crData e2e) -> let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e - in SimplexLink XLInvitation uri $ uriHosts crData + in SimplexLink XLInvitation uri (trustedUri' crData) $ uriHosts crData where uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues + trustedUri' ConnReqUriData {crScheme} = case crScheme of + CRSSimplex -> True + CRSAppServer (SrvLoc host _) -> host == "simplex.chat" linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of Just (CRDataGroup _) -> XLGroup Nothing -> XLContact diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 26db562f0..e8fe6ba58 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -27,7 +27,7 @@ import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Types import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util (unlessM) -import System.Directory (copyFile, doesDirectoryExist, doesFileExist) +import System.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist) import System.FilePath (()) import Test.Hspec @@ -2900,6 +2900,12 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ testSetContactPrefs :: IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> do + alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") + bob #$> ("/_files_folder ./tests/tmp/bob", id, "ok") + createDirectoryIfMissing True "./tests/tmp/alice" + createDirectoryIfMissing True "./tests/tmp/bob" + copyFile "./tests/fixtures/test.txt" "./tests/tmp/alice/test.txt" + copyFile "./tests/fixtures/test.txt" "./tests/tmp/bob/test.txt" bob ##> "/_profile {\"displayName\": \"bob\", \"fullName\": \"Bob\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" bob <## "profile image removed" bob <## "updated preferences:" @@ -2912,7 +2918,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ let startFeatures = [(0, "Full deletion: off"), (0, "Voice messages: off")] alice #$> ("/_get chat @2 count=100", chat, startFeatures) bob #$> ("/_get chat @2 count=100", chat, startFeatures) - let sendVoice = "/_send @2 json {\"filePath\": \"./tests/fixtures/test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}" + let sendVoice = "/_send @2 json {\"filePath\": \"test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}" voiceNotAllowed = "bad chat command: feature not allowed Voice messages" alice ##> sendVoice alice <## voiceNotAllowed @@ -2929,7 +2935,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ alice <## voiceNotAllowed bob ##> sendVoice bob <# "@alice voice message (00:10)" - bob <# "/f @alice ./tests/fixtures/test.txt" + bob <# "/f @alice test.txt" bob <## "completed sending file 1 (test.txt) to alice" alice <# "bob> voice message (00:10)" alice <# "bob> sends file test.txt (11 bytes / 11 bytes)" diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 10fa67a17..487fe651e 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -137,8 +137,8 @@ textColor = describe "text color (red)" do uri :: Text -> Markdown uri = Markdown $ Just Uri -simplexLink :: SimplexLinkType -> Text -> NonEmpty Text -> Text -> Markdown -simplexLink linkType simplexUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, smpHosts} +simplexLink :: SimplexLinkType -> Text -> Bool -> NonEmpty Text -> Text -> Markdown +simplexLink linkType simplexUri trustedUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, trustedUri, smpHosts} textWithUri :: Spec textWithUri = describe "text with Uri" do @@ -152,11 +152,13 @@ textWithUri = describe "text with Uri" do parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" - parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) + parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) True ["smp.simplex.im"] ("https://simplex.chat" <> inv) + parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) True ["smp.simplex.im"] ("simplex:" <> inv) + parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) False ["smp.simplex.im"] ("https://example.com" <> inv) let ct = "/contact#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" - parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) + parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) True ["smp.simplex.im"] ("https://simplex.chat" <> ct) let gr = "/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" - parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) True ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) email :: Text -> Markdown email = Markdown $ Just Email