mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-02 04:14:58 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
+23
@@ -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"),
|
||||
|
||||
+18
-1
@@ -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
|
||||
|
||||
+20
-22
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-13
@@ -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 ->
|
||||
|
||||
+23
-25
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-12
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user