rfc: namespace (#7001)

* rfc: namespace

* update rfc

* markdown for names

* record type, app "upgrade" alerts

* update api types

* rfc: change namespace syntax - now it is the usual namespace

* update bot types

* move types to simplexmq

* core: refactore markdown

* update simplexmq

* better names

* new names

* update nix content hashes

* fix

* change valid name function

* update simplexq, update valid name conditions

* fixes

Co-authored-by: simplex-chat-agent[bot] <287173099+simplex-chat-agent[bot]@users.noreply.github.com>

* update simplexmq

* fix localization

* simpler

* refactor

* refactor

* fix

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: simplex-chat-agent[bot] <287173099+simplex-chat-agent[bot]@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-05-28 08:44:43 +01:00
committed by GitHub
parent 12fbf61f32
commit 68abd805d4
24 changed files with 703 additions and 156 deletions
@@ -208,7 +208,9 @@ private func handleTextTaps(
var browser: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if index >= range.location && index < range.location + range.length {
if let url = attrs[linkAttrKey] as? String {
if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo {
showUnsupportedNameAlert(nameInfo)
} else if let url = attrs[linkAttrKey] as? String {
linkURL = url
browser = attrs[webLinkAttrKey] != nil
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
@@ -251,6 +253,7 @@ private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command")
private let nameAttrKey = NSAttributedString.Key("chat.simplex.app.name")
typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool)
@@ -424,6 +427,12 @@ func messageText(
t = mentionText(memberName)
}
}
case let .simplexName(nameInfo):
attrs = linkAttrs()
if !preview {
attrs[nameAttrKey] = nameInfo
handleTaps = true
}
case .email:
attrs = linkAttrs()
if !preview {
@@ -675,17 +675,18 @@ struct ChatListSearchBar: View {
if ignoreSearchTextChange {
ignoreSearchTextChange = false
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
switch strConnectTarget(t.trimmingCharacters(in: .whitespaces)) {
case let .link(text, _, linkText):
searchFocussed = false
if case let .simplexLink(_, linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
ignoreSearchTextChange = true
searchText = linkText
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
connect(link.text)
} else {
if t != "" { // if some other text is pasted, enter search mode
connect(text)
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case .none:
if t != "" {
searchFocussed = true
} else {
ConnectProgressManager.shared.cancelConnectProgress()
@@ -381,17 +381,18 @@ struct ContactsListSearchBar: View {
if ignoreSearchTextChange {
ignoreSearchTextChange = false
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
switch strConnectTarget(t.trimmingCharacters(in: .whitespaces)) {
case let .link(text, _, linkText):
searchFocussed = false
if case let .simplexLink(_, linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
ignoreSearchTextChange = true
searchText = linkText
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
connect(link.text)
} else {
if t != "" { // if some other text is pasted, enter search mode
connect(text)
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case .none:
if t != "" {
searchFocussed = true
} else {
connectProgressManager.cancelConnectProgress()
+48 -21
View File
@@ -663,14 +663,13 @@ private struct ConnectView: View {
ZStack(alignment: .trailing) {
Button {
if let str = UIPasteboard.general.string {
if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) {
pastedLink = link.text
// It would be good to hide it, but right now it is not clear how to release camera in CodeScanner
// https://github.com/twostraws/CodeScanner/issues/121
// No known tricks worked (changing view ID, wrapping it in another view, etc.)
// showQRCodeScanner = false
switch strConnectTarget(str.trimmingCharacters(in: .whitespaces)) {
case let .link(text, _, _):
pastedLink = text
connect(pastedLink)
} else {
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case .none:
alert = .newChatSomeAlert(alert: SomeAlert(
alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
id: "pasteLinkView: code is not a SimpleX link"
@@ -866,16 +865,36 @@ func strIsSimplexLink(_ str: String) -> Bool {
}
}
func strHasSingleSimplexLink(_ str: String) -> FormattedText? {
if let parsedMd = parseSimpleXMarkdown(str) {
let parsedLinks = parsedMd.filter({ $0.format?.isSimplexLink ?? false })
if parsedLinks.count == 1 {
return parsedLinks[0]
} else {
return nil
}
enum ConnectTarget {
case link(text: String, linkType: SimplexLinkType, linkText: String)
case name(SimplexNameInfo)
}
func strConnectTarget(_ str: String) -> ConnectTarget? {
let parsedMd = parseSimpleXMarkdown(str)
let links = parsedMd?.filter { $0.format?.isSimplexLink ?? false } ?? []
return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format {
.link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts))
} else if links.isEmpty,
case let .simplexName(nameInfo) = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } })?.format {
.name(nameInfo)
} else {
return nil
nil
}
}
func showUnsupportedNameAlert(_ nameInfo: SimplexNameInfo) {
let upgrade = " " + NSLocalizedString("Please upgrade the app.", comment: "alert message")
if nameInfo.nameType == .contact {
showAlert(
NSLocalizedString("Unsupported contact name", comment: "alert title"),
message: NSLocalizedString("Connecting via contact name requires a newer app version.", comment: "alert message") + upgrade
)
} else {
showAlert(
NSLocalizedString("Unsupported channel name", comment: "alert title"),
message: NSLocalizedString("Connecting via channel name requires a newer app version.", comment: "alert message") + upgrade
)
}
}
@@ -1295,13 +1314,21 @@ func planAndConnect(
filterKnownContact: ((Contact) -> Void)? = nil,
filterKnownGroup: ((GroupInfo) -> Void)? = nil
) {
if case .simplexLink(_, .relay, _, _) = strHasSingleSimplexLink(shortOrFullLink)?.format {
showAlert(
NSLocalizedString("Relay address", comment: "alert title"),
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
)
switch strConnectTarget(shortOrFullLink) {
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
cleanup?()
return
case let .link(_, linkType, _):
if linkType == .relay {
showAlert(
NSLocalizedString("Relay address", comment: "alert title"),
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
)
cleanup?()
return
}
case .none: break
}
ConnectProgressManager.shared.cancelConnectProgress()
let inProgress = BoxedValue(true)
+19
View File
@@ -5104,6 +5104,7 @@ public enum Format: Decodable, Equatable, Hashable {
case uri
case hyperLink(showText: String?, linkUri: String)
case simplexLink(showText: String?, linkType: SimplexLinkType, simplexUri: String, smpHosts: [String])
case simplexName(nameInfo: SimplexNameInfo)
case command(commandStr: String)
case mention(memberName: String)
case email
@@ -5138,6 +5139,24 @@ public enum SimplexLinkType: String, Decodable, Hashable {
}
}
public struct SimplexNameInfo: Decodable, Equatable, Hashable {
public var nameType: SimplexNameType
public var nameTLD: SimplexTLD
public var domain: String
public var subDomain: [String]
}
public enum SimplexTLD: String, Decodable, Hashable {
case simplex
case testing
case web
}
public enum SimplexNameType: String, Decodable, Hashable {
case publicGroup
case contact
}
public enum FormatColor: String, Decodable, Hashable {
case red = "red"
case green = "green"
@@ -4680,6 +4680,7 @@ sealed class Format {
val viaHosts: String get() =
"(${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
}
@Serializable @SerialName("simplexName") class SimplexName(val nameInfo: SimplexNameInfo): Format()
@Serializable @SerialName("command") class Command(val commandStr: String): Format()
@Serializable @SerialName("mention") class Mention(val memberName: String): Format()
@Serializable @SerialName("email") class Email: Format()
@@ -4697,6 +4698,7 @@ sealed class Format {
is Uri -> linkStyle
is HyperLink -> linkStyle
is SimplexLink -> linkStyle
is SimplexName -> linkStyle
is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace)
is Mention -> SpanStyle(fontWeight = FontWeight.Medium)
is Email -> linkStyle
@@ -4728,6 +4730,27 @@ enum class SimplexLinkType(val linkType: String) {
})
}
@Serializable
data class SimplexNameInfo(
val nameType: SimplexNameType,
val nameTLD: SimplexTLD,
val domain: String,
val subDomain: List<String>
)
@Serializable
enum class SimplexTLD {
@SerialName("simplex") simplex,
@SerialName("testing") testing,
@SerialName("web") web
}
@Serializable
enum class SimplexNameType {
@SerialName("publicGroup") publicGroup,
@SerialName("contact") contact
}
@Serializable
enum class FormatColor(val color: String) {
red("red"),
@@ -281,6 +281,13 @@ fun MarkdownText (
}
}
}
is Format.SimplexName -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "SIMPLEX_NAME", annotation = i.toString()) {
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Email -> {
hasLinks = true
val ftStyle = Format.linkStyle
@@ -329,6 +336,16 @@ fun MarkdownText (
withAnnotation("WEB_URL") { a -> openBrowserAlert(a.item, uriHandler) }
withAnnotation("OTHER_URL") { a -> safeOpenUri(a.item, uriHandler) }
withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) }
withAnnotation("SIMPLEX_NAME") { a ->
val idx = a.item.toIntOrNull()
val nameInfo = (idx?.let { formattedText.getOrNull(it) }?.format as? Format.SimplexName)?.nameInfo
val (title, msg) = if (nameInfo?.nameType == SimplexNameType.contact) {
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
} else {
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
}
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
}
}
if (hasSecrets) {
withAnnotation("SECRET") { a ->
@@ -343,7 +360,7 @@ fun MarkdownText (
onHover = { offset ->
val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) }
icon.value =
if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
PointerIcon.Hand
} else {
PointerIcon.Text
@@ -791,31 +791,29 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.format.simplexLinkText
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
when (val target = strConnectTarget(it.trim())) {
is ConnectTarget.Link -> {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else {
if (!chatModel.appOpenUrlConnecting.value) {
connectProgressManager.cancelConnectProgress()
}
if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else {
if (!chatModel.appOpenUrlConnecting.value) {
connectProgressManager.cancelConnectProgress()
}
if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
}
}
@@ -30,14 +30,23 @@ suspend fun planAndConnect(
filterKnownContact: ((Contact) -> Unit)? = null,
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
): CompletableDeferred<Boolean> {
val link = strHasSingleSimplexLink(shortOrFullLink.trim())
if (link?.format is Format.SimplexLink && (link.format as Format.SimplexLink).linkType == SimplexLinkType.relay) {
AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.relay_address_alert_title),
generalGetString(MR.strings.relay_address_alert_message),
)
cleanup?.invoke()
return CompletableDeferred(false)
when (val target = strConnectTarget(shortOrFullLink.trim())) {
is ConnectTarget.Name -> {
showUnsupportedNameAlert(target.nameInfo)
cleanup?.invoke()
return CompletableDeferred(false)
}
is ConnectTarget.Link -> {
if (target.linkType == SimplexLinkType.relay) {
AlertManager.privacySensitive.showAlertMsg(
generalGetString(MR.strings.relay_address_alert_title),
generalGetString(MR.strings.relay_address_alert_message),
)
cleanup?.invoke()
return CompletableDeferred(false)
}
}
null -> {}
}
connectProgressManager.cancelConnectProgress()
val inProgress = mutableStateOf(true)
@@ -73,11 +82,8 @@ private suspend fun planAndConnectTask(
if (!inProgress.value) { return completable }
if (result != null) {
val (connectionLink, connectionPlan) = result
val link = strHasSingleSimplexLink(shortOrFullLink.trim())
val linkText = if (link?.format is Format.SimplexLink)
"<br><br><u>${link.format.simplexLinkText}</u>"
else
""
val target = strConnectTarget(shortOrFullLink.trim())
val linkText = if (target is ConnectTarget.Link) "<br><br><u>${target.linkText}</u>" else ""
when (connectionPlan) {
is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) {
is InvitationLinkPlan.Ok ->
@@ -523,34 +523,32 @@ private fun ContactsSearchBar(
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.format.simplexLinkText
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
when (val target = strConnectTarget(it.trim())) {
is ConnectTarget.Link -> {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(
link = target.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(
link = link.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else {
connectProgressManager.cancelConnectProgress()
if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else {
connectProgressManager.cancelConnectProgress()
if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
}
}
@@ -671,13 +671,14 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState<String>, showQRC
val clipboard = LocalClipboardManager.current
SectionItemView({
val str = clipboard.getText()?.text ?: return@SectionItemView
val link = strHasSingleSimplexLink(str.trim())
if (link != null) {
pastedLink.value = link.text
showQRCodeScanner.value = false
withBGApi { connect(rhId, link.text, close) { pastedLink.value = "" } }
} else {
AlertManager.shared.showAlertMsg(
when (val target = strConnectTarget(str.trim())) {
is ConnectTarget.Link -> {
pastedLink.value = target.text
showQRCodeScanner.value = false
withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } }
}
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
null -> AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_contact_link),
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
)
@@ -819,12 +820,32 @@ fun strIsSimplexLink(str: String): Boolean {
return parsedMd != null && parsedMd.size == 1 && parsedMd[0].format is Format.SimplexLink
}
fun strHasSingleSimplexLink(str: String): FormattedText? {
val parsedMd = parseToMarkdown(str) ?: return null
val parsedLinks = parsedMd.filter { it.format?.isSimplexLink ?: false }
if (parsedLinks.size != 1) return null
sealed class ConnectTarget {
class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget()
class Name(val nameInfo: SimplexNameInfo) : ConnectTarget()
}
return parsedLinks[0]
fun strConnectTarget(str: String): ConnectTarget? {
val parsedMd = parseToMarkdown(str) ?: return null
val links = parsedMd.filter { it.format?.isSimplexLink ?: false }
if (links.size == 1) {
val fmt = links[0].format as Format.SimplexLink
return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText)
}
if (links.isEmpty()) {
val nameInfo = parsedMd.firstNotNullOfOrNull { (it.format as? Format.SimplexName)?.nameInfo }
if (nameInfo != null) return ConnectTarget.Name(nameInfo)
}
return null
}
fun showUnsupportedNameAlert(nameInfo: SimplexNameInfo) {
val (title, msg) = if (nameInfo.nameType == SimplexNameType.contact) {
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
} else {
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
}
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
}
@Composable
@@ -194,6 +194,11 @@
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Please check that you used the correct link or ask your contact to send you another one.</string>
<string name="unsupported_connection_link">Unsupported connection link</string>
<string name="link_requires_newer_app_version_please_upgrade">This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link.</string>
<string name="unsupported_channel_name">Unsupported channel name</string>
<string name="unsupported_contact_name">Unsupported contact name</string>
<string name="channel_name_requires_newer_app_version">Connecting via channel name requires a newer app version.</string>
<string name="contact_name_requires_newer_app_version">Connecting via contact name requires a newer app version.</string>
<string name="please_upgrade_the_app">Please upgrade the app.</string>
<string name="channel_temporarily_unavailable">Channel temporarily unavailable</string>
<string name="channel_no_active_relays_try_later">Channel has no active relays. Please try to join later.</string>
<string name="app_update_required">App update required</string>
+37
View File
@@ -165,6 +165,9 @@ This file is generated automatically.
- [SecurityCode](#securitycode)
- [SimplePreference](#simplepreference)
- [SimplexLinkType](#simplexlinktype)
- [SimplexNameInfo](#simplexnameinfo)
- [SimplexNameType](#simplexnametype)
- [SimplexTLD](#simplextld)
- [SndCIStatusProgress](#sndcistatusprogress)
- [SndConnEvent](#sndconnevent)
- [SndError](#snderror)
@@ -2091,6 +2094,10 @@ SimplexLink:
- simplexUri: string
- smpHosts: [string]
SimplexName:
- type: "simplexName"
- nameInfo: [SimplexNameInfo](#simplexnameinfo)
Command:
- type: "command"
- commandStr: string
@@ -3440,6 +3447,36 @@ A_QUEUE:
- "relay"
---
## SimplexNameInfo
**Record type**:
- nameType: [SimplexNameType](#simplexnametype)
- nameTLD: [SimplexTLD](#simplextld)
- domain: string
- subDomain: [string]
---
## SimplexNameType
**Enum type**:
- "publicGroup"
- "contact"
---
## SimplexTLD
**Enum type**:
- "simplex"
- "testing"
- "web"
---
## SndCIStatusProgress
+6
View File
@@ -345,6 +345,9 @@ chatTypesDocsData =
(sti @SecurityCode, STRecord, "", [], "", ""),
(sti @SimplePreference, STRecord, "", [], "", ""),
(sti @SimplexLinkType, STEnum, "XL", [], "", ""),
(sti @SimplexNameInfo, STRecord, "", [], "", ""),
(sti @SimplexNameType, STEnum, "NT", [], "", ""),
(sti @SimplexTLD, STEnum, "TLD", [], "", ""),
(sti @SMPAgentError, STUnion, "", [], "", ""),
(sti @SndCIStatusProgress, STEnum, "SSP", [], "", ""),
(sti @SndConnEvent, STUnion, "SCE", [], "", ""),
@@ -558,6 +561,9 @@ deriving instance Generic RelayStatus
deriving instance Generic ReportReason
deriving instance Generic SecurityCode
deriving instance Generic SimplexLinkType
deriving instance Generic SimplexNameInfo
deriving instance Generic SimplexNameType
deriving instance Generic SimplexTLD
deriving instance Generic SMPAgentError
deriving instance Generic SndCIStatusProgress
deriving instance Generic SndConnEvent
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: f03cec7a58ed13a39a52886888c74bcefdb64479
tag: e9265a7f7cb723d70b03e1b67af01f2666872a44
source-repository-package
type: git
+246
View File
@@ -0,0 +1,246 @@
# Public Namespaces for SimpleX Network
## Motivation
SimpleX has no user identifiers - users exchange invitation links out-of-band to connect. Short links help but are unmemorable. Public namespaces map human-readable names to SimpleX addresses.
Names also solve censorship at two levels. A short link is controlled by one SMP router - that router can delete it. An on-chain name can't be deleted by any router. If the link is removed, the owner points the name to a new link on a different router. At the network level, links can be URL-filtered, but names resolve through SMP proxy chains - censoring a name requires controlling all resolvers the user can reach.
DNS-based naming is vulnerable to domain seizure and requires WHOIS entries. Blockchains provide censorship-resistant globally unique names.
## Product requirements
### MVP
- **Names**: TLD `.simplex` (e.g., `privacy.simplex`, `my-channel.simplex`). Subdomains: `support.acme.simplex`. In markdown, `.simplex` can be omitted: `#privacy` = `privacy.simplex`.
- **Name rules**: see [Name rules](#name-rules).
- **Two address types**: each name stores channel links (set) and contact links (set). Client uses the first; set provides forward-compatible redundancy. Either can be empty.
- **Optional metadata**: admin SimpleX address, admin email.
- **Registration**: commit-reveal to prevent frontrunning. Length-based ETH pricing. Annual renewal. Dutch auction on expiry.
- **Launch gating**: requires SimpleX test NFT. Up to 5 paid + 5 test names per holder. Test names free, auto-removed after 3 months, use `testing` namespace.
- **Reserved names**: common verticals (books, games, music, movies, news, etc.) reserved for community-operated channels managed by SimpleX Network Consortium.
- Only 7+ character names can be registered during "launch phase".
- **Resolution**: client queries two independent name servers (Ethereum light clients) via two SMP proxies. Agreement = trusted. Disagreement = warning.
- **Double resolution**: name -> short link (on-chain), short link -> connection data (existing protocol).
- **Verification**: if on-chain link matches profile address, name is verified. Manual "verify" button + optional auto-verify on profile open.
- **Markdown**: `#name` (`.simplex` implied), `#name.simplex` (explicit), `#name.testing` for test namespace. In CLI, `#` is local in group commands, global in `/c` and message bodies.
- **Search**: `#name.simplex` auto-resolves. Disable in "More privacy" settings.
- **Router role**: `names` added to `ServerRoles`. Not all routers support it.
- **Contract**: ENS fork on Ethereum mainnet. ETH payment. Upgradeable.
### Post-MVP
- **Multiple links**: redundant entries per name. Forward-compatible schema in MVP where practical.
- **Contact syntax**: `:name.simplex`, `:my-name.simplex`. Same namespace, different link type. MVP parser supports this syntax; resolution works; UI support is post-MVP.
- **Community Credits**: replace ETH for private registration.
- **Unicode expansion**: add scripts as user base grows.
## Part 1: Blockchain contract
### Overview
ENS fork on Ethereum mainnet. Retains commit-reveal, pricing, expiry, Dutch auction. Compatible with ENS dApp. Upgradeable.
ENS source:
- Contracts: https://github.com/ensdomains/ens-contracts
- dApp: https://github.com/ensdomains/ens-app-v3
- JS library: https://github.com/ensdomains/ensjs
### Contract state
```
Name record (ENS structure + SimpleX resolver fields):
owner : address
channelLinks : string[]
contactLinks : string[]
adminAddress : string -- optional
adminEmail : string -- optional
expiry : uint256
isTest : bool
Global state:
reservedNames : mapping(string => bool)
testNFT : address
registrationLimit : uint8 -- 5
testLimit : uint8 -- 5
```
There must be maps to track names by owner, but specific contract design should be based on ENS.
### Name rules
ENS normalization (ENSIP-15) with additional restrictions enforced in dApp (registration) and resolvers (resolution). Contract follows ENS as-is.
Additional restrictions beyond ENSIP-15:
- No consecutive hyphens.
- No accented characters. Latin is `a-z` only (same as DNS LDH rule).
- Allowed scripts: Latin, Cyrillic, Arabic, Hebrew, Devanagari, Bengali, Thai, Greek, CJK, Hangul, Kana. Expandable as user base grows.
### Registration flow
1. NFT check
2. Limit check (5 paid / 5 test)
3. `commit(hash(name, owner, secret))`
4. Wait (min 1 minute)
5. `reveal(name, owner, secret)` + ETH (zero for test)
6. Validate: well-formed, not taken, not reserved, fee covered
7. Store record
### Pricing
Annual fees by name length:
| Length | Fee |
|---|---|
| 7+ | base |
| 6 | 4x |
| 5 | 16x |
| 4 | 64x |
| 3 | 256x |
Test names: free, expire after 3 months.
### Renewal and expiry
Annual renewal. Grace period, then Dutch auction decaying to base price.
### Updates
Owner can update links, admin address, admin email. Transfer follows ENS mechanics.
### Reserved names
List for community channels (e.g., `books`, `games`, `music`, `news`):
- Not registrable by users
- Revenue shared with network
### Retained ENS features
- **Resolver pattern**: registry maps name -> (owner, resolver). A SimpleX Resolver contract stores channel links, contact links, admin fields. Allows future extensibility without registry changes.
- **Multicoin address records**: BTC/ETH/XMR donation addresses per name. Subscribers see donation options from name resolution.
- **Text records**: generic key-value store for future metadata without contract upgrades.
- **Reverse resolution**: name lookup by address. Enables verification and discovery.
- **Subdomain registrar**: owner of `acme.simplex` can create `support.acme.simplex`, `sales.acme.simplex` without additional on-chain registration.
### Removed ENS features
- Avatar/image records.
- `.eth` TLD and ENS name imports.
- DNS name registration (DNSSEC imports).
### Governance
SimpleX Chat during testing and launch phases, migration to SimpleX Network Consortium.
## Part 2: SMP protocol extension
### New router role
```haskell
data ServerRoles = ServerRoles
{ storage :: Bool,
proxy :: Bool,
names :: Bool
}
```
Name-capable routers run an Ethereum light client.
### Resolution protocol
Uses existing SMP proxy infrastructure. Client sends queries through a proxy, not directly to name servers.
#### Commands
```
Client -> Proxy -> Name Server:
RSLV <namehash>
Name Server -> Proxy -> Client:
NAME <name_record>
ERR AUTH
```
Forwarded via `PRXY`/`PFWD`/`RRES` mechanism.
#### Two-operator resolution
```
Client -> Proxy A (Op 1) -> Name Server X (Op 1)
Client -> Proxy B (Op 2) -> Name Server Y (Op 2)
```
Both read same Ethereum state.
- Agree: trusted
- Disagree: warn, don't use
- One fails: retry with another server or show single result with reduced trust
Proxy sees client IP and session, but not query. Name server sees query, not client IP or session.
#### Name server implementation
1. Runs Ethereum light client (e.g., Helios) tracking SNRC
2. Receives `RSLV` via SMP proxy
3. Returns record from local state
State proofs can be added post-MVP.
#### Configuration
```haskell
data NamesConfig = NamesConfig
{ ethereumEndpoint :: String,
snrcAddress :: EthAddress,
cacheSeconds :: Int
}
```
#### Versioning
New SMP protocol version. Older routers/clients don't advertise the capability.
### Default routers
Default router list updated to include name-capable routers.
## Part 3: UI integration
### Markdown
- `#name` or `#name.simplex` - native names (no dot = `.simplex` implied)
- `#my-name` or `#my-name.simplex` - hyphenated names
- `#sub.name.simplex` - subdomains (explicit TLD)
- `#name.testing` - test namespace
- Rendered as clickable resolve-and-connect links
CLI: `#` = local in group commands, global in `/c` and messages.
`:name.simplex`, `:my-name.simplex` - contact addresses (same namespace, different link type). MVP parser supports this syntax; resolution works; UI support is post-MVP.
### Resolution flow
1. Normalize per ENSIP-15, compute namehash
2. `RSLV` to two name servers via two proxies
3. Compare results
4. First channel link -> short link resolution -> connection data
5. Present for joining
### Search
`#...simplex` triggers resolution. Disable in "More privacy" settings.
### Verification
On-chain link matches profile address = verified. Only name owner can set on-chain links.
- Manual: "Verify" button resolves and compares
- Auto: optional setting, resolves on profile open
### Display
Show name and verification status. `#` is syntax, not part of the name.
## Open questions
1. **Contract upgrade mechanism**: proxy pattern with timelock? Migration path for future Community Credits payment and domain name support.
@@ -2358,6 +2358,7 @@ export type Format =
| Format.Uri
| Format.HyperLink
| Format.SimplexLink
| Format.SimplexName
| Format.Command
| Format.Mention
| Format.Email
@@ -2375,6 +2376,7 @@ export namespace Format {
| "uri"
| "hyperLink"
| "simplexLink"
| "simplexName"
| "command"
| "mention"
| "email"
@@ -2431,6 +2433,11 @@ export namespace Format {
smpHosts: string[] // non-empty
}
export interface SimplexName extends Interface {
type: "simplexName"
nameInfo: SimplexNameInfo
}
export interface Command extends Interface {
type: "command"
commandStr: string
@@ -3848,6 +3855,24 @@ export enum SimplexLinkType {
Relay = "relay",
}
export interface SimplexNameInfo {
nameType: SimplexNameType
nameTLD: SimplexTLD
domain: string
subDomain: string[]
}
export enum SimplexNameType {
PublicGroup = "publicGroup",
Contact = "contact",
}
export enum SimplexTLD {
Simplex = "simplex",
Testing = "testing",
Web = "web",
}
export enum SndCIStatusProgress {
Partial = "partial",
Complete = "complete",
@@ -1687,6 +1687,10 @@ class Format_simplexLink(TypedDict):
simplexUri: str
smpHosts: list[str] # non-empty
class Format_simplexName(TypedDict):
type: Literal["simplexName"]
nameInfo: "SimplexNameInfo"
class Format_command(TypedDict):
type: Literal["command"]
commandStr: str
@@ -1712,13 +1716,14 @@ Format = (
| Format_uri
| Format_hyperLink
| Format_simplexLink
| Format_simplexName
| Format_command
| Format_mention
| Format_email
| Format_phone
)
Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "command", "mention", "email", "phone"]
Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone"]
class FormattedText(TypedDict):
format: NotRequired["Format"]
@@ -2687,6 +2692,16 @@ class SimplePreference(TypedDict):
SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"]
class SimplexNameInfo(TypedDict):
nameType: "SimplexNameType"
nameTLD: "SimplexTLD"
domain: str
subDomain: list[str]
SimplexNameType = Literal["publicGroup", "contact"]
SimplexTLD = Literal["simplex", "testing", "web"]
SndCIStatusProgress = Literal["partial", "complete"]
class SndConnEvent_switchQueue(TypedDict):
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."f03cec7a58ed13a39a52886888c74bcefdb64479" = "0bkd8kqgmwgfh5rwnw7s4p6mx9kwigi4jq9ljlfvzj23pslk1aq7";
"https://github.com/simplex-chat/simplexmq.git"."e9265a7f7cb723d70b03e1b67af01f2666872a44" = "00xyzc5advpka2d2mq11f02cmcr7fa7n6mjj53symspdpx1pgfa5";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+18 -10
View File
@@ -5547,17 +5547,25 @@ mkValidName :: String -> String
mkValidName = dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int)
where
fst3 (x, _, _) = x
addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct)
addChar (r, prev, punct) c' = if validChar then (c : r, c, punct') else (r, prev, punct)
where
c' = if isSpace c then ' ' else c
c = if isSpace c' then ' ' else c'
cat = generalCategory c
isPunct = case cat of
ConnectorPunctuation -> True
DashPunctuation -> True
OtherPunctuation -> True
_ -> False
punct'
| isPunctuation c = punct + 1
| isSpace c = punct
| isPunct = punct + 1
| c == ' ' = punct
| otherwise = 0
validChar
| c == '\'' = False
| prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar
| isSpace prev = validFirstChar || (punct == 0 && isPunctuation c)
| isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c)
| otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c
validFirstChar = isLetter c || isNumber c || isSymbol c
| c `elem` prohibited = False
| prev == '\NUL' = c > ' ' && validFirstNameChar
| prev == ' ' = validFirstChar || (punct == 0 && isPunct)
| punct > 0 = validFirstChar || c == ' '
| otherwise = validFirstChar || c == ' ' || isMark c || isPunct
validFirstNameChar = isLetter c || cat == DecimalNumber || cat == OtherSymbol
validFirstChar = validFirstNameChar || cat == CurrencySymbol || cat == MathSymbol
prohibited = ".,;/\\#@'\"`~" :: String
+31 -11
View File
@@ -35,11 +35,11 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnReqUriData (..), ConnShortLink (..), ConnectionLink (..), ConnectionRequestUri (..), ContactConnType (..), SMPQueue (..), simplexConnReqUri, simplexShortLink)
import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnReqUriData (..), ConnShortLink (..), ConnectionLink (..), ConnectionRequestUri (..), ContactConnType (..), SMPQueue (..), SimplexNameInfo (..), simplexConnReqUri, simplexShortLink)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON)
import Simplex.Messaging.Protocol (ProtocolServer (..))
import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8, tshow)
import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8, tshow, (<$?>))
import System.Console.ANSI.Types
import qualified Text.Email.Validate as Email
import qualified URI.ByteString as U
@@ -59,6 +59,7 @@ data Format
-- showText is Nothing for the usual Uri without text
| HyperLink {showText :: Maybe Text, linkUri :: Text}
| SimplexLink {showText :: Maybe Text, linkType :: SimplexLinkType, simplexUri :: AConnectionLink, smpHosts :: NonEmpty Text}
| SimplexName {nameInfo :: SimplexNameInfo}
| Command {commandStr :: Text}
| Mention {memberName :: Text}
| Email
@@ -184,6 +185,7 @@ isLink = \case
Uri -> True
HyperLink {} -> True
SimplexLink {} -> True
SimplexName {} -> True
_ -> False
hasLinks :: MarkdownList -> Bool
@@ -202,9 +204,9 @@ markdownP = mconcat <$> A.many' fragmentP
'_' -> formattedP '_' Italic
'~' -> formattedP '~' StrikeThrough
'`' -> formattedP '`' Snippet
'#' -> A.char '#' *> secretP
'#' -> A.char '#' *> (secretP <|> nameRefP '#' <|> secretFallback)
'!' -> styledP <|> wordP
'@' -> mentionP <|> wordP
'@' -> (A.char '@' *> nameRefP '@') <|> mentionP <|> wordP
'/' -> commandP <|> wordP
'[' -> sowLinkP <|> wordP
_
@@ -221,14 +223,29 @@ markdownP = mconcat <$> A.many' fragmentP
unmarked $ c `T.cons` s `T.snoc` c
| otherwise = markdown f s
secretP :: Parser Markdown
secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#')
secret :: Text -> Text -> Text -> Markdown
secret b s a
| T.null a || T.null s || T.head s == ' ' || T.last s == ' ' =
unmarked $ '#' `T.cons` ss
| otherwise = markdown Secret $ T.init ss
secretP = secret <$?> ((,,) <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile1 (== '#'))
secret :: (Text, Text, Text) -> Either String Markdown
secret (b, s, a)
| T.null s || T.head s == ' ' || T.last s == ' ' = Left "not secret"
| otherwise = Right $ markdown Secret $ T.init ss
where
ss = b <> s <> a
secretFallback :: Parser Markdown
secretFallback = unmarked . ('#' `T.cons`) <$> A.takeTill (== ' ')
nameRefP :: Char -> Parser Markdown
nameRefP pfx = nameRef <$?> A.takeTill (== ' ')
where
nameRef word
| pfx == '@' && T.all (/= '.') name = Left "not a name"
| otherwise = mkMd <$> strDecode (encodeUtf8 full)
where
(name, punct) = splitPunctuation word
full = pfx `T.cons` name
mkMd ni
| T.null punct = md'
| otherwise = md' :|: unmarked punct
where
md' = markdown (SimplexName ni) full
styledP :: Parser Markdown
styledP = do
f <- A.char '!' *> ((A.char '-' $> Small) <|> (colored <$> colorP)) <* A.space
@@ -449,6 +466,7 @@ markdownText (FormattedText f_ t) = case f_ of
Uri -> t
HyperLink {} -> t
SimplexLink {} -> t
SimplexName {} -> t
Mention _ -> t
Command _ -> t
Email -> t
@@ -479,7 +497,6 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill
takeNameTill p =
A.peekChar' >>= \c ->
if refChar c then A.takeTill p else fail "invalid first character in display name"
splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s)
quoted c = A.char c *> takeNameTill (== c) <* A.char c
refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\''
@@ -490,6 +507,9 @@ commandTextP = do
(keyword : _) | T.all (\c -> isAlpha c || isDigit c || c == '_') keyword -> pure (cmd, punct)
_ -> fail "invalid command keyword"
splitPunctuation :: Text -> (Text, Text)
splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s)
-- quotes names that contain spaces or end on punctuation
viewName :: Text -> Text
viewName s = if T.any isSpace s || maybe False (isPunctuation . snd) (T.unsnoc s) then "'" <> s <> "'" else s
@@ -3969,6 +3969,15 @@ Query:
Plan:
SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?)
Query:
UPDATE chat_items
SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ?
WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0
RETURNING chat_item_id
Plan:
SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?)
Query:
UPDATE chat_items
SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ?
@@ -6870,6 +6879,10 @@ Query: SELECT member_status FROM group_members WHERE local_display_name = ?
Plan:
SCAN group_members
Query: SELECT member_status FROM group_members WHERE member_role = 'relay'
Plan:
SCAN group_members
Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+40 -5
View File
@@ -10,6 +10,7 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat.Markdown
import Simplex.Messaging.Agent.Protocol (SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util ((<$$>))
import System.Console.ANSI.Types
@@ -28,6 +29,7 @@ markdownTests = do
textWithPhone
textWithMentions
textWithCommands
textWithSimplexNames
multilineMarkdownList
testSanitizeUri
@@ -117,7 +119,7 @@ secretText = describe "secret text" do
"this is # unformatted # text"
<==> "this is # unformatted # text"
"this is #unformatted # text"
<==> "this is #unformatted # text"
<==> "this is " <> sname NTPublicGroup TLDSimplex "unformatted" [] "unformatted" <> " # text"
"this is # unformatted# text"
<==> "this is # unformatted# text"
"this is ## unformatted ## text"
@@ -125,9 +127,9 @@ secretText = describe "secret text" do
"this is#unformatted# text"
<==> "this is#unformatted# text"
"this is #unformatted text"
<==> "this is #unformatted text"
<==> "this is " <> sname NTPublicGroup TLDSimplex "unformatted" [] "unformatted" <> " text"
"*this* is #unformatted text"
<==> bold "this" <> " is #unformatted text"
<==> bold "this" <> " is " <> sname NTPublicGroup TLDSimplex "unformatted" [] "unformatted" <> " text"
it "ignored internal markdown" do
"snippet: `this is #secret_text#`"
<==> "snippet: " <> markdown Snippet "this is #secret_text#"
@@ -297,8 +299,8 @@ textWithEmail = describe "text with Email" do
"test chat@simplex.chat." <==> "test " <> email "chat@simplex.chat" <> "."
"test chat@simplex.chat..." <==> "test " <> email "chat@simplex.chat" <> "..."
it "ignored as email markdown" do
"chat @simplex.chat" <==> "chat " <> mention "simplex.chat" "@simplex.chat"
"this is chat @simplex.chat" <==> "this is chat " <> mention "simplex.chat" "@simplex.chat"
"chat @simplex.chat" <==> "chat " <> sname NTContact TLDWeb "simplex.chat" [] "simplex.chat"
"this is chat @simplex.chat" <==> "this is chat " <> sname NTContact TLDWeb "simplex.chat" [] "simplex.chat"
"this is chat@ simplex.chat" <==> "this is chat@ " <> uri "simplex.chat"
"this is chat @ simplex.chat" <==> "this is chat @ " <> uri "simplex.chat"
"*this* is chat @ simplex.chat" <==> bold "this" <> " is chat @ " <> uri "simplex.chat"
@@ -378,6 +380,39 @@ uri' = FormattedText $ Just Uri
command' :: Text -> Text -> FormattedText
command' = FormattedText . Just . Command
sname :: SimplexNameType -> SimplexTLD -> Text -> [Text] -> Text -> Markdown
sname nt ns dom sub txt = markdown (SimplexName $ SimplexNameInfo nt ns dom sub) (pfx <> txt)
where
pfx = case nt of NTPublicGroup -> "#"; NTContact -> "@"
textWithSimplexNames :: Spec
textWithSimplexNames = describe "text with SimpleX names" do
it "channel names - simplex namespace" do
"#privacy" <==> sname NTPublicGroup TLDSimplex "privacy" [] "privacy"
"#privacy.simplex" <==> sname NTPublicGroup TLDSimplex "privacy" [] "privacy.simplex"
"#my-channel.simplex" <==> sname NTPublicGroup TLDSimplex "my-channel" [] "my-channel.simplex"
"hello #privacy!" <==> "hello " <> sname NTPublicGroup TLDSimplex "privacy" [] "privacy" <> "!"
"see #privacy.simplex now" <==> "see " <> sname NTPublicGroup TLDSimplex "privacy" [] "privacy.simplex" <> " now"
"#123" <==> sname NTPublicGroup TLDSimplex "123" [] "123"
it "channel names - subdomains" do
"#support.acme.simplex" <==> sname NTPublicGroup TLDSimplex "acme" ["support"] "support.acme.simplex"
"#a.b.acme.simplex" <==> sname NTPublicGroup TLDSimplex "acme" ["b", "a"] "a.b.acme.simplex"
it "channel names - testing namespace" do
"#test.testing" <==> sname NTPublicGroup TLDTesting "test" [] "test.testing"
"#sub.test.testing" <==> sname NTPublicGroup TLDTesting "test" ["sub"] "sub.test.testing"
it "channel names - web domains" do
"#example.com" <==> sname NTPublicGroup TLDWeb "example.com" [] "example.com"
"#news.bbc.co.uk" <==> sname NTPublicGroup TLDWeb "news.bbc.co.uk" [] "news.bbc.co.uk"
"#123.com" <==> sname NTPublicGroup TLDWeb "123.com" [] "123.com"
it "contact names" do
"@privacy.simplex" <==> sname NTContact TLDSimplex "privacy" [] "privacy.simplex"
"@my-name.simplex" <==> sname NTContact TLDSimplex "my-name" [] "my-name.simplex"
"@alice.example.com" <==> sname NTContact TLDWeb "alice.example.com" [] "alice.example.com"
it "not parsed as names" do
"#secret#" <==> markdown Secret "secret"
"##double secret##" <==> markdown Secret "#double secret#"
"#" <==> "#"
multilineMarkdownList :: Spec
multilineMarkdownList = describe "multiline markdown" do
it "correct markdown" do
+33 -16
View File
@@ -10,15 +10,17 @@ validNameTests = describe "valid chat names" $ do
testMkValidName :: IO ()
testMkValidName = do
mkValidName "alice" `shouldBe` "alice"
mkValidName " alice" `shouldBe` "alice"
mkValidName "?alice" `shouldBe` "alice"
mkValidName "алиса" `shouldBe` "алиса"
mkValidName "John Doe" `shouldBe` "John Doe"
mkValidName "J.Doe" `shouldBe` "J.Doe"
mkValidName "J. Doe" `shouldBe` "J. Doe"
mkValidName "J..Doe" `shouldBe` "J..Doe"
mkValidName "J ..Doe" `shouldBe` "J ..Doe"
mkValidName "J ... Doe" `shouldBe` "J ... Doe"
mkValidName "J .... Doe" `shouldBe` "J ... Doe"
mkValidName "J . . Doe" `shouldBe` "J . Doe"
mkValidName "J.Doe" `shouldBe` "JDoe"
mkValidName "J. Doe" `shouldBe` "J Doe"
mkValidName "J..Doe" `shouldBe` "JDoe"
mkValidName "J ..Doe" `shouldBe` "J Doe"
mkValidName "J ... Doe" `shouldBe` "J Doe"
mkValidName "J .... Doe" `shouldBe` "J Doe"
mkValidName "J . . Doe" `shouldBe` "J Doe"
mkValidName "@alice" `shouldBe` "alice"
mkValidName "#alice" `shouldBe` "alice"
mkValidName "'alice" `shouldBe` "alice"
@@ -26,17 +28,32 @@ testMkValidName = do
mkValidName "alice " `shouldBe` "alice"
mkValidName "John Doe" `shouldBe` "John Doe"
mkValidName "'John Doe'" `shouldBe` "John Doe"
mkValidName "\"John Doe\"" `shouldBe` "John Doe\""
mkValidName "`John Doe`" `shouldBe` "`John Doe`"
mkValidName "John \"Doe\"" `shouldBe` "John \"Doe\""
mkValidName "John `Doe`" `shouldBe` "John `Doe`"
mkValidName "alice/bob" `shouldBe` "alice/bob"
mkValidName "alice / bob" `shouldBe` "alice / bob"
mkValidName "alice /// bob" `shouldBe` "alice /// bob"
mkValidName "alice //// bob" `shouldBe` "alice /// bob"
mkValidName "\"John Doe\"" `shouldBe` "John Doe"
mkValidName "`John Doe`" `shouldBe` "John Doe"
mkValidName "John \"Doe\"" `shouldBe` "John Doe"
mkValidName "John `Doe`" `shouldBe` "John Doe"
mkValidName "alice/bob" `shouldBe` "alicebob"
mkValidName "alice / bob" `shouldBe` "alice bob"
mkValidName "alice /// bob" `shouldBe` "alice bob"
mkValidName "alice //// bob" `shouldBe` "alice bob"
mkValidName "alice >>= bob" `shouldBe` "alice >>= bob"
mkValidName "alice@example.com" `shouldBe` "alice@example.com"
mkValidName "alice@example.com" `shouldBe` "aliceexamplecom"
mkValidName "alice <> bob" `shouldBe` "alice <> bob"
mkValidName "alice -> bob" `shouldBe` "alice -> bob"
mkValidName "alice & bob" `shouldBe` "alice & bob"
mkValidName "alice && bob" `shouldBe` "alice & bob"
mkValidName "alice & & bob" `shouldBe` "alice & bob"
mkValidName "alice-bob" `shouldBe` "alice-bob"
mkValidName "alice--bob" `shouldBe` "alice-bob"
mkValidName "alice -- bob" `shouldBe` "alice - bob"
mkValidName "alice \\ bob" `shouldBe` "alice bob"
mkValidName "alice (bob)" `shouldBe` "alice bob"
mkValidName "alice: bob" `shouldBe` "alice: bob"
mkValidName "alice 👍" `shouldBe` "alice 👍"
mkValidName "👍" `shouldBe` "👍"
mkValidName "alice >" `shouldBe` "alice >"
mkValidName "> alice" `shouldBe` "alice"
mkValidName "123" `shouldBe` "123"
mkValidName "123 alice" `shouldBe` "123 alice"
mkValidName "01234567890123456789012345678901234567890123456789extra" `shouldBe` "01234567890123456789012345678901234567890123456789"
mkValidName "0123456789012345678901234567890123456789012345678 extra" `shouldBe` "0123456789012345678901234567890123456789012345678"