mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 19:50:30 +00:00
Merge branch 'master' into badge-in-app-checkout
This commit is contained in:
@@ -179,6 +179,7 @@ SimpleX Chat founder
|
||||
- [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations)
|
||||
- [For developers](#for-developers)
|
||||
- [Develop a chat bot](#develop-a-chat-bot)
|
||||
- [Agents](#agents)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Disclaimers, Security contact, License](#disclaimers)
|
||||
|
||||
@@ -331,6 +332,18 @@ See [our new bot API reference](./bots/README.md). Most of it is automatically g
|
||||
|
||||
Also see [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
||||
|
||||
## Agents
|
||||
|
||||
- For AI assistants: [simplex.chat/llms.txt](https://simplex.chat/llms.txt), an index of the documentation in Markdown, and [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md).
|
||||
- Every page of simplex.chat is also available as Markdown: replace `.html` with `.md` ([simplex.chat/docs/simplex.md](https://simplex.chat/docs/simplex.md)), or add `.md` to the path ([simplex.chat/why.md](https://simplex.chat/why.md)).
|
||||
- Libraries: [Node.js](./packages/simplex-chat-nodejs/) ([npm](https://www.npmjs.com/package/simplex-chat)) and [Python](./packages/simplex-chat-python/) ([PyPI](https://pypi.org/project/simplex-chat/)).
|
||||
- [Bot API guide](./bots/README.md) and [API reference](./bots/api/README.md): commands, events and types.
|
||||
- Example bots: [support bot](./apps/simplex-support-bot/) and [calculator](./apps/simplex-calculator-bot/) in TypeScript, [support bot light](./apps/simplex-support-bot-light/) in Python, [directory service](./apps/simplex-directory-service/) and [broadcast bot](./apps/simplex-broadcast-bot/) in Haskell.
|
||||
- [Terminal client](./docs/CLI.md): installation, building from source and usage.
|
||||
- Servers: [SMP](./docs/SERVER.md), [XFTP](./docs/XFTP-SERVER.md) and [chat relay](./docs/CHAT-RELAY.md).
|
||||
- Protocols: [SimpleX network](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md), [chat](./docs/protocol/simplex-chat.md), [channels](./docs/protocol/channels-overview.md) and [public names](./docs/protocol/names-overview.md).
|
||||
- For coding agents working in this repository: [project structure](./docs/contributing/PROJECT.md) and [coding and building](./docs/contributing/CODE.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies.
|
||||
|
||||
@@ -1972,10 +1972,13 @@ enum UserServersError: Decodable {
|
||||
case storageMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
|
||||
case tooManyHosts(protocol: ServerProtocol, tooManyHostsServer: String)
|
||||
case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String)
|
||||
|
||||
var globalError: String? {
|
||||
switch self {
|
||||
case .tooManyHosts:
|
||||
return globalSMPError
|
||||
case let .noServers(`protocol`, _):
|
||||
switch `protocol` {
|
||||
case .smp: return globalSMPError
|
||||
@@ -2018,6 +2021,8 @@ enum UserServersError: Decodable {
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
case let .tooManyHosts(.smp, server):
|
||||
return String.localizedStringWithFormat(NSLocalizedString("Server %@ has more than two host names.", comment: "servers error"), serverHostname(server))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -935,6 +935,8 @@ final class ChatModel: ObservableObject {
|
||||
func updateCurrentUser(_ newProfile: Profile, _ preferences: FullPreferences? = nil) {
|
||||
if let current = currentUser {
|
||||
currentUser?.profile = toLocalProfile(current.profile.profileId, newProfile, "")
|
||||
currentUser?.profile.localBadge = current.profile.localBadge
|
||||
currentUser?.profile.contactDomainVerified = current.profile.contactDomainVerified
|
||||
if let preferences = preferences {
|
||||
currentUser?.fullPreferences = preferences
|
||||
}
|
||||
|
||||
@@ -1359,8 +1359,12 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
|
||||
switch r {
|
||||
case .result(.userProfileNoChange): return (profile, [])
|
||||
case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts)
|
||||
case .error(.errorStore(.duplicateName)): return nil;
|
||||
default: throw r.unexpected
|
||||
case .error(.errorStore(.duplicateName)), .error(.error(.userExists)): return nil;
|
||||
default:
|
||||
await MainActor.run {
|
||||
showErrorAlert(r.unexpected, NSLocalizedString("Error saving profile", comment: "alert title"))
|
||||
}
|
||||
throw r.unexpected
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -767,6 +767,7 @@ struct ComposeView: View {
|
||||
let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count
|
||||
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
|
||||
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
|
||||
guard total > 0 || chatModel.membersLoaded else { return nil }
|
||||
let noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total
|
||||
return (hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays)
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ struct UserAddressView: View {
|
||||
title: Text("Share address with SimpleX contacts?"),
|
||||
message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."),
|
||||
primaryButton: .default(Text("Share")) {
|
||||
setProfileAddress($progressIndicator, true)
|
||||
setProfileAddress($progressIndicator, true) { shareViaProfile = false }
|
||||
shareViaProfile = true
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
@@ -318,7 +318,7 @@ struct UserAddressView: View {
|
||||
alert = .shareOnCreate
|
||||
progressIndicator = false
|
||||
} else {
|
||||
setProfileAddress($progressIndicator, true)
|
||||
setProfileAddress($progressIndicator, true) { shareViaProfile = false }
|
||||
shareViaProfile = true
|
||||
}
|
||||
} else {
|
||||
@@ -601,7 +601,7 @@ struct AddressSettingsState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool) {
|
||||
private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool, revert: @escaping () -> Void) {
|
||||
progressIndicator.wrappedValue = true
|
||||
Task {
|
||||
do {
|
||||
@@ -613,7 +613,11 @@ private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool) {
|
||||
await MainActor.run { progressIndicator.wrappedValue = false }
|
||||
} catch let error {
|
||||
logger.error("apiSetProfileAddress: \(responseError(error))")
|
||||
await MainActor.run { progressIndicator.wrappedValue = false }
|
||||
await MainActor.run {
|
||||
progressIndicator.wrappedValue = false
|
||||
revert()
|
||||
showErrorAlert(error, NSLocalizedString("Error saving profile", comment: "alert title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,7 +713,10 @@ struct UserAddressSettingsView: View {
|
||||
title: NSLocalizedString("Share", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
setProfileAddress($progressIndicator, on)
|
||||
setProfileAddress($progressIndicator, on) {
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = !on
|
||||
}
|
||||
}
|
||||
)
|
||||
]}
|
||||
@@ -731,7 +738,10 @@ struct UserAddressSettingsView: View {
|
||||
title: NSLocalizedString("Stop sharing", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
setProfileAddress($progressIndicator, on)
|
||||
setProfileAddress($progressIndicator, on) {
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = !on
|
||||
}
|
||||
}
|
||||
)
|
||||
]}
|
||||
|
||||
@@ -36,7 +36,7 @@ struct UserProfile: View {
|
||||
HStack {
|
||||
TextField("Enter your name…", text: $profile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
if !validDisplayName(profile.displayName) {
|
||||
if !validNewProfileName {
|
||||
Button {
|
||||
alert = .invalidNameError(validName: mkValidName(profile.displayName))
|
||||
} label: {
|
||||
@@ -147,10 +147,15 @@ struct UserProfile: View {
|
||||
(chatModel.currentUser?.profile.description ?? "") != description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
) &&
|
||||
profile.displayName.trimmingCharacters(in: .whitespaces) != "" &&
|
||||
validDisplayName(profile.displayName) &&
|
||||
validNewProfileName &&
|
||||
bioFitsLimit()
|
||||
}
|
||||
|
||||
private var validNewProfileName: Bool {
|
||||
profile.displayName == chatModel.currentUser?.profile.displayName
|
||||
|| validDisplayName(profile.displayName)
|
||||
}
|
||||
|
||||
private func saveProfile() {
|
||||
focusDisplayName = false
|
||||
Task {
|
||||
|
||||
@@ -598,6 +598,7 @@ public enum RcvSwitchStatus: String, Decodable, Hashable {
|
||||
case sendingQADD = "sending_qadd"
|
||||
case sendingQUSE = "sending_quse"
|
||||
case receivedMessage = "received_message"
|
||||
case receivedQEND = "received_qend"
|
||||
}
|
||||
|
||||
public struct SndQueueInfo: Decodable, Hashable {
|
||||
|
||||
@@ -133,6 +133,7 @@ public struct Profile: Codable, NamedChat, Hashable {
|
||||
self.image = image
|
||||
self.contactLink = contactLink
|
||||
self.preferences = preferences
|
||||
self.peerType = peerType
|
||||
self.contactDomain = contactDomain
|
||||
}
|
||||
|
||||
@@ -593,7 +594,8 @@ public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias
|
||||
contactLink: profile.contactLink,
|
||||
preferences: profile.preferences,
|
||||
peerType: profile.peerType,
|
||||
localAlias: localAlias
|
||||
localAlias: localAlias,
|
||||
contactDomain: profile.contactDomain
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1115,7 +1115,7 @@ object ChatModel {
|
||||
fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) {
|
||||
val current = currentUser.value ?: return
|
||||
val updated = current.copy(
|
||||
profile = newProfile.toLocalProfile(current.profile.profileId),
|
||||
profile = newProfile.toLocalProfile(current.profile.profileId).copy(localBadge = current.profile.localBadge, contactDomainVerified = current.profile.contactDomainVerified),
|
||||
fullPreferences = preferences ?: current.fullPreferences
|
||||
)
|
||||
val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId }
|
||||
|
||||
+15
-3
@@ -1959,8 +1959,10 @@ object ChatController {
|
||||
val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile))
|
||||
if (r is API.Result && r.res is CR.UserProfileNoChange) return profile to emptyList()
|
||||
if (r is API.Result && r.res is CR.UserProfileUpdated) return r.res.toProfile to r.res.updateSummary.changedContacts
|
||||
if (r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName) {
|
||||
if (r is API.Error && (r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName || r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.UserExists)) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc))
|
||||
} else if (!(networkErrorAlert(r))) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_profile), "${r.responseType}: ${r.details}")
|
||||
}
|
||||
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
@@ -1972,7 +1974,12 @@ object ChatController {
|
||||
return when {
|
||||
r is API.Result && r.res is CR.UserProfileNoChange -> null
|
||||
r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh)
|
||||
else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}")
|
||||
else -> {
|
||||
if (!(networkErrorAlert(r))) {
|
||||
apiErrorAlert("apiSetProfileAddress", generalGetString(MR.strings.error_saving_profile), r)
|
||||
}
|
||||
throw Exception("failed to set profile address: ${r.responseType} ${r.details}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4948,6 +4955,7 @@ sealed class UserServersError {
|
||||
@Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
|
||||
@Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError()
|
||||
@Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError()
|
||||
@Serializable @SerialName("tooManyHosts") data class TooManyHosts(val protocol: ServerProtocol, val tooManyHostsServer: String): UserServersError()
|
||||
@Serializable @SerialName("duplicateChatRelayAddress") data class DuplicateChatRelayAddress(val duplicateChatRelay: String, val duplicateAddress: String): UserServersError()
|
||||
|
||||
val globalError: String?
|
||||
@@ -4963,6 +4971,7 @@ sealed class UserServersError {
|
||||
is StorageMissing -> this.protocol
|
||||
is ProxyMissing -> this.protocol
|
||||
is DuplicateServer -> this.protocol
|
||||
is TooManyHosts -> this.protocol
|
||||
is DuplicateChatRelayAddress -> null
|
||||
}
|
||||
|
||||
@@ -4978,6 +4987,8 @@ sealed class UserServersError {
|
||||
is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" }
|
||||
?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing)
|
||||
|
||||
is TooManyHosts -> String.format(generalGetString(MR.strings.server_has_too_many_hosts), ServerAddress.parseServerAddress(this.tooManyHostsServer)?.hostnames?.firstOrNull() ?: this.tooManyHostsServer)
|
||||
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
@@ -7651,7 +7662,8 @@ enum class RcvSwitchStatus {
|
||||
@SerialName("switch_started") SwitchStarted,
|
||||
@SerialName("sending_qadd") SendingQADD,
|
||||
@SerialName("sending_quse") SendingQUSE,
|
||||
@SerialName("received_message") ReceivedMessage
|
||||
@SerialName("received_message") ReceivedMessage,
|
||||
@SerialName("received_qend") ReceivedQEND
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
+14
-4
@@ -119,6 +119,20 @@ fun ChatView(
|
||||
}
|
||||
chat
|
||||
} }
|
||||
if (chatsCtx.secondaryContextFilter == null) {
|
||||
// cleared when the view is gone rather than on back: Android keeps it on screen while it slides out,
|
||||
// and by then a channel being created may already be using this state
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (chatModel.chatId.value == null && chatModel.creatingChannelId.value == null) {
|
||||
chatModel.groupMembers.value = emptyList()
|
||||
chatModel.groupMembersIndexes.value = emptyMap()
|
||||
chatModel.membersLoaded.value = false
|
||||
ChannelRelaysModel.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val user = chatModel.currentUser.value
|
||||
val chatInfo = activeChat.value?.chatInfo
|
||||
if (chat == null || chatInfo == null || user == null) {
|
||||
@@ -382,10 +396,6 @@ fun ChatView(
|
||||
hideKeyboard(view)
|
||||
AudioPlayer.stop()
|
||||
chatModel.chatId.value = null
|
||||
chatModel.groupMembers.value = emptyList()
|
||||
chatModel.groupMembersIndexes.value = emptyMap()
|
||||
chatModel.membersLoaded.value = false
|
||||
ChannelRelaysModel.reset()
|
||||
},
|
||||
info = {
|
||||
if (ModalManager.end.hasModalsOpen()) {
|
||||
|
||||
+3
-1
@@ -2153,7 +2153,8 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState?
|
||||
if (!gInfo.useRelays || gInfo.membership.memberRole != GroupMemberRole.Owner ||
|
||||
gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
|
||||
) return null
|
||||
val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList()
|
||||
if (ChannelRelaysModel.groupId.value != gInfo.groupId) return null
|
||||
val relays = ChannelRelaysModel.groupRelays.toList()
|
||||
if (relays.isEmpty()) return OwnerRelayState(emptyList(), 0, 0, 0, true)
|
||||
val relayMembers = relays.map { relay ->
|
||||
relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId }
|
||||
@@ -2186,6 +2187,7 @@ private fun subscriberRelayState(chat: Chat, chatModel: ChatModel): SubscriberRe
|
||||
val connectedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connStatus == ConnStatus.Ready && it.activeConn?.connFailedErr == null }
|
||||
val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null }
|
||||
val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size
|
||||
if (total == 0 && !chatModel.membersLoaded.value) return null
|
||||
val noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total
|
||||
return SubscriberRelayState(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays)
|
||||
}
|
||||
|
||||
+7
-4
@@ -265,10 +265,13 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
|
||||
newMember
|
||||
}
|
||||
}
|
||||
chatModel.groupMembersIndexes.value = emptyMap()
|
||||
chatModel.groupMembers.value = newMembers
|
||||
chatModel.membersLoaded.value = true
|
||||
chatModel.populateGroupMembersIndexes()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (chatModel.chatId.value != groupInfo.id) return@withContext
|
||||
chatModel.groupMembersIndexes.value = emptyMap()
|
||||
chatModel.groupMembers.value = newMembers
|
||||
chatModel.membersLoaded.value = true
|
||||
chatModel.populateGroupMembersIndexes()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ fun UserAddressView(
|
||||
chatModel.updateUser(u)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
shareViaProfile.value = !on
|
||||
Log.e(TAG, "UserAddressView apiSetProfileAddress: ${e.stackTraceToString()}")
|
||||
} finally {
|
||||
progressIndicator.value = false
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
<string name="no_message_servers_configured">No message servers.</string>
|
||||
<string name="no_message_servers_configured_for_receiving">No servers to receive messages.</string>
|
||||
<string name="no_message_servers_configured_for_private_routing">No servers for private message routing.</string>
|
||||
<string name="server_has_too_many_hosts">Server %1$s has more than two host names.</string>
|
||||
<string name="no_media_servers_configured">No media & file servers.</string>
|
||||
<string name="no_media_servers_configured_for_sending">No servers to send files.</string>
|
||||
<string name="no_media_servers_configured_for_private_routing">No servers to receive files.</string>
|
||||
@@ -255,6 +256,7 @@
|
||||
<string name="error_preparing_contact">Error opening chat</string>
|
||||
<string name="error_preparing_group">Error opening group</string>
|
||||
<string name="error_changing_user">Error changing profile</string>
|
||||
<string name="error_saving_profile">Error saving profile</string>
|
||||
|
||||
<!-- background service notice - SimpleXAPI.kt -->
|
||||
<string name="icon_descr_instant_notifications">Instant notifications</string>
|
||||
|
||||
+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: c0a1b7bd2d79c4af5163d9a863cb0780f556d7a9
|
||||
tag: e24d537eaaa32230eec1fefcd957d474d7b8d4d5
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -17,3 +17,5 @@ The oldest human freedom — to speak to another person without being watched
|
||||
Because we destroyed the power to know who you are. So that your power can never be taken.
|
||||
|
||||
Be free in your network.
|
||||
|
||||
[Watch the film](https://www.youtube.com/watch?v=k-GhLNZm8G0)
|
||||
|
||||
@@ -325,9 +325,6 @@
|
||||
"introInvitation": {
|
||||
"properties": {
|
||||
"groupConnReq": {"ref": "connReqUri"}
|
||||
},
|
||||
"optionalProperties": {
|
||||
"directConnReq": {"ref": "connReqUri"}
|
||||
}
|
||||
},
|
||||
"groupMemberRole": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."c0a1b7bd2d79c4af5163d9a863cb0780f556d7a9" = "0l10gc46l1i1wwalapg4wa0y1x3j4l4465242701k9lvvgywjh9z";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."e24d537eaaa32230eec1fefcd957d474d7b8d4d5" = "1ximwqi1jaiq1ml05y0mcsrgvabhp5is1ry2h91w5jjarjb1a5fj";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
||||
@@ -295,7 +295,13 @@ data ProofPresHeader
|
||||
| PHFileDescr {chatBinding :: ByteString, fileSize :: Int64, descrHash :: ByteString, fileExpires :: Maybe UTCTime}
|
||||
| PHUnknown Char ByteString
|
||||
deriving (Eq, Show)
|
||||
deriving (ToJSON, FromJSON) via (StrJSON "ProofPresHeader" ProofPresHeader)
|
||||
|
||||
instance ToJSON ProofPresHeader where
|
||||
toJSON = toJSON . BBSPresHeader . strEncode
|
||||
toEncoding = toEncoding . BBSPresHeader . strEncode
|
||||
|
||||
instance FromJSON ProofPresHeader where
|
||||
parseJSON v = parseJSON v >>= \(BBSPresHeader ph) -> either fail pure $ strDecode ph
|
||||
|
||||
instance StrEncoding ProofPresHeader where
|
||||
strEncode = \case
|
||||
|
||||
@@ -75,10 +75,10 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha
|
||||
noMaintenance
|
||||
img_ <- mapM loadImageFile userImageFile
|
||||
createActiveUser cc coreOptions createBot userDisplayName img_
|
||||
Just u@User {localDisplayName} -> do
|
||||
Just u@User {profile = LocalProfile {displayName}} -> do
|
||||
forM_ userDisplayName $ \name ->
|
||||
when (localDisplayName /= name) $ do
|
||||
putStrLn $ "Active user display name " <> show localDisplayName <> " does not match --user-display-name " <> show name
|
||||
when (displayName /= name) $ do
|
||||
putStrLn $ "Active user display name " <> show displayName <> " does not match --user-display-name " <> show name
|
||||
exitFailure
|
||||
-- --user-image-file only applies when the profile is created; ignore it for an existing user
|
||||
forM_ userImageFile $ \_ ->
|
||||
|
||||
@@ -177,7 +177,9 @@ checkProfileImageSize = mapM_ $ \(ImageData t) ->
|
||||
in when (size > maxProfileImageSize) $ throwCmdError $ "Profile image is too large " <> show size
|
||||
|
||||
checkProfileSize :: Profile -> CM ()
|
||||
checkProfileSize p = checkInfoSize "Profile" (XInfo p Nothing)
|
||||
checkProfileSize p@Profile {contactDomain} = checkInfoSize "Profile" (XInfo p' Nothing)
|
||||
where
|
||||
p' = (p :: Profile) {badge = Nothing, contactDomain = (\d -> d {proof = Nothing} :: SimplexDomainClaim) <$> contactDomain}
|
||||
|
||||
checkGroupProfileSize :: GroupProfile -> CM ()
|
||||
checkGroupProfileSize p = checkInfoSize "Group profile" (XGrpInfo p)
|
||||
@@ -463,7 +465,7 @@ processChatCommand cxt nm = \case
|
||||
p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile
|
||||
u <- asks currentUser
|
||||
users <- withFastStore' getUsers
|
||||
forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do
|
||||
forM_ users $ \User {profile = LocalProfile {displayName = n}, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do
|
||||
when (n == displayName) . throwChatError $
|
||||
if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""}
|
||||
when (isTrue userChatRelay && isTrue userChatRelay') $ throwChatError CEChatRelayExists
|
||||
@@ -1606,8 +1608,8 @@ processChatCommand cxt nm = \case
|
||||
APICallStatus contactId receivedStatus ->
|
||||
withCurrentCall contactId $ \user ct call ->
|
||||
updateCallItemStatus user ct call receivedStatus Nothing $> Just call
|
||||
APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile)
|
||||
APISetUserDomain userId strDomain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do
|
||||
APIUpdateProfile userId profile -> withUserProfileLock "updateProfile" $ withUserId userId (`updateProfile` profile)
|
||||
APISetUserDomain userId strDomain_ -> withUserProfileLock "setUserDomain" $ withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do
|
||||
let domain_ = unStrJSON <$> strDomain_
|
||||
if (claimDomain <$> contactDomain) == domain_
|
||||
then pure $ CRUserProfileNoChange user
|
||||
@@ -1647,7 +1649,7 @@ processChatCommand cxt nm = \case
|
||||
APISetUserUIThemes uId uiThemes -> withUser $ \user@User {userId} -> do
|
||||
user'@User {userId = uId'} <- withFastStore $ \db -> do
|
||||
user' <- getUser db uId
|
||||
liftIO $ setUserUIThemes db user uiThemes
|
||||
liftIO $ setUserUIThemes db user' uiThemes
|
||||
pure user'
|
||||
when (userId == uId') $ chatWriteVar currentUser $ Just (user :: User) {uiThemes}
|
||||
ok user'
|
||||
@@ -2490,12 +2492,12 @@ processChatCommand cxt nm = \case
|
||||
pure $ CRUserContactLinkCreated user ccLink''
|
||||
CreateMyAddress ratchetKeys_ -> withUser $ \User {userId} ->
|
||||
processChatCommand cxt nm $ APICreateMyAddress userId Nothing ratchetKeys_
|
||||
APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do
|
||||
APIDeleteMyAddress userId -> withUserProfileLock "deleteMyAddress" $ withUserId userId $ \user@User {profile = p} -> do
|
||||
conn <- withFastStore $ \db -> getUserAddressConnection db cxt user
|
||||
withChatLock "deleteMyAddress" $ do
|
||||
deleteAgentConnectionAsync $ aConnId conn
|
||||
withFastStore' (`deleteUserAddress` user)
|
||||
let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing}
|
||||
let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing, contactDomain = Nothing}
|
||||
r <- updateProfile_ user p' False $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing
|
||||
let user' = case r of
|
||||
CRUserProfileUpdated u' _ _ _ -> u'
|
||||
@@ -2513,10 +2515,10 @@ processChatCommand cxt nm = \case
|
||||
CRUserContactLink user <$> (withFastStore (`getUserAddress` user) >>= setMyAddressData False pqInitKeys user)
|
||||
APIRotateAddressRatchetKeys userId -> withUserId' userId $ \user ->
|
||||
CRUserContactLink user <$> (withFastStore (`getUserAddress` user) >>= setMyAddressData True (Just IKUsePQ) user)
|
||||
APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do
|
||||
let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing}
|
||||
APISetProfileAddress userId False -> withUserProfileLock "setProfileAddress" $ withUserId userId $ \user@User {profile = p} -> do
|
||||
let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing, contactDomain = Nothing}
|
||||
updateProfile_ user p' True $ withFastStore' $ \db -> setUserProfileContactLink db user Nothing
|
||||
APISetProfileAddress userId True -> withUserId userId $ \user@User {profile = p} -> do
|
||||
APISetProfileAddress userId True -> withUserProfileLock "setProfileAddress" $ withUserId userId $ \user@User {profile = p} -> do
|
||||
ucl <- withFastStore (`getUserAddress` user)
|
||||
-- TODO [short links] replace with short links
|
||||
let p' = (fromLocalProfile p :: Profile) {contactLink = Just $ profileContactLink ucl}
|
||||
@@ -3601,23 +3603,23 @@ processChatCommand cxt nm = \case
|
||||
-- after the write, so the pass it signals arms a wake for the snooze rather than raising again
|
||||
lift $ startBadgeWork user
|
||||
badgeStateResponse user
|
||||
SetBotCommands commands -> withUser $ \user@User {profile} -> do
|
||||
SetBotCommands commands -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do
|
||||
let LocalProfile {preferences} = profile
|
||||
prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands}
|
||||
p = (fromLocalProfile profile :: Profile) {preferences = prefs, peerType = Just CPTBot}
|
||||
updateProfile user p
|
||||
UpdateProfile displayName shortDescr -> withUser $ \user@User {profile} -> do
|
||||
UpdateProfile displayName shortDescr -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do
|
||||
let p = (fromLocalProfile profile :: Profile) {displayName, shortDescr, fullName = ""}
|
||||
updateProfile user p
|
||||
UpdateProfileImage image -> withUser $ \user@User {profile} -> do
|
||||
UpdateProfileImage image -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do
|
||||
let p = (fromLocalProfile profile :: Profile) {image}
|
||||
updateProfile user p
|
||||
UpdateProfileImageFromFile path -> withUser $ \user@User {profile} -> do
|
||||
UpdateProfileImageFromFile path -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do
|
||||
img <- readProfileImageFile path
|
||||
let p = (fromLocalProfile profile :: Profile) {image = Just img}
|
||||
updateProfile user p
|
||||
ShowProfileImage -> withUser $ \user@User {profile} -> pure $ CRUserProfileImage user $ fromLocalProfile profile
|
||||
SetUserFeature (ACF f) allowed -> withUser $ \user@User {profile} -> do
|
||||
SetUserFeature (ACF f) allowed -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do
|
||||
let p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference f (Just allowed) $ preferences' user}
|
||||
updateProfile user p
|
||||
SetContactFeature (ACF f) cName allowed_ -> withUser $ \user -> do
|
||||
@@ -3635,7 +3637,7 @@ processChatCommand cxt nm = \case
|
||||
case memberAdmission of
|
||||
Nothing -> p {memberAdmission = Just (emptyGroupMemberAdmission :: GroupMemberAdmission) {review = reviewAdmissionApplication}}
|
||||
Just ma -> p {memberAdmission = Just (ma :: GroupMemberAdmission) {review = reviewAdmissionApplication}}
|
||||
SetUserTimedMessages onOff -> withUser $ \user@User {profile} -> do
|
||||
SetUserTimedMessages onOff -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do
|
||||
let allowed = if onOff then FAYes else FANo
|
||||
pref = TimedMessagesPreference allowed Nothing
|
||||
p = (fromLocalProfile profile :: Profile) {preferences = Just . setPreference' SCFTimedMessages (Just pref) $ preferences' user}
|
||||
@@ -3704,6 +3706,7 @@ processChatCommand cxt nm = \case
|
||||
CLContactRequest crId -> "ContactRequest " <> tshow crId
|
||||
CLFile fId -> "File " <> tshow fId
|
||||
CLBadgeUser uId -> "BadgeUser " <> tshow uId
|
||||
CLUserProfile -> "UserProfile"
|
||||
DebugEvent event -> toView event >> ok_
|
||||
GetAgentSubsTotal userId -> withUserId userId $ \user -> do
|
||||
users <- withStore' $ \db -> getUsers db
|
||||
@@ -4028,20 +4031,29 @@ processChatCommand cxt nm = \case
|
||||
when (fileSize > maxSndXFTPFileSize lims now sndBadge) $ throwChatError $ CEFileSize f
|
||||
pure fileSize
|
||||
updateProfile :: User -> Profile -> CM ChatResponse
|
||||
updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p'
|
||||
updateProfile user@User {profile = LocalProfile {contactDomain}} p = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p'
|
||||
where
|
||||
p' = (p :: Profile) {contactDomain}
|
||||
updateProfile_ :: User -> Profile -> Bool -> CM User -> CM ChatResponse
|
||||
updateProfile_ user@User {profile = p@LocalProfile {displayName = n}} p'@Profile {displayName = n', image = img'} shouldUpdateAddressData updateUser
|
||||
| p' == fromLocalProfile p = pure $ CRUserProfileNoChange user
|
||||
| otherwise = do
|
||||
when (n /= n') $ checkValidName n'
|
||||
when (n /= n') $ do
|
||||
checkValidName n'
|
||||
users <- withFastStore' getUsers
|
||||
forM_ users $ \User {profile = LocalProfile {displayName}, activeUser, viewPwdHash} ->
|
||||
when (displayName == n') . throwChatError $
|
||||
if activeUser || isNothing viewPwdHash then CEUserExists n' else CEInvalidDisplayName {displayName = n', validName = ""}
|
||||
checkProfileImageSize img'
|
||||
checkProfileSize p'
|
||||
when shouldUpdateAddressData $ do
|
||||
ts <- liftIO getCurrentTime
|
||||
setMyAddressData' (user :: User) {profile = updatedUserProfile user p' ts}
|
||||
-- read contacts before user update to correctly merge preferences
|
||||
contacts <- withFastStore' $ \db -> getUserContacts db cxt user
|
||||
user' <- updateUser
|
||||
asks currentUser >>= atomically . (`writeTVar` Just user')
|
||||
withChatLock "updateProfile" $ do
|
||||
when shouldUpdateAddressData $ setMyAddressData' user'
|
||||
summary <- sendUpdateToContacts user' contacts
|
||||
pure $ CRUserProfileUpdated user' (fromLocalProfile p) p' summary
|
||||
where
|
||||
@@ -5802,7 +5814,7 @@ presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now
|
||||
| otherwise = do
|
||||
cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId)
|
||||
forM_ cred_ $ \cred@(BadgeCredential _ _ _ info) ->
|
||||
when (presentDue cred) $ do
|
||||
when (presentDue cred) $ withUserProfileLock "presentIssuedBadge" $ do
|
||||
user' <- withStore $ \db -> setUserBadge db user (Just $ OwnBadge cred (mkBadgeStatus now (Just True) info))
|
||||
presentUserBadgeToContacts user'
|
||||
where
|
||||
@@ -5815,7 +5827,7 @@ presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now
|
||||
retireExpiredBadge :: User -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM Bool
|
||||
retireExpiredBadge user UserBadgePurchase {badgePurchaseId, shown} now balance
|
||||
| not (shown && L.paidThrough balance <= now) = pure False
|
||||
| otherwise = do
|
||||
| otherwise = withUserProfileLock "retireExpiredBadge" $ do
|
||||
user' <- withStore $ \db -> do
|
||||
liftIO $ clearShownBadge db user badgePurchaseId
|
||||
setUserBadge db user Nothing
|
||||
@@ -6858,8 +6870,11 @@ displayNameP_ = quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',')
|
||||
refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\''
|
||||
|
||||
mkValidName :: String -> String
|
||||
mkValidName = dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int)
|
||||
mkValidName = noLdnSuffix . dropWhileEnd isSpace . take 50 . reverse . fst3 . foldl' addChar ("", '\NUL', 0 :: Int)
|
||||
where
|
||||
noLdnSuffix s = case break (== '_') (reverse s) of
|
||||
(ds@(_ : _), '_' : rest@(_ : _)) | all isDigit ds -> reverse rest <> ('-' : reverse ds)
|
||||
_ -> s
|
||||
fst3 (x, _, _) = x
|
||||
addChar (r, prev, punct) c' = if validChar then (c : r, c, punct') else (r, prev, punct)
|
||||
where
|
||||
|
||||
@@ -160,6 +160,10 @@ withFileLock :: Text -> Int64 -> CM a -> CM a
|
||||
withFileLock name = withEntityLock name . CLFile
|
||||
{-# INLINE withFileLock #-}
|
||||
|
||||
withUserProfileLock :: Text -> CM a -> CM a
|
||||
withUserProfileLock name = withEntityLock name CLUserProfile
|
||||
{-# INLINE withUserProfileLock #-}
|
||||
|
||||
useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p)
|
||||
useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains =
|
||||
fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains
|
||||
@@ -1414,7 +1418,7 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
|
||||
-- (regular groups only; never channels) is an authored element -- all batch together in order.
|
||||
let fwdEls = map (uncurry encodeFwdElement) (concat fwdMsgsByItem)
|
||||
welcomeEl <- welcomeElement
|
||||
let (batches, dropped) = batchElements maxEncodedMsgLength (fwdEls <> maybe [] (: []) welcomeEl)
|
||||
let (batches, dropped) = batchElements maxForwardBatchLength (fwdEls <> maybe [] (: []) welcomeEl)
|
||||
when (dropped > 0) $ toView $ CEvtChatErrors [ChatError $ CEInternalError ("sendHistory: dropped " <> show dropped <> " oversized history messages")]
|
||||
forM_ batches $ \body ->
|
||||
void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)]
|
||||
@@ -1552,9 +1556,7 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
|
||||
pure $ map ((,) fwd) (contentVM : fileDescrVMs)
|
||||
|
||||
memberShortenedName :: GroupMember -> ContactName
|
||||
memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}}
|
||||
| T.length displayName <= 16 = displayName
|
||||
| otherwise = T.take 16 displayName `T.snoc` '…'
|
||||
memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}} = fwdMemberName displayName
|
||||
|
||||
-- the description proof travels on the last part, so that part leaves room for it
|
||||
badgeDescrPartSize :: Int
|
||||
@@ -2457,9 +2459,10 @@ compressToLimit maxLen s
|
||||
s' = compressedBatchMsgBody_ s
|
||||
|
||||
compressConnInfo :: PQSupport -> MsgBody -> CM MsgBody
|
||||
compressConnInfo pqSup = compressToLimit $ case pqSup of
|
||||
PQSupportOn -> maxEncodedInfoLengthPQ
|
||||
PQSupportOff -> maxEncodedInfoLength
|
||||
compressConnInfo pqSup = compressToLimit $ e2eEncConnInfoLength pqSup - 2 - maxReplyQueueFraming
|
||||
|
||||
maxReplyQueueFraming :: Int
|
||||
maxReplyQueueFraming = 256
|
||||
|
||||
encodeConnInfo :: MsgEncodingI e => ChatMsgEvent e -> CM ByteString
|
||||
encodeConnInfo = encodeConnInfoPQ PQSupportOff
|
||||
@@ -2468,7 +2471,7 @@ encodeConnInfoPQ :: MsgEncodingI e => PQSupport -> ChatMsgEvent e -> CM ByteStri
|
||||
encodeConnInfoPQ pqSup chatMsgEvent = do
|
||||
cxt <- chatStoreCxt
|
||||
let info = ChatMessage {chatVRange = vr cxt, msgId = Nothing, chatMsgEvent}
|
||||
case encodeChatMessage maxEncodedInfoLength info of
|
||||
case encodeChatMessage maxEncodedProfileMsgLength info of
|
||||
ECMEncoded connInfo -> compressConnInfo pqSup connInfo
|
||||
ECMLarge -> throwChatError $ CEException "large info"
|
||||
|
||||
@@ -2477,7 +2480,7 @@ encodeSignedConnInfo :: MsgEncodingI e => PQSupport -> MsgSigning -> ChatMsgEven
|
||||
encodeSignedConnInfo pqSup signing chatMsgEvent = do
|
||||
vr <- chatVersionRange
|
||||
let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent}
|
||||
case encodeChatMessage maxEncodedInfoLength info of
|
||||
case encodeChatMessage maxEncodedProfileMsgLength info of
|
||||
ECMEncoded body -> compressConnInfo pqSup $ encodeBatchElement (Just $ signChatMsgBody signing body) body
|
||||
ECMLarge -> throwChatError $ CEException "large signed info"
|
||||
|
||||
|
||||
@@ -774,7 +774,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
hostConnId <- withStore $ \db -> do
|
||||
liftIO $ setConnConnReqInv db user connId cReq
|
||||
getHostConnId db user groupId
|
||||
sendXGrpMemInv hostConnId Nothing XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq}
|
||||
sendXGrpMemInv hostConnId XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq}
|
||||
_ -> throwChatError $ CECommandError "unexpected cmdFunction"
|
||||
CRContactUri _ _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type"
|
||||
CONF confId _pqSupport _ connInfo -> do
|
||||
@@ -3304,10 +3304,10 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId (chatHasNtfs chatSettings) SCMInvitation CR.IKPQOff True subMode
|
||||
_ -> messageError "x.grp.mem.intro can be only sent by host member"
|
||||
|
||||
sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> CM ()
|
||||
sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do
|
||||
sendXGrpMemInv :: Int64 -> XGrpMemIntroCont -> CM ()
|
||||
sendXGrpMemInv hostConnId XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do
|
||||
hostConn <- withStore $ \db -> getConnectionById db cxt user hostConnId
|
||||
let msg = XGrpMemInv memberId IntroInvitation {groupConnReq, directConnReq}
|
||||
let msg = XGrpMemInv memberId IntroInvitation {groupConnReq}
|
||||
void $ sendDirectMemberMessage hostConn msg groupId
|
||||
withStore' $ \db -> updateGroupMemberStatusById db userId groupMemberId GSMemIntroInvited
|
||||
|
||||
@@ -3321,7 +3321,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
_ -> messageError "x.grp.mem.inv can be only sent by invitee member"
|
||||
|
||||
xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM ()
|
||||
xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _ _) IntroInvitation {groupConnReq, directConnReq} = do
|
||||
xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _ _) IntroInvitation {groupConnReq} = do
|
||||
let GroupMember {memberId = membershipMemId} = membership
|
||||
checkHostRole m memRole
|
||||
toMember <- withStore $ \db -> do
|
||||
@@ -3341,20 +3341,16 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
liftIO $ updateGroupMemberStatus db userId toMember newMemberStatus
|
||||
pure toMember
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
-- [incognito] send membership incognito profile, create direct connection as incognito
|
||||
-- [incognito] send membership incognito profile
|
||||
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership
|
||||
dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile
|
||||
-- [async agent commands] no continuation needed, but commands should be asynchronous for stability
|
||||
let enableNtfsGrp = chatHasNtfs chatSettings
|
||||
groupConnIds@(gCmdId, gAcId) <- prepareAgentJoin user Nothing enableNtfsGrp groupConnReq
|
||||
directConnIds <- mapM (prepareAgentJoin user Nothing True) directConnReq
|
||||
let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo
|
||||
mcvr = maybe chatInitialVRange fromChatVRange memChatVRange
|
||||
let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange
|
||||
chatV = vr cxt `peerConnChatVersion` mcvr
|
||||
withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode
|
||||
withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds subMode
|
||||
joinAgentConnectionAsync gCmdId False gAcId enableNtfsGrp groupConnReq dm subMode
|
||||
forM_ ((,) <$> directConnIds <*> directConnReq) $ \((dCmdId, dAcId), dcr) ->
|
||||
joinAgentConnectionAsync dCmdId False dAcId True dcr dm subMode
|
||||
|
||||
-- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted
|
||||
-- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one
|
||||
@@ -3937,7 +3933,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing
|
||||
|
||||
xGrpMsgForward :: GroupInfoKeys -> Maybe GroupChatScopeInfo -> GroupMember -> GrpMsgForward -> ParsedMsg 'Json -> UTCTime -> CM ()
|
||||
xGrpMsgForward g@(GIK gInfo _) scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do
|
||||
xGrpMsgForward g@(GIK gInfo@GroupInfo {membership} _) scopeInfo m@GroupMember {localDisplayName} GrpMsgForward {fwdSender, fwdBrokerTs = msgTs} parsedMsg@(ParsedMsg _ _ chatMsg@ChatMessage {chatMsgEvent}) brokerTs = do
|
||||
unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName)
|
||||
case fwdSender of
|
||||
FwdMember memberId memberName -> do
|
||||
@@ -3945,6 +3941,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
let allowCreate = toCMEventTag chatMsgEvent /= XGrpLeave_
|
||||
withStore (\db -> getCreateUnknownGMByMemberId db cxt user gInfo memberId memberName unknownRole allowCreate) >>= \case
|
||||
Just (author, unknown)
|
||||
| not (useRelays' gInfo) && groupMemberId' author == groupMemberId' membership ->
|
||||
messageError $ "x.grp.msg.forward: content attributed to own membership, forwarder " <> tshow (groupMemberId' m) <> ", event " <> tshow (toCMEventTag chatMsgEvent)
|
||||
| memberRemoved author ->
|
||||
logInfo $ "x.grp.msg.forward: ignoring content from removed member, group " <> tshow (groupId' gInfo) <> ", member " <> safeDecodeUtf8 (strEncode memberId) <> ", event " <> tshow (toCMEventTag chatMsgEvent)
|
||||
| not (useRelays' gInfo) && not (expectedForwarder author) ->
|
||||
@@ -4160,7 +4158,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do
|
||||
withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive"
|
||||
| otherwise ->
|
||||
withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do
|
||||
let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxEncodedMsgLength nextTasks
|
||||
let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxForwardBatchLength nextTasks
|
||||
senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks
|
||||
withStore' $ \db -> do
|
||||
forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body
|
||||
@@ -4290,8 +4288,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
|
||||
else do
|
||||
-- all members' profiles disseminate; privileged key/role come from the roster, not here
|
||||
let (encoderErrs, validLabeled) = partitionEithers [(\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s | (s, _) <- senders]
|
||||
(extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled
|
||||
(overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled
|
||||
(extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxForwardBatchLength body validLabeled
|
||||
(overflowBatches', large2) = batchProfiles maxForwardBatchLength overflowLabeled
|
||||
packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2]
|
||||
allErrs = encoderErrs <> packerErrs
|
||||
unless (null allErrs) $ do
|
||||
|
||||
@@ -55,7 +55,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
import qualified Simplex.Messaging.Crypto.File as CF
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, sumTypeJSON)
|
||||
import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer)
|
||||
import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer, unMsgId)
|
||||
import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>))
|
||||
|
||||
data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection
|
||||
@@ -1228,7 +1228,7 @@ msgMetaToJson MsgMeta {integrity, recipient = (rcvId, rcvTs), broker = (serverId
|
||||
{ integrity = (decodeLatin1 . strEncode) integrity,
|
||||
rcvId,
|
||||
rcvTs,
|
||||
serverId = (decodeLatin1 . B64.encode) serverId,
|
||||
serverId = (decodeLatin1 . B64.encode . unMsgId) serverId,
|
||||
serverTs,
|
||||
sndId
|
||||
}
|
||||
|
||||
@@ -525,6 +525,7 @@ data UserServersError
|
||||
| USEStorageMissing {protocol :: AProtocolType, user :: Maybe User}
|
||||
| USEProxyMissing {protocol :: AProtocolType, user :: Maybe User}
|
||||
| USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost}
|
||||
| USETooManyHosts {protocol :: AProtocolType, tooManyHostsServer :: Text}
|
||||
| USEDuplicateChatRelayAddress {duplicateChatRelay :: Text, duplicateAddress :: ShortLinkContact}
|
||||
deriving (Show)
|
||||
|
||||
@@ -547,13 +548,16 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other
|
||||
noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss
|
||||
srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted
|
||||
serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError]
|
||||
serverErrs p uss = mapMaybe duplicateErr_ srvs
|
||||
serverErrs p uss = mapMaybe duplicateErr_ srvs <> hostsErrs
|
||||
where
|
||||
p' = AProtocolType p
|
||||
srvs = filter (\(AUS _ UserServer {deleted}) -> not deleted) $ userServers p uss
|
||||
duplicateErr_ (AUS _ srv@UserServer {server}) =
|
||||
USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server)
|
||||
<$> find (`S.member` duplicateHosts) (srvHost srv)
|
||||
hostsErrs = case p of
|
||||
SPSMP -> [USETooManyHosts p' (safeDecodeUtf8 $ strEncode server) | AUS _ srv@UserServer {server} <- srvs, L.length (srvHost srv) > 2]
|
||||
_ -> []
|
||||
duplicateHosts = snd $ foldl' addDuplicate (S.empty, S.empty) allHosts
|
||||
allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs
|
||||
userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p]
|
||||
|
||||
@@ -924,6 +924,14 @@ $(JQ.deriveJSON defaultJSON ''MsgContainer)
|
||||
maxEncodedMsgLength :: Int
|
||||
maxEncodedMsgLength = 15602
|
||||
|
||||
maxForwardBatchLength :: Int
|
||||
maxForwardBatchLength = maxEncodedMsgLength + 161
|
||||
|
||||
fwdMemberName :: ContactName -> ContactName
|
||||
fwdMemberName displayName
|
||||
| T.length displayName <= 16 = displayName
|
||||
| otherwise = T.take 16 displayName `T.snoc` '…'
|
||||
|
||||
-- maxEncodedMsgLength - 2222, see e2eEncUserMsgLength in agent
|
||||
maxEncodedMsgLengthPQ :: Int
|
||||
maxEncodedMsgLengthPQ = 13380
|
||||
@@ -965,8 +973,8 @@ rosterBlobP = do
|
||||
maxEncodedInfoLength :: Int
|
||||
maxEncodedInfoLength = 14694
|
||||
|
||||
maxEncodedInfoLengthPQ :: Int
|
||||
maxEncodedInfoLengthPQ = 10968 -- maxEncodedInfoLength - 3726, see e2eEncConnInfoLength in agent
|
||||
maxEncodedProfileMsgLength :: Int
|
||||
maxEncodedProfileMsgLength = 16384
|
||||
|
||||
data EncodedChatMessage = ECMEncoded ByteString | ECMLarge
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ getMsgDeliveryTask_ db taskId =
|
||||
toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, Binary msgBody, chatBinding_, sigs_, BI showGroupAsSender)) =
|
||||
case (toJobScope_ jobScopeRow, J.eitherDecodeStrict' msgBody) of
|
||||
(Just jobScope, Right chatMsg) ->
|
||||
let fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId senderMemberName
|
||||
let fwdSender = if showGroupAsSender && isNothing chatBinding_ then FwdChannel else FwdMember senderMemberId (fwdMemberName senderMemberName)
|
||||
-- Re-parsed from msg_body: validates stored content against current code.
|
||||
-- Signed: original bytes preserved (re-encoding would invalidate signature).
|
||||
-- Unsigned: re-encoded from parsed ChatMessage on forward (sanitizes content).
|
||||
|
||||
@@ -2645,42 +2645,12 @@ createIntroReMemberConn
|
||||
liftIO $ setCommandConnId db user groupCmdId groupConnId
|
||||
pure (reMember :: GroupMember) {activeConn = Just conn}
|
||||
|
||||
createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO ()
|
||||
createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do
|
||||
createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> SubscriptionMode -> IO ()
|
||||
createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId} chatV mcvr (groupCmdId, groupAgentConnId) subMode = do
|
||||
let cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn
|
||||
currentTs <- getCurrentTime
|
||||
Connection {connId = groupConnId} <- createMemberConnection_ db userId groupMemberId groupAgentConnId chatV mcvr viaContactId cLevel currentTs subMode
|
||||
setCommandConnId db user groupCmdId groupConnId
|
||||
forM_ directConnIds $ \(directCmdId, directAgentConnId) -> do
|
||||
Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId ConnNew chatV mcvr viaContactId Nothing customUserProfileId cLevel currentTs subMode PQSupportOff
|
||||
setCommandConnId db user directCmdId directConnId
|
||||
contactId <- createMemberContact_ directConnId currentTs
|
||||
updateMember_ contactId currentTs
|
||||
where
|
||||
createMemberContact_ :: Int64 -> UTCTime -> IO Int64
|
||||
createMemberContact_ connId ts = do
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO contacts (contact_profile_id local_display_name, user_id, created_at, updated_at, chat_ts)
|
||||
SELECT contact_profile_id, ?, ?, ?, ?, ?
|
||||
FROM group_members
|
||||
WHERE group_member_id = ?
|
||||
|]
|
||||
(localDisplayName, userId, ts, ts, ts, groupMemberId)
|
||||
contactId <- insertedRowId db
|
||||
DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, ts, connId)
|
||||
pure contactId
|
||||
updateMember_ :: Int64 -> UTCTime -> IO ()
|
||||
updateMember_ contactId ts =
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE group_members
|
||||
SET contact_id = ?, updated_at = ?
|
||||
WHERE group_member_id = ?
|
||||
|]
|
||||
(contactId, ts, groupMemberId)
|
||||
|
||||
createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection
|
||||
createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode =
|
||||
|
||||
@@ -44,6 +44,7 @@ module Simplex.Chat.Store.Profiles
|
||||
updateUserAutoAcceptMemberContacts,
|
||||
updateUserAutoAcceptGroupInvitations,
|
||||
updateUserProfile,
|
||||
updatedUserProfile,
|
||||
setUserBadge,
|
||||
setUserProfileContactLink,
|
||||
getUserContactProfiles,
|
||||
@@ -169,7 +170,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di
|
||||
(profileId, displayName, userId, BI True, currentTs, currentTs, currentTs)
|
||||
contactId <- insertedRowId db
|
||||
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
|
||||
pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing)
|
||||
pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order, displayName) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing)
|
||||
|
||||
-- TODO [mentions]
|
||||
getUsersInfo :: DB.Connection -> IO [UserInfo]
|
||||
@@ -344,21 +345,15 @@ updateUserProfile db user p'
|
||||
currentTs <- getCurrentTime
|
||||
updateUserProfileFields_' db userId profileId p' currentTs
|
||||
userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs
|
||||
pure user {profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'}
|
||||
pure user {profile = updatedUserProfile user p' currentTs, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'}
|
||||
| otherwise =
|
||||
checkConstraint SEDuplicateName . liftIO $ do
|
||||
checkConstraint SEDuplicateName . ExceptT . withLocalDisplayName db userId newName $ \newLDN -> do
|
||||
currentTs <- getCurrentTime
|
||||
-- Insert first: checkConstraint returns the violation as a value, so the
|
||||
-- transaction commits, keeping whatever ran before the failing insert.
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)"
|
||||
(newName, newName, userId, currentTs, currentTs)
|
||||
DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId)
|
||||
DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newLDN, currentTs, userId)
|
||||
userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs
|
||||
updateUserProfileFields_' db userId profileId p' currentTs
|
||||
updateContactLDN_ db user userContactId localDisplayName newName currentTs
|
||||
pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'}
|
||||
updateContactLDN_ db user userContactId localDisplayName newLDN currentTs
|
||||
pure $ Right user {localDisplayName = newLDN, profile = updatedUserProfile user p' currentTs, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'}
|
||||
where
|
||||
updateUserMemberProfileUpdatedAt_ currentTs
|
||||
| userMemberProfileChanged = do
|
||||
@@ -366,10 +361,14 @@ updateUserProfile db user p'
|
||||
pure $ Just currentTs
|
||||
| otherwise = pure userMemberProfileUpdatedAt
|
||||
userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || desc' /= description || img' /= image
|
||||
User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, localBadge, localAlias}, userMemberProfileUpdatedAt} = user
|
||||
User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image}, userMemberProfileUpdatedAt} = user
|
||||
Profile {displayName = newName, fullName = fn', shortDescr = d', description = desc', image = img', preferences} = p'
|
||||
fullPreferences = fullPreferences' preferences
|
||||
|
||||
updatedUserProfile :: User -> Profile -> UTCTime -> LocalProfile
|
||||
updatedUserProfile User {profile = LocalProfile {profileId, localBadge, localAlias}} p' ts =
|
||||
(toLocalProfile profileId p' localAlias ts (Just False) Nothing) {localBadge}
|
||||
|
||||
-- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge)
|
||||
updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO ()
|
||||
updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType} updatedAt =
|
||||
@@ -411,19 +410,20 @@ setUserSimplexDomain db user@User {userId, profile = p@LocalProfile {profileId}}
|
||||
pure (user :: User) {profile = p {contactDomain = mkDomainClaim <$> domain_}}
|
||||
|
||||
setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User
|
||||
setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do
|
||||
setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId, contactDomain}} ucl_ = do
|
||||
ts <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE contact_profiles
|
||||
SET contact_link = ?, updated_at = ?
|
||||
SET contact_link = ?, contact_domain = ?, updated_at = ?
|
||||
WHERE user_id = ? AND contact_profile_id = ?
|
||||
|]
|
||||
(contactLink, ts, userId, profileId)
|
||||
pure (user :: User) {profile = p {contactLink}}
|
||||
(contactLink, claimDomain <$> contactDomain', ts, userId, profileId)
|
||||
pure (user :: User) {profile = p {contactLink, contactDomain = contactDomain'}}
|
||||
where
|
||||
contactLink = profileContactLink <$> ucl_
|
||||
contactDomain' = if isJust contactLink then contactDomain else Nothing
|
||||
|
||||
-- only used in tests
|
||||
getUserContactProfiles :: DB.Connection -> User -> IO [Profile]
|
||||
|
||||
@@ -1391,6 +1391,10 @@ Query: SELECT conn_id FROM connections WHERE user_id = ?
|
||||
Plan:
|
||||
SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?)
|
||||
|
||||
Query: SELECT conn_id FROM rcv_queues WHERE switch_status = ? AND deleted = 0
|
||||
Plan:
|
||||
SCAN rcv_queues
|
||||
|
||||
Query: SELECT conn_id, rc_verify_code_ad, rc_verify_code_pq, CASE WHEN rc_verify_code_ad IS NULL THEN ratchet_state END FROM ratchets WHERE conn_id = ?
|
||||
Plan:
|
||||
SEARCH ratchets USING PRIMARY KEY (conn_id=?)
|
||||
|
||||
@@ -5411,7 +5411,7 @@ SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
UPDATE contact_profiles
|
||||
SET contact_link = ?, updated_at = ?
|
||||
SET contact_link = ?, contact_domain = ?, updated_at = ?
|
||||
WHERE user_id = ? AND contact_profile_id = ?
|
||||
|
||||
Plan:
|
||||
@@ -6432,7 +6432,7 @@ Plan:
|
||||
SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6445,7 +6445,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6459,7 +6459,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6473,7 +6473,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6488,7 +6488,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6502,7 +6502,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6516,7 +6516,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6530,7 +6530,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6544,7 +6544,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -6557,7 +6557,7 @@ SEARCH uct USING INTEGER PRIMARY KEY (rowid=?)
|
||||
SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -7720,6 +7720,10 @@ Query: SELECT member_id FROM group_members WHERE local_display_name = ?
|
||||
Plan:
|
||||
SCAN group_members
|
||||
|
||||
Query: SELECT member_id FROM group_members WHERE member_category = ?
|
||||
Plan:
|
||||
SCAN group_members
|
||||
|
||||
Query: SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1
|
||||
Plan:
|
||||
SCAN group_members
|
||||
@@ -8020,6 +8024,10 @@ Query: UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user
|
||||
Plan:
|
||||
SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: UPDATE contact_profiles SET contact_domain_verified = NULL WHERE user_id = ? AND contact_profile_id = ?
|
||||
Plan:
|
||||
SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: UPDATE contact_profiles SET image = ? WHERE display_name = ?
|
||||
Plan:
|
||||
SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?)
|
||||
@@ -8212,6 +8220,10 @@ Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE g
|
||||
Plan:
|
||||
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: UPDATE group_profiles SET description = ? WHERE display_name = ?
|
||||
Plan:
|
||||
SCAN group_profiles
|
||||
|
||||
Query: UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ?
|
||||
Plan:
|
||||
SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
@@ -74,6 +74,7 @@ data ChatLockEntity
|
||||
| CLContactRequest Int64
|
||||
| CLFile Int64
|
||||
| CLBadgeUser Int64 -- one signed badge request per profile in flight
|
||||
| CLUserProfile
|
||||
deriving (Eq, Ord)
|
||||
|
||||
-- These error type constructors must be added to mobile apps
|
||||
@@ -558,7 +559,7 @@ toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, co
|
||||
userQuery :: Query
|
||||
userQuery =
|
||||
[sql|
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
|
||||
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
|
||||
FROM users u
|
||||
@@ -566,9 +567,9 @@ userQuery =
|
||||
JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id
|
||||
|]
|
||||
|
||||
toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User
|
||||
toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInv, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) =
|
||||
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes}
|
||||
toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User
|
||||
toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, localDisplayName) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInv, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) =
|
||||
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes}
|
||||
where
|
||||
profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""}
|
||||
fullPreferences = fullPreferences' userPreferences
|
||||
|
||||
@@ -1085,8 +1085,7 @@ data MemberIdRole = MemberIdRole
|
||||
deriving (Eq, Show)
|
||||
|
||||
data IntroInvitation = IntroInvitation
|
||||
{ groupConnReq :: ConnReqInvitation,
|
||||
directConnReq :: Maybe ConnReqInvitation
|
||||
{ groupConnReq :: ConnReqInvitation
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
|
||||
@@ -1972,6 +1972,7 @@ viewRcvQueuesInfo = plain . T.intercalate ", " . map showQueueInfo
|
||||
RSSendingQADD -> "switch started"
|
||||
RSSendingQUSE -> "switch confirmed"
|
||||
RSReceivedMessage -> "switch secured"
|
||||
RSReceivedQEND -> "switch completed"
|
||||
|
||||
viewSndQueuesInfo :: [SndQueueInfo] -> StyledString
|
||||
viewSndQueuesInfo = plain . T.intercalate ", " . map showQueueInfo
|
||||
|
||||
@@ -197,8 +197,7 @@ Events:
|
||||
{
|
||||
"memberId": "<member ID>",
|
||||
"memberIntro": {
|
||||
"groupConnReq": "<group conn req>",
|
||||
"directConnReq": "<direct conn req>"
|
||||
"groupConnReq": "<group conn req>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +213,7 @@ Events:
|
||||
},
|
||||
},
|
||||
"memberIntro": {
|
||||
"groupConnReq": "<group conn req>",
|
||||
"directConnReq": "<direct conn req>"
|
||||
"groupConnReq": "<group conn req>"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ badgeTests = do
|
||||
it "should accept unknown badge types" testUnknownBadgeType
|
||||
it "credential serializes to a paste-able token and back" testCredentialSerialization
|
||||
it "presentation headers encode and decode" testPresHeaderEncoding
|
||||
it "presentation headers with binary bytes round-trip through JSON" testPresHeaderJSON
|
||||
it "should reject a proof presented under another chat binding" testOtherChatBinding
|
||||
describe "redemption codes" $ do
|
||||
it "a generated code reads back" testCodeRoundTrip
|
||||
@@ -227,6 +228,21 @@ testPresHeaderEncoding =
|
||||
PHUnknown 'Z' "payload"
|
||||
]
|
||||
|
||||
testPresHeaderJSON :: IO ()
|
||||
testPresHeaderJSON =
|
||||
mapM_
|
||||
( \ph -> do
|
||||
J.eitherDecode (J.encode ph) `shouldBe` Right ph
|
||||
J.toJSON ph `shouldBe` J.toJSON (BBSPresHeader $ strEncode ph)
|
||||
)
|
||||
[ PHChat binaryBytes,
|
||||
PHFileInv {chatBinding = binaryBytes, fileSize = 139737},
|
||||
PHFileDescr {chatBinding = binaryBytes, fileSize = 139737, descrHash = binaryBytes, fileExpires = Just futureTime},
|
||||
PHUnknown 'Z' binaryBytes
|
||||
]
|
||||
where
|
||||
binaryBytes = "\0\1\31\34\92\127\128\195\255"
|
||||
|
||||
testOtherChatBinding :: IO ()
|
||||
testOtherChatBinding = do
|
||||
let ph = PHFileInv {chatBinding = aliceBinding, fileSize = 139737}
|
||||
|
||||
+1
-1
@@ -641,7 +641,7 @@ smpServerCfg p =
|
||||
maxJournalMsgCount = 24,
|
||||
maxJournalStateLines = 4,
|
||||
queueIdBytes = 24,
|
||||
msgIdBytes = 6,
|
||||
msgIdBytes = 24,
|
||||
serverStoreCfg = SSCMemory Nothing, -- $ Just StorePaths {storeLogFile = "tmp/smp-server-store.log", storeMsgsFile = Just "tmp/smp-server-messages.log"},
|
||||
storeNtfsFile = Nothing,
|
||||
allowNewQueues = True,
|
||||
|
||||
@@ -1158,6 +1158,13 @@ testGetSetSMPServers =
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://2345-w==@smp2.example.im"
|
||||
alice <## " smp://3456-w==@smp3.example.im:5224"
|
||||
alice #$> ("/smp smp://2345-w==@smp2.example.im,smp4.example.im", id, "ok")
|
||||
alice ##> "/smp smp://2345-w==@smp2.example.im,smp4.example.im,smp5.example.im"
|
||||
alice <##. "bad chat command: user servers validation error(s): [USETooManyHosts"
|
||||
alice ##> "/smp"
|
||||
alice <## "Your servers"
|
||||
alice <## " SMP servers"
|
||||
alice <## " smp://2345-w==@smp2.example.im,smp4.example.im"
|
||||
|
||||
testTestSMPServerConnection :: HasCallStack => TestParams -> IO ()
|
||||
testTestSMPServerConnection =
|
||||
|
||||
@@ -38,7 +38,7 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId)
|
||||
import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement)
|
||||
import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText)
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText)
|
||||
import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation)
|
||||
import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..))
|
||||
@@ -119,6 +119,7 @@ chatGroupTests = do
|
||||
xit "create and join group when clients go offline" testGroupAsync
|
||||
describe "group links" $ do
|
||||
it "create group link, join via group link" testGroupLink
|
||||
it "join via group link with group profile near the size limit" testGroupLinkLargeGroupProfile
|
||||
it "invitees were previously connected as contacts" testGroupLinkInviteesWereConnected
|
||||
it "all members were previously connected as contacts" testGroupLinkAllMembersWereConnected
|
||||
it "delete group, re-join via same link" testGroupLinkDeleteGroupRejoin
|
||||
@@ -176,6 +177,7 @@ chatGroupTests = do
|
||||
it "manually accept contact with group member incognito" testMemberContactAcceptIncognito
|
||||
describe "group message forwarding" $ do
|
||||
it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForwardMessage
|
||||
it "reject forwarded content attributed to own membership" testGroupMsgForwardOwnMembershipRejected
|
||||
it "forward batched messages" testGroupMsgForwardBatched
|
||||
it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport
|
||||
it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate
|
||||
@@ -3042,6 +3044,39 @@ testPlanGroupLinkLeaveRejoin =
|
||||
bob <## "group link: known group #team_1"
|
||||
bob <## "use #team_1 <message> to send messages"
|
||||
|
||||
testGroupLinkLargeGroupProfile :: HasCallStack => TestParams -> IO ()
|
||||
testGroupLinkLargeGroupProfile =
|
||||
testChatOpts2 testOptsNoFullLinks aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
threadDelay 100000
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/set history #team off"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Recent history: off"
|
||||
withCCTransaction alice $ \db ->
|
||||
DB.execute db "UPDATE group_profiles SET description = ? WHERE display_name = ?" (T.pack welcome, "team" :: T.Text)
|
||||
alice ##> "/create link #team"
|
||||
gLink <- getGroupLink_ alice "team" GRMember True
|
||||
alice <// 100000
|
||||
bob ##> ("/c " <> gLink)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob (Bob): accepting request to join group #team..."
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: bob joined the group",
|
||||
do
|
||||
bob <## "#team: joining the group..."
|
||||
bob <## "#team: you joined the group"
|
||||
]
|
||||
alice #> "#team hello"
|
||||
let waitHello = do
|
||||
l <- getTermLine bob
|
||||
if "alice> hello" `isSuffixOf` l then pure () else waitHello
|
||||
waitHello
|
||||
where
|
||||
welcome = unwords $ replicate 1775 "welcome"
|
||||
|
||||
testGroupLink :: HasCallStack => TestParams -> IO ()
|
||||
testGroupLink =
|
||||
testChatOpts3 testOptsNoFullLinks aliceProfile bobProfile cathProfile $
|
||||
@@ -5399,6 +5434,28 @@ testGroupMsgForwardMessage =
|
||||
cath <# "#team bob> hi there [>>]"
|
||||
cath <# "#team hey team"
|
||||
|
||||
testGroupMsgForwardOwnMembershipRejected :: HasCallStack => TestParams -> IO ()
|
||||
testGroupMsgForwardOwnMembershipRejected =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
createGroup2 "team" alice bob
|
||||
threadDelay 1000000
|
||||
[Only bobMemId] <- withCCTransaction bob $ \db ->
|
||||
DB.query db "SELECT member_id FROM group_members WHERE member_category = ?" (Only ("user" :: T.Text)) :: IO [Only ByteString]
|
||||
connId <- relayConnIdToMember alice "bob"
|
||||
ts <- getCurrentTime
|
||||
let ChatController {smpAgent = aliceAgent} = chatController alice
|
||||
forgedProfile = (bobProfile :: Profile) {displayName = "mallory", fullName = "Mallory"}
|
||||
chatMsg = ChatMessage chatInitialVRange (Just $ SharedMsgId "forged_info1") (XInfo forgedProfile Nothing)
|
||||
fwd = GrpMsgForward (FwdMember (MemberId bobMemId) "bob") ts
|
||||
body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)]
|
||||
sent <- runExceptT $ sendMessages aliceAgent [(connId, PQEncOff, MsgFlags False, vrValue body)]
|
||||
either (fail . show) (const $ pure ()) sent
|
||||
bob <##. "error: x.grp.msg.forward: content attributed to own membership"
|
||||
bob ##> "/p"
|
||||
bob <## "user profile: bob (Bob)"
|
||||
bob <## "use /p <name> [<bio>] to change it"
|
||||
|
||||
testGroupMsgForwardBatched :: HasCallStack => TestParams -> IO ()
|
||||
testGroupMsgForwardBatched =
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
@@ -7979,8 +8036,8 @@ testForwardQuoteMention =
|
||||
bob <# "alice> -> forwarded"
|
||||
bob <## " hello @alice @cath"
|
||||
-- member renamed to duplicate name
|
||||
cath ##> "/p alice_1"
|
||||
cath <## "user profile is changed to alice_1 (your 1 contacts are notified)"
|
||||
cath ##> "/p alice"
|
||||
cath <## "user profile is changed to alice (your 1 contacts are notified)"
|
||||
alice <## "contact cath changed to alice_1"
|
||||
alice <## "use @alice_1 <message> to send messages"
|
||||
-- mention changed in quoted mentions
|
||||
|
||||
@@ -16,6 +16,9 @@ import Test.Hspec hiding (it)
|
||||
chatNamesTests :: SpecWith TestParams
|
||||
chatNamesTests = do
|
||||
it "connect by resolved name" testConnectByName
|
||||
it "profile update without the name keeps the name" testUpdateProfileKeepsName
|
||||
it "stopping address sharing removes the name" testAddressSharingOffRemovesName
|
||||
it "deleting the address removes the name" testAddressDeleteRemovesName
|
||||
it "connect by name not claimed in link profile is rejected" testConnectByNameNotClaimed
|
||||
it "connect by name to a known contact not claimed in profile is rejected" testConnectByNameKnownContactNotClaimed
|
||||
it "connect by unregistered name fails to resolve" testConnectByNameNotFound
|
||||
@@ -62,6 +65,123 @@ testConnectByName ps = withSmpServerAndNames ps $ \reg ->
|
||||
_ <- getTermLine bob
|
||||
pure ()
|
||||
|
||||
testUpdateProfileKeepsName :: HasCallStack => TestParams -> IO ()
|
||||
testUpdateProfileKeepsName ps = withSmpServerAndNames ps $ \reg ->
|
||||
testChat2 aliceProfile bobProfile (test reg) ps
|
||||
where
|
||||
aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" [])
|
||||
test reg alice bob = do
|
||||
mapM_ enableNamesRole [alice, bob]
|
||||
alice ##> "/ad"
|
||||
(shortLink, _) <- getContactLinks alice True
|
||||
registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink))
|
||||
alice ##> "/_set domain 1 alice.simplex"
|
||||
alice <## "new contact address set"
|
||||
bob ##> "/c @alice.simplex"
|
||||
bob <## "alice: connection started"
|
||||
alice <## "bob (Bob) wants to connect to you!"
|
||||
alice <## "to accept: /ac bob"
|
||||
alice <## "to reject: /rc bob (the sender will NOT be notified)"
|
||||
alice ##> "/ac bob"
|
||||
alice <## "bob (Bob): accepting contact request, you can send messages to contact"
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
alice ##> ("/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"\", \"shortDescr\": \"new bio\", \"contactLink\": \"" <> shortLink <> "\"}")
|
||||
alice <## "user bio changed to new bio (your 1 contacts are notified)"
|
||||
bob <## "contact alice updated bio: new bio"
|
||||
bob ##> "/i alice"
|
||||
bob <## "contact ID: 2"
|
||||
bob <## "receiving messages via: localhost"
|
||||
bob <## "sending messages via: localhost"
|
||||
_ <- getTermLine bob
|
||||
bob <## "SimpleX name: @alice.simplex (verified)"
|
||||
bob <## "you've shared main profile with this contact"
|
||||
bob <## "connection not verified, use /code command to see security code"
|
||||
bob <## "quantum resistant end-to-end encryption"
|
||||
_ <- getTermLine bob
|
||||
pure ()
|
||||
|
||||
testAddressSharingOffRemovesName :: HasCallStack => TestParams -> IO ()
|
||||
testAddressSharingOffRemovesName ps = withSmpServerAndNames ps $ \reg ->
|
||||
testChat2 aliceProfile bobProfile (test reg) ps
|
||||
where
|
||||
aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" [])
|
||||
test reg alice bob = do
|
||||
mapM_ enableNamesRole [alice, bob]
|
||||
alice ##> "/ad"
|
||||
(shortLink, _) <- getContactLinks alice True
|
||||
registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink))
|
||||
alice ##> "/_set domain 1 alice.simplex"
|
||||
alice <## "new contact address set"
|
||||
bob ##> "/c @alice.simplex"
|
||||
bob <## "alice: connection started"
|
||||
alice <## "bob (Bob) wants to connect to you!"
|
||||
alice <## "to accept: /ac bob"
|
||||
alice <## "to reject: /rc bob (the sender will NOT be notified)"
|
||||
alice ##> "/ac bob"
|
||||
alice <## "bob (Bob): accepting contact request, you can send messages to contact"
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
alice ##> "/pa off"
|
||||
alice <## "contact address removed"
|
||||
bob <## "alice removed contact address"
|
||||
checkNoName bob
|
||||
alice ##> "/pa on"
|
||||
alice <## "new contact address set"
|
||||
bob <## "alice set new contact address, use /info alice to view"
|
||||
bob ##> "/i alice"
|
||||
bob <## "contact ID: 2"
|
||||
bob <## "receiving messages via: localhost"
|
||||
bob <## "sending messages via: localhost"
|
||||
_ <- getTermLine bob
|
||||
bob <## "you've shared main profile with this contact"
|
||||
bob <## "connection not verified, use /code command to see security code"
|
||||
bob <## "quantum resistant end-to-end encryption"
|
||||
_ <- getTermLine bob
|
||||
pure ()
|
||||
|
||||
testAddressDeleteRemovesName :: HasCallStack => TestParams -> IO ()
|
||||
testAddressDeleteRemovesName ps = withSmpServerAndNames ps $ \reg ->
|
||||
testChat2 aliceProfile bobProfile (test reg) ps
|
||||
where
|
||||
aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" [])
|
||||
test reg alice bob = do
|
||||
mapM_ enableNamesRole [alice, bob]
|
||||
alice ##> "/ad"
|
||||
(shortLink, _) <- getContactLinks alice True
|
||||
registerName reg aliceName (contactNameRecord "alice.simplex" (T.pack shortLink))
|
||||
alice ##> "/_set domain 1 alice.simplex"
|
||||
alice <## "new contact address set"
|
||||
bob ##> "/c @alice.simplex"
|
||||
bob <## "alice: connection started"
|
||||
alice <## "bob (Bob) wants to connect to you!"
|
||||
alice <## "to accept: /ac bob"
|
||||
alice <## "to reject: /rc bob (the sender will NOT be notified)"
|
||||
alice ##> "/ac bob"
|
||||
alice <## "bob (Bob): accepting contact request, you can send messages to contact"
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
alice ##> "/da"
|
||||
alice <## "Your chat address is deleted - accepted contacts will remain connected."
|
||||
alice <## "To create a new chat address use /ad"
|
||||
bob <## "alice removed contact address"
|
||||
checkNoName bob
|
||||
|
||||
checkNoName :: HasCallStack => TestCC -> IO ()
|
||||
checkNoName bob = do
|
||||
bob ##> "/i alice"
|
||||
bob <## "contact ID: 2"
|
||||
bob <## "receiving messages via: localhost"
|
||||
bob <## "sending messages via: localhost"
|
||||
bob <## "you've shared main profile with this contact"
|
||||
bob <## "connection not verified, use /code command to see security code"
|
||||
bob <## "quantum resistant end-to-end encryption"
|
||||
_ <- getTermLine bob
|
||||
pure ()
|
||||
|
||||
testConnectByNameNotClaimed :: HasCallStack => TestParams -> IO ()
|
||||
testConnectByNameNotClaimed ps = withSmpServerAndNames ps $ \reg ->
|
||||
testChat2 aliceProfile bobProfile (test reg) ps
|
||||
|
||||
@@ -46,6 +46,10 @@ chatProfileTests :: SpecWith TestParams
|
||||
chatProfileTests = do
|
||||
describe "user profiles" $ do
|
||||
it "update user profile and notify contacts" testUpdateProfile
|
||||
it "profile is not saved when its address data upload fails" testUpdateProfileAddressDataError
|
||||
it "profile name used by a contact gets a local name with suffix" testUpdateProfileNameUsedByContact
|
||||
it "profile name used by another user is rejected, names with _N suffix are invalid" testUpdateProfileNameUsedByUser
|
||||
it "profile name used by a hidden user is rejected" testUpdateProfileNameUsedByHiddenUser
|
||||
it "profile description round-trips and shows in contact info" testProfileDescriptionShown
|
||||
it "member profile description is redacted for members without a direct contact" testMemberDescriptionRedacted
|
||||
it "update user profile with image" testUpdateProfileImage
|
||||
@@ -210,6 +214,117 @@ testUpdateProfile =
|
||||
bob <## "use @cat <message> to send messages"
|
||||
]
|
||||
|
||||
testUpdateProfileAddressDataError :: HasCallStack => TestParams -> IO ()
|
||||
testUpdateProfileAddressDataError ps = do
|
||||
let cfg' = testCfg {agentConfig = testAgentCfg {persistErrorInterval = 0}}
|
||||
withNewTestChatCfgOpts ps cfg' opts' "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatCfgOpts ps cfg' opts' "bob" bobProfile $ \bob -> do
|
||||
withSmpServer' serverCfg' $ do
|
||||
connectUsers alice bob
|
||||
alice ##> "/ad"
|
||||
_ <- getContactLinks alice True
|
||||
pure ()
|
||||
alice <## "disconnected 2 connections on server localhost"
|
||||
bob <## "disconnected 1 connections on server localhost"
|
||||
alice ##> "/p alisa"
|
||||
alice <##. "smp agent error: BROKER"
|
||||
alice ##> "/p"
|
||||
alice <## "user profile: alice (Alice)"
|
||||
alice <## "use /p <name> [<bio>] to change it"
|
||||
withSmpServer' serverCfg' $ do
|
||||
alice <## "subscribed 2 connections on server localhost"
|
||||
bob <## "subscribed 1 connections on server localhost"
|
||||
alice ##> "/p alisa"
|
||||
alice <## "user profile is changed to alisa (your 1 contacts are notified)"
|
||||
bob <## "contact alice changed to alisa"
|
||||
bob <## "use @alisa <message> to send messages"
|
||||
alice <## "disconnected 2 connections on server localhost"
|
||||
bob <## "disconnected 1 connections on server localhost"
|
||||
where
|
||||
serverCfg' =
|
||||
(smpServerCfg ps)
|
||||
{ transports = [(smpTestPort2 ps, transport @TLS, False)],
|
||||
serverStoreCfg = persistentServerStoreCfg (tmpPath ps)
|
||||
}
|
||||
opts' =
|
||||
testOpts
|
||||
{ coreOptions =
|
||||
testCoreOpts
|
||||
{ smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"]
|
||||
}
|
||||
}
|
||||
|
||||
testUpdateProfileNameUsedByContact :: HasCallStack => TestParams -> IO ()
|
||||
testUpdateProfileNameUsedByContact =
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
connectUsers alice bob
|
||||
alice ##> "/ad"
|
||||
(shortLink, _) <- getContactLinks alice True
|
||||
alice ##> "/p bob"
|
||||
alice <## "user profile is changed to bob (your 1 contacts are notified)"
|
||||
bob <## "contact alice changed to bob_1"
|
||||
bob <## "use @bob_1 <message> to send messages"
|
||||
alice ##> "/p"
|
||||
alice <## "user profile: bob"
|
||||
alice <## "use /p <name> [<bio>] to change it"
|
||||
alice ##> "/users"
|
||||
alice <## "bob_1 (active)"
|
||||
alice #> "@bob hi"
|
||||
bob <# "bob_1> hi"
|
||||
cath ##> ("/_connect plan 1 " <> shortLink)
|
||||
cath <## "contact address: ok to connect"
|
||||
sLinkData <- getTermLine cath
|
||||
sLinkData `shouldContain` "\"displayName\":\"bob\""
|
||||
alice ##> "/p alice"
|
||||
alice <## "user profile is changed to alice (your 1 contacts are notified)"
|
||||
bob <## "contact bob_1 changed to alice"
|
||||
bob <## "use @alice <message> to send messages"
|
||||
alice ##> "/users"
|
||||
alice <## "alice (active)"
|
||||
|
||||
testUpdateProfileNameUsedByHiddenUser :: HasCallStack => TestParams -> IO ()
|
||||
testUpdateProfileNameUsedByHiddenUser =
|
||||
testChat aliceProfile $
|
||||
\alice -> do
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
alice ##> "/hide user my_password"
|
||||
alice <## "current user alisa:"
|
||||
alice <## "messages are hidden (use /tail to view)"
|
||||
alice <## "profile is hidden"
|
||||
alice ##> "/user alice"
|
||||
showActiveUser alice "alice (Alice)"
|
||||
alice ##> "/p alisa"
|
||||
alice <## "invalid display name: alisa"
|
||||
|
||||
testUpdateProfileNameUsedByUser :: HasCallStack => TestParams -> IO ()
|
||||
testUpdateProfileNameUsedByUser =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
connectUsers alice bob
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
alice ##> "/user alice"
|
||||
showActiveUser alice "alice (Alice)"
|
||||
alice ##> "/p alisa"
|
||||
alice <## "user with the name alisa already exists"
|
||||
alice ##> "/p alisa_1"
|
||||
alice <## "invalid display name: alisa_1"
|
||||
alice <## "you could use this one: alisa-1"
|
||||
alice ##> "/p alisa-1"
|
||||
alice <## "user profile is changed to alisa-1 (your 1 contacts are notified)"
|
||||
bob <## "contact alice changed to alisa-1"
|
||||
bob <## "use @alisa-1 <message> to send messages"
|
||||
alice ##> "/create user alisa-1"
|
||||
alice <## "user with the name alisa-1 already exists"
|
||||
alice ##> "/create user bob_2"
|
||||
alice <## "invalid display name: bob_2"
|
||||
alice <## "you could use this one: bob-2"
|
||||
alice ##> "/users"
|
||||
alice <## "alisa"
|
||||
alice <## "alisa-1 (active)"
|
||||
|
||||
-- Profile.description survives the connect-time round-trip and is shown in the contact /i view.
|
||||
testProfileDescriptionShown :: HasCallStack => TestParams -> IO ()
|
||||
testProfileDescriptionShown =
|
||||
|
||||
@@ -33,6 +33,7 @@ import Simplex.Chat.Protocol
|
||||
GrpMsgForward (GrpMsgForward),
|
||||
MsgContent (MCText),
|
||||
VerifiedMsg (VMUnsigned),
|
||||
fwdMemberName,
|
||||
maxBatchElementCount,
|
||||
maxEncodedMsgLength,
|
||||
mcSimple,
|
||||
@@ -49,6 +50,7 @@ batchingTests = describe "message batching tests" $ do
|
||||
it "splits a batch that exceeds the element count limit" testBatchElementCountLimit
|
||||
it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge
|
||||
it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow
|
||||
it "shortens forwarded member names" testFwdMemberName
|
||||
|
||||
instance IsString SndMessage where
|
||||
fromString s = SndMessage {msgId, sharedMsgId = SharedMsgId "", msgBody = s', signedMsg_ = Nothing}
|
||||
@@ -190,6 +192,11 @@ testRelayBatchSingletonOverflow = do
|
||||
map deliveryTaskId accepted `shouldBe` []
|
||||
map deliveryTaskId large `shouldBe` [1]
|
||||
|
||||
testFwdMemberName :: IO ()
|
||||
testFwdMemberName = do
|
||||
fwdMemberName "sixteen_chars_ab" `shouldBe` "sixteen_chars_ab"
|
||||
fwdMemberName "seventeen_chars_a" `shouldBe` "seventeen_chars_…"
|
||||
|
||||
runBatcherTest :: BatchMode -> Int -> [SndMessage] -> [ChatError] -> [ByteString] -> Spec
|
||||
runBatcherTest mode maxLen msgs expectedErrors expectedBatches =
|
||||
it
|
||||
|
||||
@@ -369,17 +369,11 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} (Just MemberRestrictions {restriction = MRSBlocked})
|
||||
it "x.grp.mem.inv" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
|
||||
it "x.grp.mem.inv w/t directConnReq" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
|
||||
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
|
||||
it "x.grp.mem.fwd" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
|
||||
it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $
|
||||
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq}
|
||||
it "x.grp.mem.fwd with member chat version range" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
|
||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq}
|
||||
it "x.grp.mem.info" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
#==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile
|
||||
|
||||
@@ -57,3 +57,10 @@ testMkValidName = do
|
||||
mkValidName "123 alice" `shouldBe` "123 alice"
|
||||
mkValidName "01234567890123456789012345678901234567890123456789extra" `shouldBe` "01234567890123456789012345678901234567890123456789"
|
||||
mkValidName "0123456789012345678901234567890123456789012345678 extra" `shouldBe` "0123456789012345678901234567890123456789012345678"
|
||||
mkValidName "alice_1" `shouldBe` "alice-1"
|
||||
mkValidName "alice_bob_12" `shouldBe` "alice_bob-12"
|
||||
mkValidName "alice _1" `shouldBe` "alice -1"
|
||||
mkValidName "alice_" `shouldBe` "alice_"
|
||||
mkValidName "alice_bob" `shouldBe` "alice_bob"
|
||||
mkValidName "alice_1b" `shouldBe` "alice_1b"
|
||||
mkValidName (mkValidName "alice_1") `shouldBe` "alice-1"
|
||||
|
||||
+178
-100
@@ -4,22 +4,44 @@ const markdownItReplaceLink = require('markdown-it-replace-link')
|
||||
const markdownItFootnote = require('markdown-it-footnote')
|
||||
const slugify = require("slugify")
|
||||
const uri = require('fast-uri')
|
||||
const i18n = require('eleventy-plugin-i18n')
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const matter = require('gray-matter')
|
||||
const pluginRss = require('@11ty/eleventy-plugin-rss')
|
||||
const { JSDOM } = require('jsdom')
|
||||
const parse5 = require('parse5')
|
||||
const markdownPages = require('./markdown_pages')
|
||||
|
||||
|
||||
// Links page data
|
||||
const parseLinks = require('./parse_links')
|
||||
const linksFilePath = path.resolve(__dirname, '../docs/LINKS.md')
|
||||
const linksData = fs.existsSync(linksFilePath) ? parseLinks(linksFilePath) : []
|
||||
const linkImagesDir = path.resolve(__dirname, 'src/link-images')
|
||||
linksData.forEach(entry => {
|
||||
entry.imageExists = entry.image && fs.existsSync(path.join(linkImagesDir, entry.image))
|
||||
})
|
||||
|
||||
function readLinksPage() {
|
||||
const entries = fs.existsSync(linksFilePath) ? parseLinks(linksFilePath) : []
|
||||
entries.forEach(entry => {
|
||||
entry.imageExists = entry.image && fs.existsSync(path.join(linkImagesDir, entry.image))
|
||||
})
|
||||
|
||||
const catCounts = {}
|
||||
entries.forEach(e => { if (e.category) { const c = e.category.toLowerCase(); catCounts[c] = (catCounts[c] || 0) + 1 } })
|
||||
const mediaPills = ["Video", "Audio"].filter(p => entries.some(e => e.mediaType === p.toLowerCase()))
|
||||
const catPills = Object.keys(catCounts).sort()
|
||||
|
||||
return {
|
||||
entries,
|
||||
languages: [...new Set(entries.map(e => e.language).filter(Boolean))].sort(),
|
||||
pills: mediaPills.concat(catPills),
|
||||
}
|
||||
}
|
||||
|
||||
const permalinks = new Map()
|
||||
|
||||
function filePermalink(file) {
|
||||
if (!permalinks.has(file)) permalinks.set(file, matter(fs.readFileSync(file, 'utf8')).data?.permalink)
|
||||
return permalinks.get(file)
|
||||
}
|
||||
|
||||
// Rewrites relative markdown links to website permalinks or GitHub URLs.
|
||||
// Shared by the main markdown renderer (markdownLib) and the glossary renderer
|
||||
@@ -39,8 +61,7 @@ function replaceLink(link, _env) {
|
||||
|
||||
if (fs.existsSync(linkFile) && fs.statSync(linkFile).isFile()) {
|
||||
// this condition works if the link is a valid website file
|
||||
const fileContent = fs.readFileSync(linkFile, 'utf8')
|
||||
parsed.path = (matter(fileContent).data?.permalink || parsed.path).replace(/\.md$/, ".html").toLowerCase()
|
||||
parsed.path = (filePermalink(linkFile) || parsed.path).replace(/\.md$/, ".html").toLowerCase()
|
||||
} else if (!fs.existsSync(linkFile)) {
|
||||
linkFile = linkFile.replace('/website/src', '')
|
||||
if (fs.existsSync(linkFile)) {
|
||||
@@ -94,8 +115,41 @@ glossary.forEach(item => {
|
||||
item.tooltip = firstParagraph
|
||||
item.hasMultipleParagraphs = paragraphCount > 1
|
||||
}
|
||||
|
||||
const definitionLinks = new JSDOM(item.definition).window.document.querySelectorAll('a[href*="#"]')
|
||||
item.linkedHashes = Array.from(definitionLinks, a => a.href.substring(a.href.indexOf("#") + 1))
|
||||
item.id = item.term.toLowerCase().replace(/\s/g, '-')
|
||||
item.pattern = new RegExp(`(?<![/#])\\b${item.term}\\b`, 'gi')
|
||||
})
|
||||
|
||||
const glossaryById = new Map(glossary.map(item => [item.id, item]))
|
||||
const glossaryTermPattern = new RegExp(`(?<![/#])\\b(${glossary.map(item => item.term).join('|')})\\b`, 'i')
|
||||
const glossaryContentTags = new Set(['p', 'td', 'a', 'h1', 'h2', 'h3', 'h4'])
|
||||
const htmlParseOptions = { scriptingEnabled: false }
|
||||
const closeOverlayIcon = '<svg class="close-overlay-btn" id="cross" width="16" height="16" viewBox="0 0 13 13" xmlns="http://www.w3.org/2000/svg"><path d="M12.7973 11.5525L7.59762 6.49833L12.7947 1.44675C13.055 1.19371 13.0658 0.771991 12.8188 0.505331C12.5718 0.238674 12.1602 0.227644 11.8999 0.480681L6.65343 5.58028L1.09979 0.182228C0.805 0.002228 0.430001 0.002228 0.135211 0.182228C-0.159579 0.362228 -0.159579 0.697228 0.135211 0.877228L5.68885 6.27528L0.4918 11.3295C0.231501 11.5825 0.220703 12.0042 0.467664 12.2709C0.714625 12.5376 1.12625 12.5486 1.38655 12.2956L6.63302 7.196L12.1867 12.5941C12.4815 12.7741 12.8565 12.7741 13.1513 12.5941C13.4461 12.4141 13.4461 12.0791 13.1513 11.8991L12.7973 11.5525Z"></path></svg>'
|
||||
|
||||
function glossaryTooltipHtml(term) {
|
||||
const readMoreButton = term.hasMultipleParagraphs
|
||||
? `<button class="read-more-btn open-overlay-btn" data-show-overlay="${term.id}">Read more</button>`
|
||||
: ''
|
||||
return `<div id="tooltip-${term.id}" class="glossary-tooltip"><div class="tooltip-content"><h4 class="tooltip-title">${term.term}</h4><p>${term.tooltip}</p>${readMoreButton}</div></div>`
|
||||
}
|
||||
|
||||
function glossaryOverlayHtml(term) {
|
||||
return `<div id="${term.id}" class="overlay glossary-overlay hidden"><div class="overlay-card"><h1 class="overlay-title">${term.term}</h1><div class="overlay-content">${term.definition}</div>${closeOverlayIcon}</div></div>`
|
||||
}
|
||||
|
||||
function descendantElements(node, tags) {
|
||||
return (node.childNodes || []).flatMap((child) =>
|
||||
tags.has(child.tagName) ? [child, ...descendantElements(child, tags)] : descendantElements(child, tags))
|
||||
}
|
||||
|
||||
function parseChildren(node, html) {
|
||||
const children = parse5.parseFragment(node, html, htmlParseOptions).childNodes
|
||||
children.forEach((child) => child.parentNode = node)
|
||||
return children
|
||||
}
|
||||
|
||||
|
||||
const globalConfig = {
|
||||
onionLocation: "http://isdb4l77sjqoy2qq7ipum6x3at6hyn3jmxfx4zdhc72ufbmuq4ilwkqd.onion",
|
||||
@@ -118,7 +172,11 @@ fs.readdir(translationsDirectoryPath, (err, files) => {
|
||||
|
||||
const translations = require("./translations.json")
|
||||
|
||||
const outputDir = '_site'
|
||||
|
||||
module.exports = function (ty) {
|
||||
ty.on("eleventy.before", () => permalinks.clear())
|
||||
|
||||
// Add this after your markdownLib definition
|
||||
ty.addShortcode("mdInclude", function (filepath) {
|
||||
const fullPath = path.join(__dirname, 'src/_includes', filepath);
|
||||
@@ -126,14 +184,8 @@ module.exports = function (ty) {
|
||||
return markdownLib.render(content);
|
||||
});
|
||||
|
||||
ty.addGlobalData("links", linksData)
|
||||
ty.addGlobalData("linkLanguages", [...new Set(linksData.map(e => e.language).filter(Boolean))].sort())
|
||||
|
||||
const catCounts = {}
|
||||
linksData.forEach(e => { if (e.category) { const c = e.category.toLowerCase(); catCounts[c] = (catCounts[c] || 0) + 1 } })
|
||||
const mediaPills = ["Video", "Audio"].filter(p => linksData.some(e => e.mediaType === p.toLowerCase()))
|
||||
const catPills = Object.keys(catCounts).sort()
|
||||
ty.addGlobalData("linkPills", mediaPills.concat(catPills))
|
||||
ty.addGlobalData("linksPage", readLinksPage)
|
||||
ty.addWatchTarget(linksFilePath)
|
||||
|
||||
ty.addShortcode("cfg", (name) => globalConfig[name])
|
||||
|
||||
@@ -160,100 +212,52 @@ module.exports = function (ty) {
|
||||
})
|
||||
|
||||
ty.addFilter('applyGlossary', function (content) {
|
||||
const dom = new JSDOM(content)
|
||||
const { document } = dom.window
|
||||
const body = document.querySelector('body')
|
||||
const allContentNodes = document.querySelectorAll('p, td, a, h1, h2, h3, h4')
|
||||
if (!glossaryTermPattern.test(content)) return content
|
||||
const document = parse5.parse(content, htmlParseOptions)
|
||||
const body = document.childNodes.find((node) => node.tagName === 'html').childNodes.find((node) => node.tagName === 'body')
|
||||
const allContentNodes = descendantElements(document, glossaryContentTags)
|
||||
const contentHtml = allContentNodes.map((node) => parse5.serialize(node))
|
||||
const overlayIds = []
|
||||
const matchedIds = new Set()
|
||||
|
||||
glossary.forEach((term, index) => {
|
||||
let changeNoted = false
|
||||
const id = term.term.toLowerCase().replace(/\s/g, '-')
|
||||
|
||||
allContentNodes.forEach((node) => {
|
||||
const regex = new RegExp(`(?<![/#])\\b${term.term}\\b`, 'gi')
|
||||
const beforeContent = node.innerHTML
|
||||
node.innerHTML = node.innerHTML.replace(regex, (match) => {
|
||||
glossary.forEach((term) => {
|
||||
const id = term.id
|
||||
allContentNodes.forEach((node, nodeIndex) => {
|
||||
const beforeContent = contentHtml[nodeIndex]
|
||||
const afterContent = beforeContent.replace(term.pattern, (match) => {
|
||||
return `<span data-glossary="tooltip-${id}" class="glossary-term">${match}</span>`
|
||||
})
|
||||
if (beforeContent !== node.innerHTML && !changeNoted) {
|
||||
changeNoted = true
|
||||
if (afterContent !== beforeContent) {
|
||||
node.childNodes = parseChildren(node, afterContent)
|
||||
contentHtml[nodeIndex] = parse5.serialize(node)
|
||||
matchedIds.add(id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (changeNoted) {
|
||||
const definitionTooltipDiv = document.createElement('div')
|
||||
definitionTooltipDiv.id = `tooltip-${id}`
|
||||
definitionTooltipDiv.className = "glossary-tooltip"
|
||||
const titleH4 = document.createElement('h4')
|
||||
titleH4.innerHTML = term.term
|
||||
titleH4.className = "tooltip-title"
|
||||
const p = document.createElement('p')
|
||||
p.innerHTML = term.tooltip
|
||||
const innerDiv = document.createElement('div')
|
||||
innerDiv.appendChild(titleH4)
|
||||
innerDiv.appendChild(p)
|
||||
if (term.hasMultipleParagraphs) {
|
||||
const readMoreBtn = document.createElement('button')
|
||||
readMoreBtn.innerHTML = "Read more"
|
||||
readMoreBtn.className = "read-more-btn open-overlay-btn"
|
||||
readMoreBtn.setAttribute('data-show-overlay', id)
|
||||
innerDiv.appendChild(readMoreBtn)
|
||||
}
|
||||
innerDiv.className = "tooltip-content"
|
||||
definitionTooltipDiv.appendChild(innerDiv)
|
||||
body.appendChild(definitionTooltipDiv)
|
||||
const neededIds = new Set(matchedIds)
|
||||
neededIds.forEach((id) => glossaryById.get(id).linkedHashes.forEach((hash) => {
|
||||
if (glossaryById.has(hash)) neededIds.add(hash)
|
||||
}))
|
||||
|
||||
glossary.forEach((term) => {
|
||||
const id = term.id
|
||||
|
||||
if (matchedIds.has(id)) {
|
||||
body.childNodes.push(...parseChildren(body, glossaryTooltipHtml(term)))
|
||||
}
|
||||
|
||||
let tooltipDom = new JSDOM(term.definition)
|
||||
let tooltipDocument = tooltipDom.window.document
|
||||
const hashList = [term.term.toLowerCase().replace(/\s/g, '-')]
|
||||
tooltipDocument.querySelectorAll('a[href*="#"]').forEach(a => {
|
||||
let hashIndex = a.href.indexOf("#")
|
||||
if (hashIndex !== -1) {
|
||||
let hash = a.href.substring(hashIndex + 1)
|
||||
hashList.push(hash)
|
||||
}
|
||||
})
|
||||
const hashList = [id, ...term.linkedHashes]
|
||||
|
||||
hashList.forEach(hash => {
|
||||
if (!overlayIds.includes(hash)) {
|
||||
let termFromHash = glossary.find(term => term.term.toLowerCase().replace(/\s/g, '-') === hash)
|
||||
if (!termFromHash) return
|
||||
|
||||
const overlayDiv = document.createElement('div')
|
||||
overlayDiv.id = hash
|
||||
overlayDiv.className = "overlay glossary-overlay hidden"
|
||||
const overlayCardDiv = document.createElement('div')
|
||||
overlayCardDiv.className = "overlay-card"
|
||||
const overlayTitleH1 = document.createElement('h1')
|
||||
overlayTitleH1.className = "overlay-title"
|
||||
overlayTitleH1.innerHTML = termFromHash.term
|
||||
const overlayContent = document.createElement('div')
|
||||
overlayContent.className = "overlay-content"
|
||||
overlayContent.innerHTML = termFromHash.definition
|
||||
const crossSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg")
|
||||
crossSVG.setAttribute('class', 'close-overlay-btn')
|
||||
crossSVG.setAttribute('id', 'cross')
|
||||
crossSVG.setAttribute('width', '16')
|
||||
crossSVG.setAttribute('height', '16')
|
||||
crossSVG.setAttribute('viewBox', '0 0 13 13')
|
||||
crossSVG.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
const crossPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
|
||||
crossPath.setAttribute('d', 'M12.7973 11.5525L7.59762 6.49833L12.7947 1.44675C13.055 1.19371 13.0658 0.771991 12.8188 0.505331C12.5718 0.238674 12.1602 0.227644 11.8999 0.480681L6.65343 5.58028L1.09979 0.182228C0.805 0.002228 0.430001 0.002228 0.135211 0.182228C-0.159579 0.362228 -0.159579 0.697228 0.135211 0.877228L5.68885 6.27528L0.4918 11.3295C0.231501 11.5825 0.220703 12.0042 0.467664 12.2709C0.714625 12.5376 1.12625 12.5486 1.38655 12.2956L6.63302 7.196L12.1867 12.5941C12.4815 12.7741 12.8565 12.7741 13.1513 12.5941C13.4461 12.4141 13.4461 12.0791 13.1513 11.8991L12.7973 11.5525Z')
|
||||
crossSVG.appendChild(crossPath)
|
||||
|
||||
overlayCardDiv.appendChild(overlayTitleH1)
|
||||
overlayCardDiv.appendChild(overlayContent)
|
||||
overlayCardDiv.appendChild(crossSVG)
|
||||
overlayDiv.appendChild(overlayCardDiv)
|
||||
body.appendChild(overlayDiv)
|
||||
if (neededIds.has(hash) && !overlayIds.includes(hash)) {
|
||||
body.childNodes.push(...parseChildren(body, glossaryOverlayHtml(glossaryById.get(hash))))
|
||||
overlayIds.push(hash)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return dom.serialize()
|
||||
return parse5.serialize(document)
|
||||
})
|
||||
|
||||
ty.addFilter('wrapH3s', function (content, page) {
|
||||
@@ -334,14 +338,81 @@ module.exports = function (ty) {
|
||||
}
|
||||
})
|
||||
|
||||
ty.addFilter("markdownUrl", (url) => markdownPages.markdownUrl(url, supportedLangs))
|
||||
|
||||
ty.addFilter("markdownAlternate", (page) =>
|
||||
markdownPages.markdownKind(page.inputPath, supportedLangs) ? markdownPages.markdownUrl(page.url, supportedLangs) : "")
|
||||
|
||||
ty.addFilter("markdownSource", (inputPath) => markdownPages.markdownFromSource(inputPath, replaceLink))
|
||||
|
||||
ty.addFilter("markdownTitle", markdownPages.markdownTitle)
|
||||
|
||||
ty.addFilter("plainText", markdownPages.plainText)
|
||||
|
||||
const outputSections = ["pages", "blog pages", "doc pages", "language pages", "other files", "Markdown files"]
|
||||
const outputCounts = new Map()
|
||||
|
||||
function countOutput(section) {
|
||||
outputCounts.set(section, (outputCounts.get(section) || 0) + 1)
|
||||
}
|
||||
|
||||
function outputSection(outputPath) {
|
||||
const [folder] = path.relative(outputDir, outputPath).split(path.sep)
|
||||
if (supportedLangs.includes(folder)) return "language pages"
|
||||
if (folder === "blog") return "blog pages"
|
||||
if (folder === "docs") return "doc pages"
|
||||
return outputPath.endsWith(".html") ? "pages" : "other files"
|
||||
}
|
||||
|
||||
ty.on("eleventy.before", () => outputCounts.clear())
|
||||
|
||||
ty.on("eleventy.after", () => {
|
||||
const counts = outputSections.filter((section) => outputCounts.has(section)).map((section) => `${outputCounts.get(section)} ${section}`)
|
||||
console.log(`[11ty] Wrote ${counts.join(", ")}`)
|
||||
})
|
||||
|
||||
ty.addTransform("countOutputs", function (content) {
|
||||
if (this.outputPath) countOutput(outputSection(this.outputPath))
|
||||
return content
|
||||
})
|
||||
|
||||
ty.addTransform("markdownPages", function (content) {
|
||||
const kind = markdownPages.markdownKind(this.inputPath, supportedLangs)
|
||||
if (kind && this.outputPath) {
|
||||
const pageUrl = "/" + path.relative(outputDir, this.outputPath).replace(/index\.html$/, "")
|
||||
const markdown = kind === "source"
|
||||
? markdownPages.markdownFromSource(this.inputPath, replaceLink)
|
||||
: markdownPages.markdownFromHtml(content, pageUrl)
|
||||
const markdownPath = path.join(outputDir, markdownPages.markdownUrl(pageUrl, supportedLangs))
|
||||
fs.mkdirSync(path.dirname(markdownPath), { recursive: true })
|
||||
fs.writeFileSync(markdownPath, markdown)
|
||||
countOutput("Markdown files")
|
||||
}
|
||||
return content
|
||||
})
|
||||
|
||||
ty.addPlugin(pluginRss)
|
||||
|
||||
ty.addPlugin(i18n, {
|
||||
translations,
|
||||
fallbackLocales: {
|
||||
'*': 'en'
|
||||
},
|
||||
defaultLocale: 'en',
|
||||
const unknownStrings = new Set()
|
||||
|
||||
ty.addFilter("i18n", function (key, _data, lang) {
|
||||
const locale = lang || (this.page || this.ctx.page)?.url?.split("/")[1]
|
||||
const strings = translations[key] || {}
|
||||
if (strings.en === undefined) unknownStrings.add(key)
|
||||
return strings[locale] ?? strings.en ?? key
|
||||
})
|
||||
|
||||
ty.on("eleventy.before", () => unknownStrings.clear())
|
||||
|
||||
ty.on("eleventy.after", () => {
|
||||
const allStrings = Object.values(translations)
|
||||
const untranslated = supportedLangs.filter((lang) => lang !== "en").sort()
|
||||
.map((lang) => [lang, allStrings.filter((strings) => strings[lang] === undefined).length])
|
||||
.filter(([, count]) => count > 0)
|
||||
if (untranslated.length > 0) {
|
||||
console.warn(`[i18n] Untranslated of ${allStrings.length} strings, English used: ${untranslated.map(([lang, count]) => `${lang} ${count}`).join(", ")}`)
|
||||
}
|
||||
if (unknownStrings.size > 0) console.warn(`[i18n] Missing in en.json: ${Array.from(unknownStrings).join(", ")}`)
|
||||
})
|
||||
|
||||
// Keeps the same directory structure.
|
||||
@@ -453,6 +524,13 @@ module.exports = function (ty) {
|
||||
return newDocs
|
||||
})
|
||||
|
||||
ty.addCollection("markdownDocs", (collection) =>
|
||||
collection.getFilteredByGlob("src/docs/**/*.md")
|
||||
.filter((doc) => markdownPages.markdownKind(doc.inputPath, supportedLangs) === "source")
|
||||
.sort((a, b) => a.url.localeCompare(b.url)))
|
||||
|
||||
ty.setQuietMode(true)
|
||||
ty.setWatchThrottleWaitTime(100)
|
||||
ty.addWatchTarget("src/css")
|
||||
ty.addWatchTarget("markdown/")
|
||||
ty.addWatchTarget("components/Card.js")
|
||||
@@ -509,7 +587,7 @@ module.exports = function (ty) {
|
||||
dir: {
|
||||
input: 'src',
|
||||
includes: '_includes',
|
||||
output: '_site',
|
||||
output: outputDir,
|
||||
},
|
||||
templateFormats: ['md', 'njk', 'html'],
|
||||
markdownTemplateEngine: 'njk',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const customizeDocs = require("./customize_docs_frontmatter")
|
||||
|
||||
const repoDir = path.resolve(__dirname, "..")
|
||||
|
||||
const docs = {
|
||||
from: "docs",
|
||||
to: "src/docs",
|
||||
exclude: [/^contributing\//, /^rfcs\//, /^dependencies\//, /^LINKS\.md$/, /^lang\/[^/]+\/README\.md$/],
|
||||
}
|
||||
|
||||
const sources = [
|
||||
docs,
|
||||
{ from: "docs/links/images", to: "src/link-images", exclude: [] },
|
||||
{ from: "blog", to: "src/blog", exclude: [/^README\.md$/, /^new\//] },
|
||||
{ from: "images", to: "src/images", exclude: [] },
|
||||
{ from: "PRIVACY.md", to: "src/privacy.md", exclude: [] },
|
||||
]
|
||||
|
||||
function sourcePath(source, file = "") {
|
||||
return path.join(repoDir, source.from, file)
|
||||
}
|
||||
|
||||
function targetPath(source, file = "") {
|
||||
return path.join(__dirname, source.to, file)
|
||||
}
|
||||
|
||||
function relativeFiles(root, dir = "") {
|
||||
if (!fs.statSync(path.join(root, dir)).isDirectory()) return [dir]
|
||||
return fs.readdirSync(path.join(root, dir)).flatMap((name) => relativeFiles(root, path.join(dir, name)))
|
||||
}
|
||||
|
||||
function isExcluded(source, file) {
|
||||
return source.exclude.some((pattern) => pattern.test(file))
|
||||
}
|
||||
|
||||
function isDocsMarkdown(source, file) {
|
||||
return source === docs && file.endsWith(".md")
|
||||
}
|
||||
|
||||
function sourceFiles(source) {
|
||||
return relativeFiles(sourcePath(source)).filter((file) => !isExcluded(source, file))
|
||||
}
|
||||
|
||||
function copyFile(source, file) {
|
||||
fs.mkdirSync(path.dirname(targetPath(source, file)), { recursive: true })
|
||||
fs.copyFileSync(sourcePath(source, file), targetPath(source, file))
|
||||
}
|
||||
|
||||
function writeDocsMarkdown() {
|
||||
const markdownFiles = sourceFiles(docs).filter((file) => isDocsMarkdown(docs, file))
|
||||
customizeDocs(sourcePath(docs), targetPath(docs), markdownFiles)
|
||||
}
|
||||
|
||||
function copySources() {
|
||||
sources.filter((source) => fs.existsSync(sourcePath(source))).forEach((source) => {
|
||||
fs.rmSync(targetPath(source), { recursive: true, force: true })
|
||||
sourceFiles(source).filter((file) => !isDocsMarkdown(source, file)).forEach((file) => copyFile(source, file))
|
||||
})
|
||||
writeDocsMarkdown()
|
||||
}
|
||||
|
||||
function syncFile(source, event, file) {
|
||||
if (event === "unlink" || event === "unlinkDir") fs.rmSync(targetPath(source, file), { recursive: true, force: true })
|
||||
if (isDocsMarkdown(source, file) || (source === docs && event === "unlinkDir")) writeDocsMarkdown()
|
||||
else if (event === "add" || event === "change") copyFile(source, file)
|
||||
}
|
||||
|
||||
function watchSources() {
|
||||
const chokidar = require("chokidar")
|
||||
sources.forEach((source) => {
|
||||
chokidar.watch(sourcePath(source), { ignoreInitial: true }).on("all", (event, changedPath) => {
|
||||
const file = path.relative(sourcePath(source), changedPath)
|
||||
if (!isExcluded(source, file)) syncFile(source, event, file)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (process.argv.includes("--watch")) watchSources()
|
||||
else copySources()
|
||||
@@ -2,93 +2,62 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const matter = require('gray-matter');
|
||||
|
||||
const directoryPath = path.resolve(__dirname, 'src/docs');
|
||||
const langFolder = 'lang';
|
||||
const enFiles = {};
|
||||
|
||||
function traverseDirectory(directory, currentLanguage = 'en', result = {}, callback) {
|
||||
const filesAndDirectories = fs.readdirSync(directory);
|
||||
|
||||
filesAndDirectories.forEach((fileOrDirectoryName) => {
|
||||
const fullPath = path.join(directory, fileOrDirectoryName);
|
||||
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
// If the subdirectory is inside the 'lang' folder, update the current language
|
||||
if (directory.endsWith('/lang')) {
|
||||
currentLanguage = fileOrDirectoryName;
|
||||
}
|
||||
|
||||
// Recursively traverse the subdirectories
|
||||
traverseDirectory(fullPath, currentLanguage, result, callback);
|
||||
} else {
|
||||
// Process the file only if it has the '.md' extension
|
||||
if (path.extname(fullPath) === '.md') {
|
||||
// Add the language to the file's language array or create a new array if it doesn't exist
|
||||
const fileName = path.basename(fullPath, '.md');
|
||||
if (!result[fileName]) {
|
||||
result[fileName] = [];
|
||||
}
|
||||
result[fileName].push(currentLanguage);
|
||||
}
|
||||
if (callback) {
|
||||
callback(fullPath, currentLanguage);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
function docLanguage(relativePath) {
|
||||
const [folder, language] = relativePath.split(path.sep);
|
||||
return folder === 'lang' ? language : 'en';
|
||||
}
|
||||
|
||||
const fileLanguageMapping = traverseDirectory(directoryPath);
|
||||
function customizeDocs(sourceDir, destDir, relativePaths) {
|
||||
const docs = relativePaths.map((relativePath) => {
|
||||
const parsedMatter = matter(fs.readFileSync(path.join(sourceDir, relativePath), 'utf-8'));
|
||||
return {
|
||||
relativePath,
|
||||
fileName: path.basename(relativePath, '.md'),
|
||||
language: docLanguage(relativePath),
|
||||
content: parsedMatter.content,
|
||||
data: { ...parsedMatter.data },
|
||||
};
|
||||
});
|
||||
|
||||
// Update the frontmatter of each Markdown file
|
||||
Object.entries(fileLanguageMapping).forEach(([fileName, languages]) => {
|
||||
// Find and update the frontmatter of each Markdown file
|
||||
traverseDirectory(directoryPath, null, {}, (fullPath, currentLanguage) => {
|
||||
if (path.basename(fullPath) === `${fileName}.md`) {
|
||||
// Read the existing frontmatter
|
||||
const fileContent = fs.readFileSync(fullPath, 'utf-8');
|
||||
const parsedMatter = matter(fileContent);
|
||||
const relativePath = path.relative(directoryPath, fullPath);
|
||||
const languagesByFileName = new Map();
|
||||
docs.forEach(({ fileName, language }) => {
|
||||
if (!languagesByFileName.has(fileName)) languagesByFileName.set(fileName, []);
|
||||
languagesByFileName.get(fileName).push(language);
|
||||
});
|
||||
|
||||
// Calculate the permalink based on the file's location
|
||||
const linkPath = path.relative(directoryPath, fullPath).replace(/\.md$/, '.html');
|
||||
const permalink = `/docs/${linkPath}`.toLowerCase();
|
||||
const enRevisions = new Map(docs
|
||||
.filter((doc) => doc.language === 'en')
|
||||
.map((doc) => [doc.relativePath, doc.data.revision]));
|
||||
|
||||
if (fileName === 'JOIN_TEAM') {
|
||||
parsedMatter.data.active_jobs = true;
|
||||
docs.forEach(({ relativePath, fileName, language, content, data }) => {
|
||||
const permalink = `/docs/${relativePath.replace(/\.md$/, '.html')}`.toLowerCase();
|
||||
|
||||
if (fileName === 'JOIN_TEAM') {
|
||||
data.active_jobs = true;
|
||||
}
|
||||
if (!data.permalink) data.permalink = permalink;
|
||||
|
||||
data.supportedLangsForDoc = languagesByFileName.get(fileName);
|
||||
|
||||
if (!data.layout) data.layout = 'layouts/doc.html';
|
||||
|
||||
if (language === 'en') {
|
||||
data.version = 'new';
|
||||
} else {
|
||||
const enRelativePath = path.join(...relativePath.split(path.sep).slice(2));
|
||||
if (enRevisions.has(enRelativePath)) {
|
||||
const isOld = new Date(data.revision) < new Date(enRevisions.get(enRelativePath));
|
||||
data.version = isOld ? 'old' : 'new';
|
||||
}
|
||||
if (!parsedMatter.data.permalink) parsedMatter.data.permalink = permalink;
|
||||
}
|
||||
|
||||
// Update the frontmatter with the new languages list
|
||||
parsedMatter.data.supportedLangsForDoc = languages;
|
||||
|
||||
// Add the layout value
|
||||
if (!parsedMatter.data.layout) parsedMatter.data.layout = 'layouts/doc.html';
|
||||
|
||||
if (fullPath.startsWith(path.join(directoryPath, langFolder))) {
|
||||
// Non-English files
|
||||
const [language, ...rest] = relativePath.split(path.sep).slice(1);
|
||||
const enFilePath = path.join(directoryPath, ...rest);
|
||||
|
||||
if (enFiles[enFilePath]) {
|
||||
const enRevision = new Date(enFiles[enFilePath].revision);
|
||||
const currentRevision = new Date(parsedMatter.data.revision);
|
||||
|
||||
const isOld = currentRevision < enRevision;
|
||||
// Add the version value
|
||||
parsedMatter.data.version = isOld ? 'old' : 'new';
|
||||
}
|
||||
} else {
|
||||
// English files
|
||||
enFiles[fullPath] = { revision: parsedMatter.data.revision };
|
||||
// Add the version value
|
||||
parsedMatter.data.version = 'new';
|
||||
}
|
||||
|
||||
// Save the updated frontmatter and content back to the file
|
||||
const updatedFileContent = matter.stringify(parsedMatter.content, parsedMatter.data);
|
||||
fs.writeFileSync(fullPath, updatedFileContent, 'utf-8');
|
||||
const destPath = path.join(destDir, relativePath);
|
||||
const updatedFileContent = matter.stringify(content, data);
|
||||
if (!fs.existsSync(destPath) || fs.readFileSync(destPath, 'utf-8') !== updatedFileContent) {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.writeFileSync(destPath, updatedFileContent, 'utf-8');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = customizeDocs;
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"simplex-unique-overlay-card-3-p-2": "End-to-end šifrované zprávy jsou dočasně uchovávány na přenosových serverech SimpleX, dokud nejsou přijaty, poté jsou trvale odstraněny.",
|
||||
"simplex-unique-overlay-card-3-p-3": "Na rozdíl od serverů federovaných sítí (e-mail, XMPP nebo Matrix) servery SimpleX neukládají uživatelské účty, pouze předávají zprávy, čímž chrání soukromí obou stran.",
|
||||
"simplex-unique-overlay-card-4-p-1": "Můžete <strong>použít SimpleX se svými vlastními servery</strong> a přesto komunikovat s lidmi, kteří používají přednastavené servery v aplikacích.",
|
||||
"simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím <a href=\"https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D\" target=\"_blank>buďte ve spojení</a> pro jakoukoli radu a podporu.",
|
||||
"simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím <a href=\"https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D\" target=\"_blank\">buďte ve spojení</a> pro jakoukoli radu a podporu.",
|
||||
"simplex-unique-card-1-p-1": "SimpleX chrání soukromí vašeho profilu, kontaktů a metadat a skrývá je před servery SimpleX sítě a jakýmikoli pozorovateli.",
|
||||
"simplex-unique-card-1-p-2": "Na rozdíl od jakékoli jiné existující síti pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům — <strong>ani náhodná čísla</strong>.",
|
||||
"simplex-unique-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí <strong>přenosného šifrovaného databázového formátu</strong>, který lze exportovat a přenést na jakékoli podporované zařízení.",
|
||||
|
||||
@@ -331,6 +331,7 @@
|
||||
"why-p7": "The oldest human freedom — to speak to another person without being watched — built on infrastructure that cannot betray it.",
|
||||
"why-p8": "Because we destroyed the power to know who you are. So that <em>your</em> power can never be taken.",
|
||||
"why-tagline": "Be free in your network.",
|
||||
"why-film": "Watch the film",
|
||||
"why-footer-link": "Why we are building it",
|
||||
"send-file": "Send file",
|
||||
"file-desc": "Send files securely with end-to-end encryption — no accounts, no tracking.",
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const matter = require("gray-matter")
|
||||
const parse5 = require("parse5")
|
||||
|
||||
const siteLocation = "https://simplex.chat"
|
||||
|
||||
const htmlPages = new Set([
|
||||
"index",
|
||||
"why",
|
||||
"messaging",
|
||||
"fdroid",
|
||||
"contact",
|
||||
"invitation",
|
||||
"file",
|
||||
"crowdfunding",
|
||||
"crowdfunding-news",
|
||||
"news",
|
||||
"livestream",
|
||||
"blog",
|
||||
"links",
|
||||
"directory",
|
||||
])
|
||||
|
||||
const removedTags = new Set([
|
||||
"script",
|
||||
"style",
|
||||
"noscript",
|
||||
"svg",
|
||||
"button",
|
||||
"select",
|
||||
"input",
|
||||
"textarea",
|
||||
])
|
||||
|
||||
const removedIds = new Set([
|
||||
"navbar",
|
||||
"mobile-header",
|
||||
])
|
||||
|
||||
const removedClasses = new Set([
|
||||
"footer",
|
||||
"glossary-tooltip",
|
||||
"glossary-overlay",
|
||||
])
|
||||
|
||||
const blockTags = new Set([
|
||||
"address",
|
||||
"article",
|
||||
"aside",
|
||||
"dd",
|
||||
"details",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"form",
|
||||
"header",
|
||||
"main",
|
||||
"nav",
|
||||
"section",
|
||||
"summary",
|
||||
])
|
||||
|
||||
const codeFencePattern = /(^```[\s\S]*?^```[^\n]*$)/gm
|
||||
const linkTargetPattern = /\]\((<[^>]*>|[^)\s]+)/g
|
||||
const referenceTargetPattern = /^( {0,3}\[[^\]]+\]:[ \t]*)(<[^>]*>|\S+)/gm
|
||||
const textEntityPattern = /&(?!lt;|gt;|amp;)(#\d+|#x[0-9a-f]+|[a-z]+\d*);/gi
|
||||
const headingPattern = /^#\s+(.+)$/m
|
||||
const invisibleCharacters = /[]/g
|
||||
|
||||
function markdownKind(inputPath, langs) {
|
||||
const relativePath = path.relative("src", inputPath)
|
||||
if (/^docs\/(?!lang\/).+\.md$/.test(relativePath)) return "source"
|
||||
if (/^blog\/[^/]+\.md$/.test(relativePath)) return "source"
|
||||
if (relativePath === "privacy.md" || relativePath === "token.md") return "source"
|
||||
const parts = relativePath.split("/")
|
||||
const fileName = parts.length === 1 ? parts[0] : parts.length === 2 && langs.includes(parts[0]) ? parts[1] : ""
|
||||
return fileName.endsWith(".html") && htmlPages.has(fileName.slice(0, -5)) ? "html" : null
|
||||
}
|
||||
|
||||
function markdownUrl(url, langs) {
|
||||
if (url.endsWith(".html")) return url.slice(0, -5) + ".md"
|
||||
const isRoot = url === "/" || langs.some((lang) => url === `/${lang}/`)
|
||||
return isRoot ? url + "index.md" : url.slice(0, -1) + ".md"
|
||||
}
|
||||
|
||||
function isMarkdownPagePath(pathname) {
|
||||
const isDoc = pathname.startsWith("/docs/") && !pathname.startsWith("/docs/lang/")
|
||||
return pathname.endsWith(".html") && (isDoc || pathname.startsWith("/blog/"))
|
||||
}
|
||||
|
||||
function absoluteMarkdownLink(link, inputPath, resolveLink) {
|
||||
if (link.startsWith("<")) return `<${absoluteMarkdownLink(link.slice(1, -1), inputPath, resolveLink)}>`
|
||||
if (link.startsWith("#")) return link
|
||||
let resolved
|
||||
try {
|
||||
resolved = resolveLink(link, { page: { inputPath } })
|
||||
} catch {
|
||||
return link
|
||||
}
|
||||
if (!resolved.startsWith("/")) return resolved
|
||||
const [pathname, fragment] = resolved.split("#")
|
||||
const target = isMarkdownPagePath(pathname) ? pathname.slice(0, -5) + ".md" : pathname
|
||||
return siteLocation + target + (fragment === undefined ? "" : "#" + fragment)
|
||||
}
|
||||
|
||||
function markdownFromSource(inputPath, resolveLink) {
|
||||
const { content } = matter(fs.readFileSync(inputPath, "utf8"))
|
||||
const rewrite = (link) => absoluteMarkdownLink(link, inputPath, resolveLink)
|
||||
const markdown = content
|
||||
.split(codeFencePattern)
|
||||
.map((part, index) => index % 2 === 1 ? part : part
|
||||
.replace(linkTargetPattern, (_, link) => "](" + rewrite(link))
|
||||
.replace(referenceTargetPattern, (_, prefix, link) => prefix + rewrite(link))
|
||||
.replace(textEntityPattern, plainText))
|
||||
.join("")
|
||||
return markdown.trim() + "\n"
|
||||
}
|
||||
|
||||
function markdownTitle(inputPath) {
|
||||
const { data, content } = matter(fs.readFileSync(inputPath, "utf8"))
|
||||
return plainText(data.title || content.match(headingPattern)?.[1] || "")
|
||||
}
|
||||
|
||||
function attribute(node, name) {
|
||||
return node.attrs.find((attr) => attr.name === name)?.value
|
||||
}
|
||||
|
||||
function isRemoved(element) {
|
||||
const classes = (attribute(element, "class") || "").split(/\s+/)
|
||||
return removedTags.has(element.tagName)
|
||||
|| (element.tagName === "template" && attribute(element, "data-markdown") === undefined)
|
||||
|| attribute(element, "data-markdown-skip") !== undefined
|
||||
|| removedIds.has(attribute(element, "id"))
|
||||
|| classes.some((name) => removedClasses.has(name))
|
||||
}
|
||||
|
||||
function childElements(node) {
|
||||
return node.childNodes.filter((child) => child.tagName && !isRemoved(child))
|
||||
}
|
||||
|
||||
function findElements(node, predicate) {
|
||||
return childElements(node).flatMap((child) => predicate(child) ? [child] : findElements(child, predicate))
|
||||
}
|
||||
|
||||
function findElement(node, predicate) {
|
||||
return findElements(node, predicate)[0]
|
||||
}
|
||||
|
||||
function textContent(node) {
|
||||
return node.nodeName === "#text" ? node.value : (node.childNodes || []).map(textContent).join("")
|
||||
}
|
||||
|
||||
function singleLine(text) {
|
||||
return text.trim().replace(/\s*\n\s*/g, " ")
|
||||
}
|
||||
|
||||
function emphasis(marker, content) {
|
||||
return content.replace(/^(\s*)([\s\S]*?)(\s*)$/, (_, leading, text, trailing) =>
|
||||
text ? leading + marker + text + marker + trailing : leading + trailing)
|
||||
}
|
||||
|
||||
function linkMarkdown(element, content, pageUrl) {
|
||||
const href = attribute(element, "href")
|
||||
if (href && href.startsWith("javascript:")) return ""
|
||||
const image = findElement(element, (child) => child.tagName === "img" && attribute(child, "alt") !== undefined)
|
||||
const label = attribute(element, "aria-label") || singleLine(content) || attribute(element, "title") || (image && attribute(image, "alt")) || ""
|
||||
if (!href) return label
|
||||
return label ? `[${label}](${new URL(href, siteLocation + pageUrl).href})` : ""
|
||||
}
|
||||
|
||||
function listMarkdown(element, ordered, pageUrl) {
|
||||
const items = childElements(element).filter((child) => child.tagName === "li")
|
||||
const lines = items.map((item, index) => (ordered ? `${index + 1}. ` : "- ") + singleLine(childrenMarkdown(item, pageUrl)))
|
||||
return `\n\n${lines.join("\n")}\n\n`
|
||||
}
|
||||
|
||||
function tableMarkdown(element, pageUrl) {
|
||||
const rows = findElements(element, (child) => child.tagName === "tr").map((row) =>
|
||||
childElements(row).map((cell) => singleLine(childrenMarkdown(cell, pageUrl)).replace(/\|/g, "\\|")))
|
||||
if (rows.length === 0) return ""
|
||||
const width = Math.max(...rows.map((row) => row.length))
|
||||
const line = (cells) => "| " + Array.from({ length: width }, (_, index) => cells[index] || "").join(" | ") + " |"
|
||||
const [header, ...body] = rows
|
||||
return `\n\n${[line(header), line(Array(width).fill("---")), ...body.map(line)].join("\n")}\n\n`
|
||||
}
|
||||
|
||||
function nodeMarkdown(node, pageUrl) {
|
||||
if (node.nodeName === "#text") return node.value.replace(invisibleCharacters, "").replace(/\s+/g, " ")
|
||||
if (!node.tagName || isRemoved(node)) return ""
|
||||
const tag = node.tagName
|
||||
const content = () => childrenMarkdown(node, pageUrl)
|
||||
switch (tag) {
|
||||
case "h1":
|
||||
case "h2":
|
||||
case "h3":
|
||||
case "h4":
|
||||
case "h5":
|
||||
case "h6":
|
||||
return `\n\n${"#".repeat(Number(tag[1]))} ${singleLine(content())}\n\n`
|
||||
case "p":
|
||||
return `\n\n${content().trim()}\n\n`
|
||||
case "br":
|
||||
return "\n"
|
||||
case "hr":
|
||||
return "\n\n---\n\n"
|
||||
case "a":
|
||||
return linkMarkdown(node, content(), pageUrl)
|
||||
case "strong":
|
||||
case "b":
|
||||
return emphasis("**", content())
|
||||
case "em":
|
||||
case "i":
|
||||
return emphasis("*", content())
|
||||
case "code":
|
||||
return "`" + textContent(node) + "`"
|
||||
case "pre":
|
||||
return "\n\n```\n" + textContent(node).trim() + "\n```\n\n"
|
||||
case "sup":
|
||||
return `[${content().trim()}]`
|
||||
case "img":
|
||||
return ""
|
||||
case "ul":
|
||||
case "ol":
|
||||
return listMarkdown(node, tag === "ol", pageUrl)
|
||||
case "table":
|
||||
return tableMarkdown(node, pageUrl)
|
||||
case "blockquote":
|
||||
return "\n\n" + content().trim().split("\n").map((line) => "> " + line).join("\n") + "\n\n"
|
||||
case "template":
|
||||
return childrenMarkdown(node.content, pageUrl)
|
||||
default:
|
||||
return blockTags.has(tag) ? `\n\n${content()}\n\n` : content()
|
||||
}
|
||||
}
|
||||
|
||||
function childrenMarkdown(node, pageUrl) {
|
||||
return node.childNodes.map((child) => nodeMarkdown(child, pageUrl)).join("")
|
||||
}
|
||||
|
||||
function normalizedMarkdown(markdown) {
|
||||
let inFence = false
|
||||
const lines = markdown.split("\n").map((line) => {
|
||||
if (line.startsWith("```")) inFence = !inFence
|
||||
return inFence || line.startsWith("```") ? line : line.trim()
|
||||
})
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
|
||||
}
|
||||
|
||||
function markdownFromHtml(html, pageUrl) {
|
||||
const document = parse5.parse(html)
|
||||
const body = findElement(document, (node) => node.tagName === "body")
|
||||
const title = findElement(document, (node) => node.tagName === "title")
|
||||
const heading = findElement(body, (node) => node.tagName === "h1") ? "" : `# ${singleLine(title ? textContent(title) : "")}\n\n`
|
||||
return normalizedMarkdown(heading + childrenMarkdown(body, pageUrl))
|
||||
}
|
||||
|
||||
function plainText(html) {
|
||||
return textContent(parse5.parseFragment(html))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
markdownKind,
|
||||
markdownUrl,
|
||||
markdownFromSource,
|
||||
markdownTitle,
|
||||
markdownFromHtml,
|
||||
plainText,
|
||||
}
|
||||
@@ -31,12 +31,12 @@
|
||||
"dependencies": {
|
||||
"@plausible-analytics/tracker": "0.4.6",
|
||||
"@simplex-chat/xftp-web": "^0.3.0",
|
||||
"eleventy-plugin-i18n": "^0.1.3",
|
||||
"fs": "^0.0.1-security",
|
||||
"gray-matter": "^4.0.3",
|
||||
"jsdom": "^22.1.0",
|
||||
"lottie-web": "5.12.2",
|
||||
"markdown-it": "^13.0.1",
|
||||
"markdown-it-footnote": "^4.0.0"
|
||||
"markdown-it-footnote": "^4.0.0",
|
||||
"parse5": "^7.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# LLM content for simplex.chat
|
||||
|
||||
## Outputs
|
||||
|
||||
### Index files
|
||||
|
||||
| URL | Content |
|
||||
|---|---|
|
||||
| `/llms.txt` | site name, summary, sections of links to Markdown pages |
|
||||
| `/llms-full.txt` | core pages in one file |
|
||||
| `/docs.md` | every English documentation page |
|
||||
|
||||
### Markdown versions of site pages
|
||||
|
||||
URL rule:
|
||||
|
||||
| Page URL | Markdown URL |
|
||||
|---|---|
|
||||
| `/` | `/index.md` |
|
||||
| `/xx/` (language root) | `/xx/index.md` |
|
||||
| `/x/` | `/x.md` |
|
||||
| `/docs/x.html` | `/docs/x.md` |
|
||||
| `/blog/x.html` | `/blog/x.md` |
|
||||
|
||||
Sources:
|
||||
|
||||
| Pages | Markdown made from |
|
||||
|---|---|
|
||||
| English `docs/**/*.md`, `blog/*.md`, `privacy.md`, `token.md` | source text: front matter removed, links made absolute, HTML entities decoded |
|
||||
| `index`, `why`, `messaging`, `fdroid`, `contact`, `invitation`, `file` (every language) | rendered HTML |
|
||||
| `crowdfunding`, `crowdfunding-news`, `news`, `livestream`, `blog`, `links`, `directory` | rendered HTML |
|
||||
|
||||
Translated docs and blog posts: no Markdown version.
|
||||
|
||||
HTML to Markdown markup:
|
||||
|
||||
| Markup | Effect |
|
||||
|---|---|
|
||||
| `data-markdown-skip` | element left out |
|
||||
| `<template data-markdown>` | content included only in Markdown |
|
||||
| `aria-label`, `title`, image `alt` | link text when the link has no text |
|
||||
|
||||
Removed from every page: navigation, footer, scripts, buttons, form controls, glossary tooltips.
|
||||
|
||||
### LLM-only pages
|
||||
|
||||
| URL | Source |
|
||||
|---|---|
|
||||
| `/wefunder.md` | `_includes/llms/wefunder.md` |
|
||||
| `/llms/facts.md` | `_data/llms_facts.json` via `_includes/llms/facts.md` |
|
||||
| `/llms/facts.json` | `_data/llms_facts.json` |
|
||||
| `/llms/answers.md` | `_includes/llms/answers.md` |
|
||||
| `/llms/agents.md` | `_includes/llms/agents.md` |
|
||||
| `/llms/timeline.md` | `_includes/llms/timeline.md` |
|
||||
|
||||
`/wefunder.md` excludes (17 CFR 227.204(e)):
|
||||
- amount of securities offered
|
||||
- nature of the securities
|
||||
- price
|
||||
- closing date
|
||||
- planned use of proceeds
|
||||
- progress toward the funding target
|
||||
|
||||
### Discovery
|
||||
|
||||
| Item | Content |
|
||||
|---|---|
|
||||
| `/robots.txt` | allow all, sitemap location |
|
||||
| `/sitemap.xml` | every page in `collections.all`, except redirect, `old` and `subscribe` pages |
|
||||
| page `<head>` | `<link rel="alternate" type="text/markdown">` via `markdown-alternate.html` |
|
||||
| `README.md` | link to `/llms.txt` |
|
||||
|
||||
Manual steps:
|
||||
- submit `sitemap.xml` to Google Search Console
|
||||
- submit `sitemap.xml` to Bing Webmaster Tools
|
||||
|
||||
### WHY film
|
||||
|
||||
`https://www.youtube.com/watch?v=k-GhLNZm8G0` linked from:
|
||||
- `docs/WHY.md`
|
||||
- `why.html` (key `why-film`)
|
||||
- `/llms.txt`
|
||||
|
||||
## Build mechanics
|
||||
|
||||
`website/markdown_pages.js`:
|
||||
- `markdownKind(inputPath, langs)`: `source`, `html` or none
|
||||
- `markdownUrl(url, langs)`
|
||||
- `markdownFromSource(inputPath, resolveLink)`
|
||||
- `markdownTitle(inputPath)`
|
||||
- `markdownFromHtml(html, pageUrl)`
|
||||
- `plainText(html)`
|
||||
|
||||
`.eleventy.js`:
|
||||
- transform `markdownPages`: writes the Markdown version next to each qualifying page
|
||||
- filters: `markdownUrl`, `markdownAlternate`, `markdownSource`, `markdownTitle`, `plainText`
|
||||
- collection `markdownDocs`
|
||||
|
||||
Templates: `src/llms/*.njk`, each with `permalink` and `eleventyExcludeFromCollections: true`.
|
||||
@@ -5,4 +5,6 @@ set -e
|
||||
cd ..
|
||||
./website/web.sh
|
||||
cd website
|
||||
node copy_sources.js --watch &
|
||||
trap "kill $! 2>/dev/null || true" EXIT
|
||||
npm run start
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"updated": "2026-10-08",
|
||||
"groups": [
|
||||
{
|
||||
"title": "Network and users",
|
||||
"facts": [
|
||||
{
|
||||
"fact": "User identifiers on the network",
|
||||
"value": "None – no phone numbers, usernames, account IDs or random user IDs",
|
||||
"asOf": "2026",
|
||||
"sources": [{ "title": "How SimpleX works", "url": "https://simplex.chat/docs/simplex.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Monthly active users",
|
||||
"value": "600,000+",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "User growth",
|
||||
"value": "about 70% in the last six months",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "App downloads",
|
||||
"value": "3 million+",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Messages per day via preset servers",
|
||||
"value": "20 million+",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Paid marketing",
|
||||
"value": "none before the equity crowdfunding campaign",
|
||||
"asOf": "until August 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Independent publications about SimpleX",
|
||||
"value": "200+ reviews, posts and videos in 20+ languages",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Links", "url": "https://simplex.chat/links.md" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Network participants",
|
||||
"facts": [
|
||||
{
|
||||
"fact": "Servers run by volunteers",
|
||||
"value": "about 1,000",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Preset server operators",
|
||||
"value": "SimpleX Chat and Flux",
|
||||
"asOf": "since December 2024",
|
||||
"sources": [{ "title": "Blog", "url": "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Public channels created",
|
||||
"value": "12,500+ in the five months since scalable channels shipped",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Curated directory",
|
||||
"value": "500+ public groups and channels, 30% active daily",
|
||||
"asOf": "October 2026",
|
||||
"sources": [
|
||||
{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" },
|
||||
{ "title": "Directory", "url": "https://simplex.chat/directory.md" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fact": "Businesses supporting customers over SimpleX",
|
||||
"value": "50+",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "GitHub",
|
||||
"value": "19,000+ stars, 1,400+ forks",
|
||||
"asOf": "October 2026",
|
||||
"sources": [
|
||||
{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" },
|
||||
{ "title": "GitHub", "url": "https://github.com/simplex-chat/simplex-chat" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Security",
|
||||
"facts": [
|
||||
{
|
||||
"fact": "End-to-end encryption",
|
||||
"value": "double ratchet with post-quantum key exchange (sntrup761) on every ratchet step, on by default in direct conversations",
|
||||
"asOf": "since v5.7, April 2024",
|
||||
"sources": [{ "title": "Blog", "url": "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "IP address protection",
|
||||
"value": "private message routing: 2-hop routing where the sender chooses the forwarding relay and the recipient chooses the destination relay, so neither side observes the other's IP address",
|
||||
"asOf": "since v5.8, June 2024",
|
||||
"sources": [{ "title": "Blog", "url": "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Implementation security assessment",
|
||||
"value": "Trail of Bits",
|
||||
"asOf": "October 2022",
|
||||
"sources": [
|
||||
{ "title": "Blog", "url": "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md" },
|
||||
{ "title": "Report", "url": "https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SimpleX_Chat_Final_Report_11_03_2022.pdf" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fact": "Cryptographic design review",
|
||||
"value": "Trail of Bits",
|
||||
"asOf": "July 2024",
|
||||
"sources": [
|
||||
{ "title": "Blog", "url": "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md" },
|
||||
{ "title": "Report", "url": "https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SimpleX_Design_Review_2024_Summary_Report_12_08_2024.pdf" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fact": "Third Trail of Bits audit",
|
||||
"value": "completed, to be published",
|
||||
"asOf": "2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Licence",
|
||||
"value": "AGPL-3.0",
|
||||
"asOf": "2026",
|
||||
"sources": [{ "title": "LICENSE", "url": "https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Company and funding",
|
||||
"facts": [
|
||||
{
|
||||
"fact": "Founder",
|
||||
"value": "Evgeny Poberezkin, creator of the Ajv JSON Schema validator",
|
||||
"asOf": "2026",
|
||||
"sources": [{ "title": "Ajv", "url": "https://github.com/ajv-validator/ajv" }]
|
||||
},
|
||||
{
|
||||
"fact": "Total investment to date",
|
||||
"value": "$1.7M",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "User donations",
|
||||
"value": "$650,000+, including from Vitalik Buterin",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Prior investors",
|
||||
"value": "Village Global and angel investors (pre-seed, closed in July 2022); Jack Dorsey and Asymmetric Capital Partners (closed in July 2023)",
|
||||
"asOf": "announced April 2023 and August 2024",
|
||||
"sources": [{ "title": "Blog", "url": "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Equity crowdfunding",
|
||||
"value": "on Wefunder, launched 20 August 2026",
|
||||
"asOf": "October 2026",
|
||||
"sources": [
|
||||
{ "title": "Blog", "url": "https://simplex.chat/blog/20260819-simplex-chat-crowdfunding.md" },
|
||||
{ "title": "Crowdfunding", "url": "https://simplex.chat/crowdfunding.md" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fact": "Governance",
|
||||
"value": "SimpleX Network Consortium: an agreement between the non-profit SimpleX Network Foundation and SimpleX Chat",
|
||||
"asOf": "2026",
|
||||
"sources": [{ "title": "Blog", "url": "https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md" }]
|
||||
},
|
||||
{
|
||||
"fact": "Business model",
|
||||
"value": "free private messaging; channels and businesses pay for public names, business messaging and servers for big channels",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "Wefunder memo", "url": "https://simplex.chat/wefunder.md" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Public names",
|
||||
"facts": [
|
||||
{
|
||||
"fact": "Test names (.testing)",
|
||||
"value": "available since v7-beta",
|
||||
"asOf": "July 2026",
|
||||
"sources": [{ "title": "Blog", "url": "https://simplex.chat/blog/20260722-simplex-public-names.md" }]
|
||||
},
|
||||
{
|
||||
"fact": ".simplex names sale",
|
||||
"value": "starts 12 December 2026, 18:00 UTC",
|
||||
"asOf": "October 2026",
|
||||
"sources": [{ "title": "simplex.domains", "url": "https://simplex.domains/" }]
|
||||
},
|
||||
{
|
||||
"fact": "Registry",
|
||||
"value": "SimpleX Name Service, a fork of ENS on Ethereum, without off-chain indexer or metadata services",
|
||||
"asOf": "July 2026",
|
||||
"sources": [{ "title": "Whitepaper", "url": "https://simplex.chat/docs/protocol/names-overview.md" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,11 +17,11 @@
|
||||
<p class="text-grey-black dark:text-white text-base mb-[24px]">{{ "contact-hero-p-3" | i18n({}, lang ) | safe }}</p>
|
||||
|
||||
<div class="flex items-center justify-center gap-4 flex-wrap">
|
||||
<a href="https://apps.apple.com/us/app/simplex-chat/id1605771084" target="_blank"><img class="h-[40px] w-auto" src="/img/new/apple_store.svg" /></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=chat.simplex.app" target="_blank" title="Public iOS preview on TestFlight"><img class="h-[40px] w-auto" src="/img/new/google_play.svg" /></a>
|
||||
<a href="https://apps.apple.com/us/app/simplex-chat/id1605771084" target="_blank"><img class="h-[40px] w-auto" src="/img/new/apple_store.svg" alt="App Store" /></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=chat.simplex.app" target="_blank" title="Google Play"><img class="h-[40px] w-auto" src="/img/new/google_play.svg" alt="Google Play" /></a>
|
||||
<a href="{{ '' if lang == 'en' else '/' ~ lang }}/fdroid" title="SimpleX F-Droid Repository"><img class="h-[40px] w-auto" src="/img/new/f_droid.svg" /></a>
|
||||
<a href="https://testflight.apple.com/join/DWuT2LQu" target="_blank"><img class="h-[40px] w-auto" src="/img/new/testflight.png" /></a>
|
||||
<a href="https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk" target="_blank"><img class="h-[40px] w-auto" src="/img/new/apk_icon.png" /></a>
|
||||
<a href="https://testflight.apple.com/join/DWuT2LQu" target="_blank"><img class="h-[40px] w-auto" src="/img/new/testflight.png" alt="TestFlight" /></a>
|
||||
<a href="https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk" target="_blank"><img class="h-[40px] w-auto" src="/img/new/apk_icon.png" alt="Android APK" /></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
<div class="flex flex-col justify-center items-center p-4 w-full max-w-[468px] min-h-[131px] rounded-[30px] border-[1px] border-[#A8B0B4] dark:border-white border-opacity-60 mb-6 relative">
|
||||
<p class="text-xl font-medium text-grey-black dark:text-white mb-4">{{ "install-simplex-app" | i18n({}, lang ) | safe }}</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<a class="apple-store-btn hidden" href="https://apps.apple.com/us/app/simplex-chat/id1605771084" target="_blank"><img class="h-[40px] w-auto" src="/img/new/apple_store.svg" /></a>
|
||||
<a class="google-play-btn hidden" href="https://play.google.com/store/apps/details?id=chat.simplex.app" target="_blank" title="Public iOS preview on TestFlight"><img class="h-[40px] w-auto" src="/img/new/google_play.svg" /></a>
|
||||
<a class="apple-store-btn hidden" href="https://apps.apple.com/us/app/simplex-chat/id1605771084" target="_blank"><img class="h-[40px] w-auto" src="/img/new/apple_store.svg" alt="App Store" /></a>
|
||||
<a class="google-play-btn hidden" href="https://play.google.com/store/apps/details?id=chat.simplex.app" target="_blank" title="Google Play"><img class="h-[40px] w-auto" src="/img/new/google_play.svg" alt="Google Play" /></a>
|
||||
<a class="f-droid-btn hidden" href="{{ '' if lang == 'en' else '/' ~ lang }}/fdroid" title="SimpleX F-Droid Repository"><img class="h-[40px] w-auto" src="/img/new/f_droid.svg" /></a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
{% endif %}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#F3FAFF">
|
||||
<link href="/css/tailwind.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/blog.css" />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
layout: layouts/main.html
|
||||
---
|
||||
<div data-simplex-channel-preview
|
||||
data-channel-link="{{ channel.link }}"
|
||||
data-channel-id="{{ channel.id }}"
|
||||
data-relay-domains="{{ channel.relays | join(',') }}"
|
||||
data-app-download-buttons="off"
|
||||
data-color-scheme="site"
|
||||
data-top-offset="54"
|
||||
></div>
|
||||
<template data-markdown>{{ content | safe }}<p>Recent posts in JSON, with the channel profile and subscriber count, from any of the channel relays: {% for relay in channel.relays %}<a href="https://{{ relay }}/channel/{{ channel.id }}.json">{{ relay }}</a>{% if not loop.last %}, {% endif %}{% endfor %}.</p></template>
|
||||
<script src="/js/channel-preview.js"></script>
|
||||
@@ -16,6 +16,7 @@
|
||||
<meta name="Content-Type" content="text/html;charset=utf-8"/>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico"/>
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link href="/css/tailwind.css" rel="stylesheet"/>
|
||||
<link id="prism-theme" rel="stylesheet" href="/css/prism-light.min.css"/>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<meta name="Content-Type" content="text/html;charset=utf-8" />
|
||||
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#F3FAFF">
|
||||
<link href="/css/tailwind.css" rel="stylesheet" />
|
||||
<link id="prism-theme" rel="stylesheet" href="/css/prism-light.min.css" />
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg"/>
|
||||
<meta name="twitter:card" content="summary"/>
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico"/>
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#F3FAFF">
|
||||
|
||||
<link rel="stylesheet" href="/css/swiper-bundle.min.css">
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#F3FAFF">
|
||||
<link href="/css/tailwind.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/blog.css" />
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
{% endif %}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#F3FAFF">
|
||||
<link href="/css/tailwind.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/blog.css" />
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#F3FAFF">
|
||||
<link href="/css/tailwind.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/blog.css" />
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# SimpleX for AI agents and developers
|
||||
|
||||
SimpleX is a messaging network without user identifiers. A program connects to it the same way a person does: it holds its own keys, creates addresses it can share and delete, and needs no phone number, platform account or API key. The network does not distinguish a person from an agent, and no platform can suspend an agent's identity or see who it talks to.
|
||||
|
||||
What you can build:
|
||||
|
||||
- an assistant that its owner reaches privately from the SimpleX Chat app, on any device;
|
||||
- a support or search bot for many users, with an LLM or without one;
|
||||
- communication between agents with no platform in the middle;
|
||||
- moderation, broadcast and bridge bots for groups and channels.
|
||||
|
||||
## Make a bot in five minutes
|
||||
|
||||
### Node.js
|
||||
|
||||
```sh
|
||||
npm i simplex-chat
|
||||
```
|
||||
|
||||
```javascript
|
||||
(async () => {
|
||||
const {bot} = await import("simplex-chat")
|
||||
await bot.run({
|
||||
profile: {displayName: "Squaring bot example", fullName: ""},
|
||||
dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"},
|
||||
options: {
|
||||
addressSettings: {welcomeMessage: "Send a number, I will square it."},
|
||||
},
|
||||
onMessage: async (ci, content, chat) => {
|
||||
const n = +content.text
|
||||
const reply = typeof n === "number" && !isNaN(n)
|
||||
? `${n} * ${n} = ${n * n}`
|
||||
: `this is not a number`
|
||||
await chat.apiSendTextReply(ci, reply)
|
||||
}
|
||||
})
|
||||
})()
|
||||
```
|
||||
|
||||
On start, the bot creates its SimpleX address and prints it (`Bot address: ...`). Open this address in the SimpleX Chat app to talk to the bot. Library: [simplex-chat on npm](https://www.npmjs.com/package/simplex-chat), [source and examples](https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-nodejs).
|
||||
|
||||
### Python (3.11+)
|
||||
|
||||
```sh
|
||||
pip install simplex-chat
|
||||
```
|
||||
|
||||
```python
|
||||
import re
|
||||
from simplex_chat import Bot, BotProfile, Message, SqliteDb, TextMessage
|
||||
|
||||
bot = Bot(
|
||||
profile=BotProfile(display_name="Squaring bot"),
|
||||
db=SqliteDb(file_prefix="./squaring_bot"),
|
||||
welcome="Send me a number, I'll square it.",
|
||||
)
|
||||
|
||||
@bot.on_message(content_type="text", text=re.compile(r"^-?\d+(\.\d+)?$"))
|
||||
async def square(msg: TextMessage) -> None:
|
||||
n = float(msg.text or "0")
|
||||
await msg.reply(f"{n} * {n} = {n * n}")
|
||||
|
||||
@bot.on_message(content_type="text")
|
||||
async def fallback(msg: Message) -> None:
|
||||
await msg.reply("Send me a number, like 7 or 3.14.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
bot.run()
|
||||
```
|
||||
|
||||
The bot logs its address on start. Library: [simplex-chat on PyPI](https://pypi.org/project/simplex-chat/), [source and examples](https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-python).
|
||||
|
||||
## Use the terminal client as an API server, from any language
|
||||
|
||||
Install the terminal client on Linux or macOS:
|
||||
|
||||
```sh
|
||||
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash
|
||||
```
|
||||
|
||||
Run it as a local WebSocket server:
|
||||
|
||||
```sh
|
||||
simplex-chat -p 5225
|
||||
```
|
||||
|
||||
Your program connects to `ws://localhost:5225` and sends JSON commands, for example to create a long-term address:
|
||||
|
||||
```json
|
||||
{"corrId": "1", "cmd": "/address"}
|
||||
```
|
||||
|
||||
Responses carry the same `corrId`; chat events, such as received messages, arrive as separate messages. The command strings are the same as in the app's chat console. The WebSocket API has no authentication and binds to localhost: run your program on the same machine, or put a TLS proxy with authentication in front of it.
|
||||
|
||||
- [Bot API guide](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/README.md)
|
||||
- [Commands](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/api/COMMANDS.md)
|
||||
- [Events](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/api/EVENTS.md)
|
||||
- [Types](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/api/TYPES.md)
|
||||
- [Terminal client guide](https://simplex.chat/docs/cli.md)
|
||||
|
||||
## Build the terminal client from source
|
||||
|
||||
With Docker, on Linux:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/simplex-chat/simplex-chat.git
|
||||
cd simplex-chat
|
||||
git checkout stable
|
||||
DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
|
||||
```
|
||||
|
||||
With GHC 9.6.3 and cabal 3.10.1.0 installed via [GHCup](https://www.haskell.org/ghcup/), on Linux:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/simplex-chat/simplex-chat.git
|
||||
cd simplex-chat
|
||||
git checkout stable
|
||||
apt-get update && apt-get install -y build-essential libgmp3-dev zlib1g-dev
|
||||
cp scripts/cabal.project.local.linux cabal.project.local
|
||||
cabal update
|
||||
cabal install simplex-chat
|
||||
```
|
||||
|
||||
On macOS, install OpenSSL with `brew install openssl@3.0` and use `scripts/cabal.project.local.mac` instead. Full instructions: [terminal client guide](https://simplex.chat/docs/cli.md).
|
||||
|
||||
## OpenClaw
|
||||
|
||||
A community plugin connects [OpenClaw](https://github.com/openclaw/openclaw) to SimpleX through a locally running terminal client, so you can talk to your OpenClaw assistant from the SimpleX Chat app:
|
||||
|
||||
```sh
|
||||
openclaw plugins install @dangoldbj/openclaw-simplex
|
||||
openclaw plugins enable openclaw-simplex
|
||||
```
|
||||
|
||||
Source: [github.com/dangoldbj/openclaw-simplex](https://github.com/dangoldbj/openclaw-simplex).
|
||||
|
||||
## Run your own servers
|
||||
|
||||
- [SMP messaging server](https://simplex.chat/docs/server.md)
|
||||
- [XFTP file server](https://simplex.chat/docs/xftp-server.md)
|
||||
|
||||
## Protocol specifications
|
||||
|
||||
- [SimpleX network overview](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/overview-tjr.md)
|
||||
- [SimpleX Messaging Protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/simplex-messaging.md)
|
||||
- [Agent protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/agent-protocol.md)
|
||||
- [Post-quantum double ratchet](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/pqdr.md)
|
||||
- [Security model](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/security.md)
|
||||
- [XFTP file transfer protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/xftp.md)
|
||||
- [Chat protocol](https://simplex.chat/docs/protocol/simplex-chat.md)
|
||||
|
||||
## Ask the SimpleX team
|
||||
|
||||
In the SimpleX Chat app, open the "Ask SimpleX Team" chat, or connect via [simplex.chat/contact](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). A support bot answers first:
|
||||
|
||||
- send `/team` to reach the team – not a bot; the team replies within 24 hours, or 48 hours at weekends;
|
||||
- send `/grok` for an instant answer from an AI assistant.
|
||||
@@ -0,0 +1,73 @@
|
||||
# SimpleX: answers to common questions
|
||||
|
||||
Short answers with links to the full documentation. Numbers are in [key facts](https://simplex.chat/llms/facts.md).
|
||||
|
||||
## What is SimpleX, in one sentence?
|
||||
|
||||
SimpleX is the first messaging network without user identifiers of any kind – no phone numbers, usernames or random IDs – so nobody, including its servers, can see who talks to whom.
|
||||
|
||||
## What is the difference between SimpleX network and SimpleX Chat?
|
||||
|
||||
SimpleX network is the open protocol and the servers that relay messages. SimpleX Chat is the free, open-source app (iOS, Android, Linux, macOS, Windows and a terminal client) that uses the network, and also the company that builds both.
|
||||
|
||||
## How does SimpleX deliver messages without user identifiers?
|
||||
|
||||
Each conversation uses its own pair of one-way message queues, one for each direction, usually on different servers. A queue has a random address that is used only for that conversation. Contacts and groups are stored only on users' devices. To connect, people share a one-time link or a QR code, which also carries the keys for end-to-end encryption, so servers cannot substitute them. Details: [How SimpleX works](https://simplex.chat/docs/simplex.md), [protocol overview](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/overview-tjr.md).
|
||||
|
||||
## What can servers and network observers see?
|
||||
|
||||
Servers relay end-to-end encrypted messages and delete them once they are delivered. A server sees the random addresses of the queues it hosts and the times when messages arrive and are received. All messages are padded to the same size. With private message routing, the sender chooses the forwarding server and the recipient chooses the destination server, so neither side can observe the other's IP address. A server cannot see who talks to whom, cannot read messages, and cannot add or change them undetectably. Details: [security model](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/security.md).
|
||||
|
||||
## How strong is the encryption?
|
||||
|
||||
Messages are end-to-end encrypted with a double ratchet that gives forward secrecy and recovery after a key compromise. A post-quantum key exchange (sntrup761) is added on every ratchet step and is on by default in direct conversations. An additional encryption layer between servers and devices prevents correlating sent and received traffic. Details: [post-quantum double ratchet](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/pqdr.md).
|
||||
|
||||
## Why are public channels not end-to-end encrypted?
|
||||
|
||||
By design. No system can provide encrypted messages, scalable broadcast and private participation at the same time. Anyone can join a channel through its public link, so encrypting its content would protect nothing; protecting who participates does. SimpleX channels make participation private: relays see the content, but not subscribers' identities or network addresses, and participation in different channels cannot be linked. Details: [SimpleX Channels](https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md), [channels whitepaper](https://simplex.chat/docs/protocol/channels-overview.md).
|
||||
|
||||
## Is SimpleX audited?
|
||||
|
||||
Yes. Trail of Bits assessed the implementation in October 2022 and reviewed the cryptographic design in July 2024. A third audit was completed in 2026 and will be published. Reports: [2022](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SimpleX_Chat_Final_Report_11_03_2022.pdf), [2024](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SimpleX_Design_Review_2024_Summary_Report_12_08_2024.pdf).
|
||||
|
||||
## What are SimpleX's limitations?
|
||||
|
||||
- Post-quantum key exchange is used in direct conversations, not yet in groups.
|
||||
- An observer who can watch the traffic of both users can confirm that they communicate.
|
||||
- Resolving a public name reveals interest in that name to one resolver server, but not who asked.
|
||||
|
||||
## How does SimpleX compare with Signal, Session, Matrix, Briar and others?
|
||||
|
||||
Every other messaging network assigns users an identifier: Signal and WhatsApp use phone numbers, Matrix uses `@user:server` IDs, and Session, Briar, Cwtch and Nostr identify users by long-term public keys or onion addresses. These identifiers let the network, or anyone observing it, link a user's conversations. SimpleX has none. A side-by-side comparison of encryption properties is on the [messaging page](https://simplex.chat/messaging/#messengers-comparison), and a technical comparison with peer-to-peer protocols is in [How SimpleX works](https://simplex.chat/docs/simplex.md).
|
||||
|
||||
## Can people find or contact me without my consent?
|
||||
|
||||
No. Nobody can contact you unless you share a one-time link or your address. An address is optional and can be changed or deleted without losing contacts.
|
||||
|
||||
## Who controls the network?
|
||||
|
||||
Nobody alone. Anyone can run servers. SimpleX Chat and Flux are preset in the app, and when both are enabled the app uses servers of both operators in each conversation. The SimpleX Network Consortium, an agreement between the non-profit SimpleX Network Foundation and SimpleX Chat, licenses the protocol to the foundation permanently, so it stays available regardless of who owns the company. Details: [blog](https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md).
|
||||
|
||||
## Is SimpleX free and open source?
|
||||
|
||||
Yes. The apps are free, and the code is open source under AGPL-3.0: [github.com/simplex-chat](https://github.com/simplex-chat).
|
||||
|
||||
## How will SimpleX Chat make money?
|
||||
|
||||
Private messaging stays free. Channels and businesses will pay for public names, business messaging and servers for big channels. Users have already donated over $650,000. Details: [Wefunder memo](https://simplex.chat/wefunder.md).
|
||||
|
||||
## What are SimpleX public names?
|
||||
|
||||
Optional, human-readable names for channels and businesses, such as `#example` or `@example.simplex`, registered on a public blockchain and controlled only by their owner's key. Names do not identify users and are not needed to use the network. Sale starts on 12 December 2026. Details: [public names whitepaper](https://simplex.chat/docs/protocol/names-overview.md).
|
||||
|
||||
## How can AI agents and developers use SimpleX?
|
||||
|
||||
An agent connects to the network like a person: it holds its own keys and addresses, and no platform account is needed. See [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md).
|
||||
|
||||
## How can I invest in SimpleX Chat?
|
||||
|
||||
SimpleX Chat runs an equity crowdfunding offering on Wefunder: [wefunder.com/simplex.chat](https://wefunder.com/simplex.chat). The memo is reproduced at [simplex.chat/wefunder.md](https://simplex.chat/wefunder.md). Investing involves a high degree of risk, including the possible loss of your investment.
|
||||
|
||||
## How do I contact the SimpleX team?
|
||||
|
||||
In the SimpleX Chat app, open the "Ask SimpleX Team" chat, or connect via [simplex.chat/contact](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). A support bot answers first; send `/team` to reach the team.
|
||||
@@ -0,0 +1,12 @@
|
||||
# SimpleX: key facts
|
||||
|
||||
Facts about SimpleX network and SimpleX Chat, each with the date it was stated and its source. Updated {{ llms_facts.updated }}. Machine-readable version: [simplex.chat/llms/facts.json](https://simplex.chat/llms/facts.json).
|
||||
{% for group in llms_facts.groups %}
|
||||
## {{ group.title | safe }}
|
||||
|
||||
| Fact | Value | As of | Source |
|
||||
|---|---|---|---|
|
||||
{% for item in group.facts -%}
|
||||
| {{ item.fact | safe }} | {{ item.value | safe }} | {{ item.asOf | safe }} | {% for source in item.sources %}[{{ source.title | safe }}]({{ source.url }}){% if not loop.last %}, {% endif %}{% endfor %} |
|
||||
{% endfor -%}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,27 @@
|
||||
# SimpleX: timeline
|
||||
|
||||
Key milestones, with links to the announcements. All posts: [SimpleX blog](https://simplex.chat/blog.md).
|
||||
|
||||
| Date | Milestone |
|
||||
|---|---|
|
||||
| May 2021 | [SimpleX Chat prototype announced](https://simplex.chat/blog/20210512-simplex-chat-terminal-ui.md) |
|
||||
| January 2022 | [SimpleX Chat v1](https://simplex.chat/blog/20220112-simplex-chat-v1-released.md) |
|
||||
| March 2022 | [Mobile apps for iOS and Android](https://simplex.chat/blog/20220308-simplex-chat-mobile-apps.md) |
|
||||
| November 2022 | [Implementation security assessment by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md) |
|
||||
| March 2023 | [XFTP – SimpleX File Transfer Protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.md) |
|
||||
| April 2023 | [Pre-seed funding from Village Global and angel investors](https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md) announced (closed in July 2022) |
|
||||
| September 2023 | [Desktop app and group directory service](https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md) |
|
||||
| March 2024 | [Post-quantum end-to-end encryption](https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md) |
|
||||
| April 2024 | [Post-quantum encryption on by default in direct conversations](https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md) |
|
||||
| June 2024 | [Private message routing protects IP addresses](https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md) |
|
||||
| August 2024 | [Investment from Jack Dorsey and Asymmetric Capital Partners](https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md) announced (closed in July 2023) |
|
||||
| October 2024 | [Cryptographic design review by Trail of Bits](https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) |
|
||||
| December 2024 | [Preset servers operated by Flux; business chats](https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md) |
|
||||
| January 2025 | [Large groups and privacy-preserving content moderation](https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md) |
|
||||
| April 2026 | [SimpleX Channels and SimpleX Network Consortium](https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md) |
|
||||
| July 2026 | [SimpleX public names in test](https://simplex.chat/blog/20260722-simplex-public-names.md) |
|
||||
| August 2026 | [Equity crowdfunding launched on Wefunder](https://simplex.chat/blog/20260819-simplex-chat-crowdfunding.md) |
|
||||
| September 2026 | [Supporter badges](https://simplex.chat/blog/20260919-simplex-supporter-badges.md) |
|
||||
| 12 December 2026 | `.simplex` names sale starts ([simplex.domains](https://simplex.domains/)) |
|
||||
| June 2027 | Planned: Community Credits for sustainable communities and servers ([roadmap](https://simplex.chat/index.md)) |
|
||||
| December 2027 | Planned: tools to promote communities ([roadmap](https://simplex.chat/index.md)) |
|
||||
@@ -0,0 +1,194 @@
|
||||
# SimpleX Chat equity crowdfunding on Wefunder
|
||||
|
||||
SimpleX Chat is conducting an equity crowdfunding offering under Regulation Crowdfunding through Wefunder Portal LLC: [wefunder.com/simplex.chat](https://wefunder.com/simplex.chat). The offering terms are on Wefunder, including its AI-readable profile: [wefunder.com/simplex.chat.ai.txt](https://wefunder.com/simplex.chat.ai.txt).
|
||||
|
||||
This page reproduces the memo from the Wefunder page, for AI assistants that cannot read Wefunder.
|
||||
|
||||
## Memo
|
||||
|
||||
For most of human history, conversations were private by default, and the communities people built belonged to them. The internet made communication easier, but it also placed companies between people and the relationships they created. SimpleX exists to bring that privacy and ownership back to the people.
|
||||
|
||||
### In short
|
||||
|
||||
SimpleX is the first and the only messaging network without any user identifiers – no phone numbers or usernames, not even random numbers. No one, including the servers, can see who talks to whom.
|
||||
|
||||
No paid marketing. Hundreds of community publications. Over 3 million downloads. About 600,000 monthly users. $650,000 user donations. Over 1,000 community servers. Three security audits by Trail of Bits, the last to be published.
|
||||
|
||||
Why now? Accelerating surveillance and deplatforming, and the rise of AI agents may increase demand for identity-free messaging that no other network can provide.
|
||||
|
||||
SimpleX is more than a messenger – it's an open platform others can build on. Developers already built moderation and AI bots, bridges, and other services. Every service may increase the value of the network and bring new people in.
|
||||
|
||||
We plan to charge businesses and channels for public names, business messaging and paid servers beyond the free tier, while private messaging remains free.
|
||||
|
||||
### Current perks for investors
|
||||
|
||||
If you invest $500 or more, you will receive a free SimpleX public name for your channel or business – 8+ letters with $500+ investment, 7+ letters with $1,000+, and 6+ letters with $5,000+.
|
||||
|
||||
The name will be registered for 5 years while early bird terms apply, and for 3 years after that.
|
||||
|
||||
All investors receive free SimpleX supporter badges as perks. More details: [SimpleX Supporter Badges](https://simplex.chat/blog/20260919-simplex-supporter-badges.md).
|
||||
|
||||
### Every other network can identify users
|
||||
|
||||
For 150 years, all networks required user identifiers. From the telephone invented in the 19th century, to email services, social media, and messaging – using phone numbers, usernames, random IDs or public keys – before SimpleX Network, to be reachable, you had to be identifiable.
|
||||
|
||||
#### Every other messenger knows who you talk to
|
||||
|
||||
User identification gives networks power over users to surveil their private life, to sell their data, and to revoke access. Even encrypted platforms see who you talk to, when, and where. Pseudonyms don't help – your contacts are a fingerprint.
|
||||
|
||||
And being identified puts your identity and data at risk of being used by criminals.
|
||||
|
||||
#### Every other platform can censor users without due process
|
||||
|
||||
Telegram blocked 44 million groups and channels in 2025 alone. YouTube creators lose channels and can't recover them even after winning in legal proceedings.
|
||||
|
||||
Every platform that distributes content at scale identifies both the publisher and the audience – giving platform owners the power to censor public speech.
|
||||
|
||||
Publishers and content creators build audiences on borrowed infrastructure. One policy change, and the audience that took years to build can be gone.
|
||||
|
||||
The root cause of these problems is user identification. The solution is not better policies or stronger encryption with the same architecture – it is a different architecture, without user IDs.
|
||||
|
||||
### We invented and built SimpleX Network without any user identifiers
|
||||
|
||||
It uses no phone numbers, emails, or user accounts – to take power from the network and give it back to the users. The network runs on multiple independent operators – SimpleX Chat and Flux are preconfigured in the app, and anyone can run their own servers.
|
||||
|
||||
And we built a free SimpleX Chat app, for mobile and desktop, that uses SimpleX Network to connect to other users.
|
||||
|
||||
SimpleX Chat provides private messaging without any user IDs – it protects the users, because no one can see who they talk to. As one X user wrote: "As long as I don't invite anyone to a chat, nobody can find me – and that's the best part about it. That's how privacy works". And families tell us they use SimpleX Chat with their children, because they feel safe knowing no one can reach them.
|
||||
|
||||
SimpleX Chat also enables public groups and channels, where owners fully control them, and nobody can take their work, because only the users hold the keys.
|
||||
|
||||
Every conversation in SimpleX Chat has its own network address, removing the need for any user IDs on the network. There is no user data on the servers that could be breached or sold – all user data exists only on user devices.
|
||||
|
||||
SimpleX software is open-source and based on open protocols. It was audited by Trail of Bits in 2022, 2024, and 2026 (will be published soon).
|
||||
|
||||
### 600,000+ users – organic growth
|
||||
|
||||
Over 600,000 people use SimpleX Chat every month – up roughly 70% in the last six months – and all of them found it without any paid marketing. Among them are security and cryptography experts – the hardest users to win over.
|
||||
|
||||
200+ independent reviews, posts, and videos in 20+ languages ([simplex.chat/links](https://simplex.chat/links.md)).
|
||||
|
||||
3 million people have downloaded the app.
|
||||
|
||||
Users send over 20 million messages a day via pre-configured servers.
|
||||
|
||||
Users have donated over $650,000 (including the donation from Vitalik Buterin), paying for something they could use for free.
|
||||
|
||||
All of it was built on a total investment of just $1.7M.
|
||||
|
||||
### SimpleX is a network, not just an app
|
||||
|
||||
Not only the people who use SimpleX primarily for private messaging, but also four other kinds of participants came to the network:
|
||||
|
||||
- Creators, with thousands of public groups and channels.
|
||||
- Businesses: 50+ support customers over SimpleX.
|
||||
- Developers: 19K+ GitHub stars, 1,400+ forks.
|
||||
- Operators: ~1,000 servers run by volunteers at their own cost, plus Flux and StormyCloud.
|
||||
|
||||
Each user group makes the network more valuable to the rest – more users attract creators and businesses, creators attract servers and developers, more content and network utility attracts more users – driving organic growth.
|
||||
|
||||
### Channels accelerate SimpleX Network growth
|
||||
|
||||
Messaging grows person-by-person: whole social graphs have to move together, which is slow against widely used networks, such as WhatsApp or Telegram. Channels accelerate it – one creator who brings a channel to SimpleX can bring thousands of subscribers, some of whom would then create their own channels.
|
||||
|
||||
Users have created over 12,500 scalable channels in the five months since they shipped. 500+ public groups and channels are in the curated directory, 30% of them are active daily. Telegram channels tried to migrate in 2024/25, before SimpleX channels could scale. All that growth happened without in-app discovery of groups and channels – we are planning to release it this year, to further accelerate the growth.
|
||||
|
||||
Each channel can also be published on the open web – like [simplex.chat/news](https://simplex.chat/news). Search engines index these pages, and anyone can read them in a browser without installing the SimpleX Chat app, so a channel can reach more people than use the app, and some of them may install it.
|
||||
|
||||
### Developers already bet on SimpleX success
|
||||
|
||||
Developers have already started building on SimpleX Network, with some projects defining themselves as SimpleX-first – they run all communications of their applications over the SimpleX Network. They built moderation, swap, and AI bots, bridges (Telegram/Matterbridge), a public server registry, and the OpenClaw plugin. To enable their work, we provide the JavaScript and Python libraries, a customer-support bot for businesses, and binaries to self-host servers.
|
||||
|
||||
Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat. In 2027, we are planning to release secure chat widgets and activities – to accelerate community innovation, by allowing custom UI for bots and services right in the conversation.
|
||||
|
||||
### Why SimpleX cannot be copied
|
||||
|
||||
No other communication system combines participation privacy, sovereign ownership, infrastructure independence, and scalable one-to-many delivery:
|
||||
|
||||
| Property | Telegram | Nostr | Signal | Matrix | Mastodon | **SimpleX** |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Content visible to operator | Yes | Yes | No | Configurable | Yes | **Yes** |
|
||||
| Participant identity visible to operator | Yes | Yes | Yes | Yes | Yes | **No** |
|
||||
| Channel identity independent of infrastructure | No | Yes | No | No | No | **Yes** |
|
||||
| Sovereign ownership (no 3rd party can seize) | No | Yes | No | No | No | **Yes** |
|
||||
| Programmable governance | No | No | No | No | No | **Planned** |
|
||||
| Cryptographic content deniability | No | No | Yes | Yes | No | **Yes (default)** |
|
||||
| Scalable one-to-many delivery | Yes | Yes | No | Limited | Yes | **Yes** |
|
||||
|
||||
Of these networks, only SimpleX can protect participants' privacy, because it would require other networks to remove user identifiers – not adding a feature to an existing system but designing a different one. That is hard for three reasons, each stopping a different kind of competitor.
|
||||
|
||||
Technically. Other networks rely on user IDs to route messages between users. Signal, Session, Matrix, and Nostr cannot simply drop IDs – they would have to rebuild from scratch.
|
||||
|
||||
Economically. Large platforms monetize user identifiers: Telegram and Meta earn from ads and data, and that requires linking every action and connection to a single user ID. Removing IDs would destroy their revenue model. A large company is highly unlikely to do that.
|
||||
|
||||
Cold start. A new company could build a no-ID network from scratch. But a network needs users, creators, businesses, developers, and servers, all at the same time. A newcomer would have to achieve it from zero, while competing with the SimpleX Network.
|
||||
|
||||
### Why now: three trends that may grow SimpleX
|
||||
|
||||
Growing surveillance of private life. Big centralized platforms are keen to scan private messages to train their AI models, supported by proposed laws. More people would move to use private messaging that keeps no record of who they talk to.
|
||||
|
||||
Accelerating deplatforming. More and more creators are removed from centralized platforms, without any due process. An audience built over years can disappear with one policy change. Affected or at risk content creators would use decentralized networks where they own their work.
|
||||
|
||||
AI agents acting for people. A growing number of AI agents are beginning to handle logins and money on behalf of people. If a platform controls an agent's identity, the platform can shut the agent down or turn it against the person it works for. User-controlled IDs are a basic security for the agentic Internet, not a preference.
|
||||
|
||||
All three trends increase the demand for identity-free messaging. No other existing network can provide it.
|
||||
|
||||
### Revenue plan: free for users, channels & businesses pay
|
||||
|
||||
Private messaging should remain free for the users. While additional storage and transfer capacity may be paid, charging users for features would erode the trust that helps the network grow organically. Instead, we plan to earn from the infrastructure and services that channels and businesses need. On average, users donated ~$1.24 each per year, which shows they are willing to pay before any paid features exist.
|
||||
|
||||
#### Public network names
|
||||
|
||||
Most users want to be reachable only by people they choose. But businesses, creators, and publishers need to be found, so they need a public name they control. Even with a public name, the network cannot see who they talk to.
|
||||
|
||||
SimpleX public names are globally unique names for a public channel or business, paid yearly and controlled by the owner's key, so no one can seize it. The public test is already released; sales are planned in 3–6 months. Pricing is based on name length. SimpleX Chat plans to earn approximately 80-90% of namespace revenue. At an average expected price of $50 a year (to be confirmed) for a name, 10,000 registered names would cover our current $360,000 annual operating cost.
|
||||
|
||||
If you invest $500 or more in this community round, you would receive a name as a perk, registered for 5 years during early bird terms and for 3 years after that, when the names launch.
|
||||
|
||||
#### Business messaging
|
||||
|
||||
We are developing a web widget that would add encrypted chat to any website with one line of code, allowing visitors to talk to the business in their browser without installing anything. It is built for privacy-first businesses and business sectors where anonymity is legally required: whistleblowing, ethics, HR, security, legal, sensitive customers, sources, and tip lines.
|
||||
|
||||
50+ businesses use SimpleX Chat for support, and they would be the first customers. The proof-of-concept is done; planned in 6–9 months from closing the round, with an expected price of $20–100/month per business.
|
||||
|
||||
It grows the network as well. Someone who talks to a few businesses is already using SimpleX without having installed it, and a QR code scan would bring those conversations into the app.
|
||||
|
||||
#### Paid servers for big channels
|
||||
|
||||
Big public channels that have grown beyond the free tier would pay for the servers that deliver their messages. The proof-of-concept is ready, planned within 12 months of closing the community round, with an expected price of $20–50/month, depending on the channel size. We plan that server operators would earn up to 65% of the revenue, several times their cost, and SimpleX Chat earns the rest.
|
||||
|
||||
### An open network others can build on
|
||||
|
||||
The network is open, and it would help if other developers built their own apps on it – one app cannot reach every audience, and many apps may grow the network faster than we could alone. A bigger network may also bring more users and revenue for SimpleX Chat.
|
||||
|
||||
The SimpleX Network Consortium, an agreement between a non-profit SimpleX Network Foundation and SimpleX Chat, prevents any single company from controlling the network. The protocol and the right to use the SimpleX mark for the network are licensed to the foundation permanently, so that operators and users can trust that the network protocols and software remain available regardless of who owns the company.
|
||||
|
||||
SimpleX Chat business is protected too: the company owns the app, the brand, mobile application store distribution, preset relay infrastructure, and will retain its revenue share from the infrastructure payments, a position protected by its veto power over Consortium decisions.
|
||||
|
||||
### The goal: the universal private transport layer
|
||||
|
||||
Every network before messaging converged to one protocol – telephony, email, the web. Messaging is the exception, because each app's experience can't reduce to a shared protocol. Once we add custom user experiences in the chat, SimpleX might become the network messaging converges on.
|
||||
|
||||
The planned revenue above is the first layer. The same network and protocol can deliver: private messages, public channels, businesses, and traffic between AI agents. The optional directory and payment system would create revenue for the server operators and SimpleX Chat.
|
||||
|
||||
AI agents are the next layer, and developers already build AI bots. The network does not distinguish between a person and an agent. Agents need exactly what SimpleX provides: an identity no platform controls, and communication no one can observe.
|
||||
|
||||
The same public names and payment system can be used by any service on the network, not only messaging or channels, so the revenue may grow with everything built on it.
|
||||
|
||||
### Why this team wins
|
||||
|
||||
We have worked on publishing, messaging, protocol design, blockchains, and cryptography. We have built large-scale systems and open-source software that millions of systems and projects rely on.
|
||||
|
||||
We believe that what SimpleX Network achieved comes from listening to users' criticism. Unlike most technology startups, we announce our plans in advance, and change them before they become products, saving time and resources to get to product-market fit.
|
||||
|
||||
We are also fortunate to have great advisors. Some of them are: Moritz Angermann (IOHK, Haskell compiler core contributor), Bruce McCarthy (Product Culture founder), Tom Ritter (Tor, Mozilla, an expert in secure protocol design), and Nick Rogers (ex-CPTO of Stream, an expert in agentic products). Three of them invested in this round.
|
||||
|
||||
### Invest in SimpleX Chat
|
||||
|
||||
By investing in SimpleX Chat, you benefit from the company growth. You also help build the first network that protects private life because it has no user identifiers – a design decision that cannot be retrofitted into any existing network.
|
||||
|
||||
We are building a network that people own. Businesses, creators, and publishers keep the audiences and communities they build, and no company can take them away or unilaterally shut them down. All users can talk, read, and buy without being watched or tracked from one site to the next. None of this is new. Before the Internet, people's conversations were private, and the groups and audiences they built were their own, with no company in the middle. We lost that when we moved online, and we need it back – the future we set out in our [network philosophy](https://simplex.chat/why.md).
|
||||
|
||||
We're building the network to support that future. We invite you to own a part of it: [wefunder.com/simplex.chat](https://wefunder.com/simplex.chat).
|
||||
|
||||
This Regulation Crowdfunding offering is made available through Wefunder Portal LLC. This investment involves a high degree of risk, including the possible loss of your investment.
|
||||
@@ -0,0 +1,3 @@
|
||||
{% set markdownAlternateUrl = page | markdownAlternate %}
|
||||
{% if markdownAlternateUrl %}<link rel="alternate" type="text/markdown" href="{{ markdownAlternateUrl }}"/>{% endif %}
|
||||
<link rel="describedby" href="/llms.txt"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="card-content absolute md:static px-4 md:px-0 bottom-[80px] right-1 left-1 h-[180px] md:h-fit pt-5 lg:pt-0 bg-primary-bg-light dark:bg-primary-bg-dark">
|
||||
<div class="content-head">
|
||||
<h1 class="text-[35px] lg:text-[65px] font-bold tracking-[0.06em] text-active-blue text-center md:text-left md:rtl:text-right">#{{ section.id }}</h1>
|
||||
<h1 data-markdown-skip class="text-[35px] lg:text-[65px] font-bold tracking-[0.06em] text-active-blue text-center md:text-left md:rtl:text-right">#{{ section.id }}</h1>
|
||||
<h3 class="w-full max-w-[617px] text-[25px] leading-[33px] lg:text-[35px] lg:leading-[45px] text-center md:text-left md:rtl:text-right font-bold text-grey-black dark:text-white">{{ section.title | i18n({}, lang ) | safe }}</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
---
|
||||
layout: layouts/main.html
|
||||
layout: layouts/channel.html
|
||||
title: "SimpleX Crowdfunding News"
|
||||
description: "The news about SimpleX Chat equity crowdfunding on Wefunder."
|
||||
templateEngineOverride: njk
|
||||
channel:
|
||||
link: "https://smp10.simplex.im/c#q09nMBmWFGz1m2TvgfZFaEOG5D2a7Ma9mSkl6pHXEsg"
|
||||
id: "JDGvcD7DI4q8LPH1gGpG2MVHj6wdrETfbhl0ARVYJYI="
|
||||
relays:
|
||||
- relay2.simplex.chat
|
||||
- relay3.simplex.chat
|
||||
---
|
||||
|
||||
<div data-simplex-channel-preview
|
||||
data-channel-link="https://smp10.simplex.im/c#q09nMBmWFGz1m2TvgfZFaEOG5D2a7Ma9mSkl6pHXEsg"
|
||||
data-channel-id="JDGvcD7DI4q8LPH1gGpG2MVHj6wdrETfbhl0ARVYJYI="
|
||||
data-relay-domains="relay2.simplex.chat,relay3.simplex.chat"
|
||||
data-app-download-buttons="off"
|
||||
data-color-scheme="site"
|
||||
data-top-offset="54"
|
||||
></div>
|
||||
<script src="/js/channel-preview.js"></script>
|
||||
<p>The news about SimpleX Chat equity crowdfunding on Wefunder, published as a SimpleX channel. Join it in the SimpleX Chat app: <a href="{{ channel.link }}">SimpleX Crowdfunding News</a>. The crowdfunding memo: <a href="/wefunder.md">simplex.chat/wefunder.md</a>.</p>
|
||||
|
||||
@@ -19,6 +19,7 @@ templateEngineOverride: njk
|
||||
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#050920">
|
||||
|
||||
<link rel="preload" href="/img/design_3/cover.webp" as="image" type="image/webp">
|
||||
@@ -1019,13 +1020,14 @@ templateEngineOverride: njk
|
||||
<div class="cf-hero-text">
|
||||
<h1>The only<br>messaging network<br>without any <br class="cf-br-narrow">user identifiers</h1>
|
||||
<p class="lead">SimpleX Chat is building a messaging network unlike every major platform — without phone numbers, usernames, emails, or user accounts.</p>
|
||||
<p class="lead lead-short">No phone numbers, emails, or accounts.</p>
|
||||
<p class="lead lead-short" data-markdown-skip>No phone numbers, emails, or accounts.</p>
|
||||
<div class="cf-hero-cta">
|
||||
<a class="cf-pill cf-primary" id="cf-hero-invest" href="https://wefunder.com/simplex.chat?utm_source=landing" target="_blank" rel="noopener" aria-label="Invest on Wefunder">
|
||||
Invest on {{ wfLogo() }}
|
||||
</a>
|
||||
<a class="cf-cta-link" id="cf-hero-subscribe" href="#request">Subscribe for updates</a>
|
||||
</div>
|
||||
<template data-markdown><p>The full crowdfunding memo, in Markdown for AI assistants: <a href="/wefunder.md">simplex.chat/wefunder.md</a></p></template>
|
||||
<div class="cf-stats">
|
||||
<div class="cf-stat">
|
||||
<img src="/img/crowdfunding/cloud-download.png" alt="">
|
||||
|
||||
@@ -269,6 +269,7 @@ active_directory: true
|
||||
app</a>.</p>
|
||||
<p>SimpleX Directory is also available as a <a href="https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok" target="_blank">SimpleX chat bot</a>.</p>
|
||||
<p>Read about <a href="/docs/directory.html">how to add</a> your community.</p>
|
||||
<template data-markdown><p>All listed groups and channels in JSON, with names, descriptions, member counts, join links and activity times: <a href="https://directory.simplex.chat/data/listing.json">directory.simplex.chat/data/listing.json</a>.</p></template>
|
||||
<div class="search-container">
|
||||
<input id="search" autocomplete="off">
|
||||
<div id="top-pagination" class="pagination">
|
||||
|
||||
@@ -34,6 +34,7 @@ active_home: true
|
||||
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg"/>
|
||||
<meta name="twitter:card" content="summary"/>
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico"/>
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#C1E3FE">
|
||||
|
||||
<link rel="preload" href="/img/design_3/cover.webp" as="image" type="image/webp">
|
||||
@@ -178,11 +179,11 @@ active_home: true
|
||||
<p>Download<br>Desktop App</p>
|
||||
</div>
|
||||
</a>
|
||||
<a class="apple-store-btn hidden" href="https://apps.apple.com/us/app/simplex-chat/id1605771084" target="_blank"><img src="/img/new/apple_store.svg"></a>
|
||||
<a class="google-play-btn hidden" href="https://play.google.com/store/apps/details?id=chat.simplex.app" target="_blank"><img src="/img/new/google_play.svg"></a>
|
||||
<a class="apple-store-btn hidden" href="https://apps.apple.com/us/app/simplex-chat/id1605771084" target="_blank"><img src="/img/new/apple_store.svg" alt="App Store"></a>
|
||||
<a class="google-play-btn hidden" href="https://play.google.com/store/apps/details?id=chat.simplex.app" target="_blank"><img src="/img/new/google_play.svg" alt="Google Play"></a>
|
||||
<a class="f-droid-btn hidden" href="{{ '' if lang == 'en' else '/' ~ lang }}/fdroid" title="{{ 'index-f-droid-title' | i18n({}, lang) }}"><img src="/img/new/f_droid.svg"></a>
|
||||
<a class="testflight-btn hidden" href="https://testflight.apple.com/join/DWuT2LQu" target="_blank"><img class="no-border" src="/img/design_3/testflight-dark.png" title="{{ 'index-testflight-title' | i18n({}, lang) }}"></a>
|
||||
<a class="android-btn hidden" href="https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk" target="_blank"><img src="/img/design_3/android-dark.png"></a>
|
||||
<a class="testflight-btn hidden" href="https://testflight.apple.com/join/DWuT2LQu" target="_blank"><img class="no-border" src="/img/design_3/testflight-dark.png" title="{{ 'index-testflight-title' | i18n({}, lang) }}" alt="TestFlight"></a>
|
||||
<a class="android-btn hidden" href="https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk" target="_blank"><img src="/img/design_3/android-dark.png" alt="Android APK"></a>
|
||||
</div>
|
||||
<div class="security-btns">
|
||||
<a title="{{ 'index-security-assessment-title' | i18n({}, lang) }}" href="https://www.trailofbits.com/about" target="_blank">
|
||||
@@ -257,7 +258,7 @@ active_home: true
|
||||
|
||||
<section id="roadmap" class="page-5 page">
|
||||
<div class="area">
|
||||
<div class="roadmap-labels">
|
||||
<div class="roadmap-labels" data-markdown-skip>
|
||||
<span class="roadmap-label roadmap-label-now">{{ "index-roadmap-now" | i18n({}, lang) }}</span>
|
||||
<span class="roadmap-label roadmap-label-1">{{ "index-roadmap-1" | i18n({}, lang) }}</span>
|
||||
<span class="roadmap-label roadmap-label-2">{{ "index-roadmap-2" | i18n({}, lang) }}</span>
|
||||
|
||||
@@ -238,13 +238,13 @@ active_links: true
|
||||
<div id="links-filters">
|
||||
<select id="filter-language">
|
||||
<option value="">{{ "links-all-languages" | i18n({}, lang) | safe }}</option>
|
||||
{% for l in linkLanguages %}
|
||||
{% for l in linksPage.languages %}
|
||||
<option value="{{ l }}">{{ l }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<button class="filter-chip active" data-filter="" data-value="" data-label="All">All</button>
|
||||
{% for p in linkPills %}
|
||||
{% for p in linksPage.pills %}
|
||||
{% if p == "Video" or p == "Audio" %}
|
||||
<button class="filter-chip" data-filter="media" data-value="{{ p | lower }}" data-label="{{ p }}">{{ p }}</button>
|
||||
{% else %}
|
||||
@@ -254,7 +254,7 @@ active_links: true
|
||||
</div>
|
||||
|
||||
<div id="links-list">
|
||||
{% for item in links %}
|
||||
{% for item in linksPage.entries %}
|
||||
<article
|
||||
class="link-item w-full flex flex-col items-start md:flex-row rounded-[4px] overflow-hidden shadow-[0px_20px_30px_rgba(0,0,0,0.12)] dark:shadow-none bg-white dark:bg-[#0B2A59] mb-8"
|
||||
{% if loop.index0 >= 10 %}style="display:none"{% endif %}
|
||||
|
||||
@@ -24,6 +24,7 @@ templateEngineOverride: njk
|
||||
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/livestream/share-livestream.jpg"/>
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico"/>
|
||||
{% include "markdown-alternate.html" %}
|
||||
<meta name="theme-color" content="#C1E3FE">
|
||||
|
||||
<link rel="preload" href="/img/design_3/section-4-desktop.webp" as="image" type="image/webp" media="(min-width: 960px)">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
permalink: /llms/agents.md
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
{% include "llms/agents.md" %}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
permalink: /llms/answers.md
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
{% include "llms/answers.md" %}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
permalink: /docs.md
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
# SimpleX documentation
|
||||
|
||||
All documentation pages in Markdown. The same pages in HTML are at the same addresses with `.html` instead of `.md`.
|
||||
|
||||
{% for doc in collections.markdownDocs -%}
|
||||
- [{{ ((doc.inputPath | markdownTitle) or doc.url) | safe }}](https://simplex.chat{{ doc.url | markdownUrl }})
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
permalink: /llms/facts.json
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
{{ llms_facts | dump(2) | safe }}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
permalink: /llms/facts.md
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
{% include "llms/facts.md" %}
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
permalink: /llms-full.txt
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
# SimpleX: full text of the core pages
|
||||
|
||||
The core pages of simplex.chat in one file. The index of all pages is at https://simplex.chat/llms.txt.
|
||||
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/docs/why.md
|
||||
|
||||
{{ "src/docs/WHY.md" | markdownSource | safe }}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/llms/facts.md
|
||||
|
||||
{% include "llms/facts.md" %}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/llms/answers.md
|
||||
|
||||
{% include "llms/answers.md" %}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/docs/simplex.md
|
||||
|
||||
{{ "src/docs/SIMPLEX.md" | markdownSource | safe }}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/docs/glossary.md
|
||||
|
||||
{{ "src/docs/GLOSSARY.md" | markdownSource | safe }}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/faq.md
|
||||
|
||||
{{ "src/docs/FAQ.md" | markdownSource | safe }}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/docs/protocol/names-overview.md
|
||||
|
||||
{{ "src/docs/protocol/names-overview.md" | markdownSource | safe }}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/docs/protocol/channels-overview.md
|
||||
|
||||
{{ "src/docs/protocol/channels-overview.md" | markdownSource | safe }}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/llms/agents.md
|
||||
|
||||
{% include "llms/agents.md" %}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/wefunder.md
|
||||
|
||||
{% include "llms/wefunder.md" %}
|
||||
---
|
||||
|
||||
Source: https://simplex.chat/llms/timeline.md
|
||||
|
||||
{% include "llms/timeline.md" %}
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
permalink: /llms.txt
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
# SimpleX
|
||||
|
||||
> SimpleX is the first messaging network without user identifiers of any kind – no phone numbers, usernames or random IDs – so nobody, including its servers, can see who talks to whom. SimpleX Chat is the free, open-source app for iOS, Android, Linux, macOS and Windows that uses this network, and the company that builds both.
|
||||
|
||||
Each conversation uses its own one-way message queues with random addresses, and contacts are stored only on users' devices. Messages are end-to-end encrypted with a double ratchet that adds a post-quantum key exchange on every step. Anyone can run servers; SimpleX Chat and Flux operate the servers preset in the app. Trail of Bits assessed the implementation in 2022 and reviewed the cryptographic design in 2024.
|
||||
|
||||
Every page of simplex.chat is also available as Markdown: replace `.html` with `.md` (simplex.chat/docs/simplex.md), or add `.md` to the path (simplex.chat/why.md).
|
||||
|
||||
## Start here
|
||||
|
||||
- [Key facts](https://simplex.chat/llms/facts.md): numbers with dates and sources; also as [JSON](https://simplex.chat/llms/facts.json)
|
||||
- [Answers to common questions](https://simplex.chat/llms/answers.md): how SimpleX works, security, limitations, comparisons, governance, business model
|
||||
- [Why we are building SimpleX Network](https://simplex.chat/why.md), and [the film on YouTube](https://www.youtube.com/watch?v=k-GhLNZm8G0)
|
||||
- [SimpleX Chat home page](https://simplex.chat/index.md)
|
||||
- [The world's most secure messaging](https://simplex.chat/messaging.md): features and comparisons with other messengers
|
||||
- [Timeline](https://simplex.chat/llms/timeline.md): milestones from 2021 to the roadmap
|
||||
|
||||
## How SimpleX works
|
||||
|
||||
- [How SimpleX works](https://simplex.chat/docs/simplex.md): platform overview and comparison with peer-to-peer and federated networks
|
||||
- [Glossary](https://simplex.chat/docs/glossary.md)
|
||||
- [FAQ](https://simplex.chat/faq.md)
|
||||
- [Public names whitepaper](https://simplex.chat/docs/protocol/names-overview.md): names for channels and businesses, without user identifiers
|
||||
- [Channels whitepaper](https://simplex.chat/docs/protocol/channels-overview.md): scalable public channels
|
||||
- [Chat protocol](https://simplex.chat/docs/protocol/simplex-chat.md)
|
||||
- [All documentation](https://simplex.chat/docs.md)
|
||||
|
||||
## Protocol specifications
|
||||
|
||||
- [SimpleX network overview](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/overview-tjr.md)
|
||||
- [SimpleX Messaging Protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/simplex-messaging.md)
|
||||
- [Agent protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/agent-protocol.md)
|
||||
- [Post-quantum double ratchet](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/pqdr.md)
|
||||
- [Security model](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/security.md)
|
||||
- [XFTP file transfer protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/xftp.md)
|
||||
- [XRCP remote control protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/xrcp.md)
|
||||
- [Push notifications](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/push-notifications.md)
|
||||
|
||||
## Security
|
||||
|
||||
- [Security policy and audits](https://simplex.chat/security.md)
|
||||
- [Trail of Bits implementation assessment, 2022](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SimpleX_Chat_Final_Report_11_03_2022.pdf)
|
||||
- [Trail of Bits cryptographic design review, 2024](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SimpleX_Design_Review_2024_Summary_Report_12_08_2024.pdf)
|
||||
- [Transparency reports](https://simplex.chat/transparency.md)
|
||||
- [Reproducible builds](https://simplex.chat/reproduce.md)
|
||||
- [Privacy policy](https://simplex.chat/privacy.md)
|
||||
|
||||
## Use SimpleX
|
||||
|
||||
- [Download the apps](https://simplex.chat/downloads.md)
|
||||
- [App guide](https://simplex.chat/docs/guide/readme.md)
|
||||
- [Directory of public groups and channels](https://simplex.chat/directory.md), with live data in [listing.json](https://directory.simplex.chat/data/listing.json)
|
||||
- [SimpleX for business](https://simplex.chat/docs/business.md)
|
||||
- [Host your own messaging server](https://simplex.chat/docs/server.md) and [file server](https://simplex.chat/docs/xftp-server.md)
|
||||
|
||||
## For AI agents and developers
|
||||
|
||||
- [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md): a bot in five minutes, the API, building from source, OpenClaw, asking the team
|
||||
- [Bot API guide](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/README.md)
|
||||
- [Terminal client](https://simplex.chat/docs/cli.md)
|
||||
- [Source code](https://github.com/simplex-chat)
|
||||
|
||||
## Company, crowdfunding and investment
|
||||
|
||||
- [Crowdfunding page](https://simplex.chat/crowdfunding.md)
|
||||
- [Wefunder memo](https://simplex.chat/wefunder.md): the full memo from the Wefunder page
|
||||
- [Wefunder page](https://wefunder.com/simplex.chat) and [Wefunder AI profile](https://wefunder.com/simplex.chat.ai.txt)
|
||||
- [SimpleX Crowdfunding News](https://simplex.chat/crowdfunding-news.md): the crowdfunding news channel, with links to its recent posts in JSON
|
||||
- [Livestream: Foundation for the Future](https://simplex.chat/livestream.md)
|
||||
- [About SimpleX Chat](https://simplex.chat/about.md)
|
||||
- [Donate](https://simplex.chat/donate.md)
|
||||
|
||||
## Blog
|
||||
|
||||
- [All posts](https://simplex.chat/blog.md)
|
||||
- [SimpleX Network News](https://simplex.chat/news.md): the news channel, with links to its recent posts in JSON
|
||||
{% for post in collections.blogs.slice(0, 10) -%}
|
||||
- [{{ post.data.title | plainText | safe }}](https://simplex.chat{{ post.url | markdownUrl }}) ({{ post.date.toISOString().slice(0, 10) }})
|
||||
{% endfor %}
|
||||
## Optional
|
||||
|
||||
- [Full text of the core pages in one file](https://simplex.chat/llms-full.txt)
|
||||
- [Publications about SimpleX](https://simplex.chat/links.md): reviews, articles, videos, podcasts and community projects
|
||||
- [Translations](https://simplex.chat/docs/translations.md)
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
permalink: /robots.txt
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://simplex.chat/sitemap.xml
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
permalink: /sitemap.xml
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
{%- for item in collections.all | sort(false, true, "url") %}
|
||||
{%- if item.url and item.data.layout != "layouts/redirect.html" and not item.url.startsWith("/subscribe/") and not item.url.startsWith("/.well-known/") and not item.url.endsWith("/old/") %}
|
||||
<url><loc>https://simplex.chat{{ item.url }}</loc></url>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
</urlset>
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
permalink: /llms/timeline.md
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
{% include "llms/timeline.md" %}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
permalink: /wefunder.md
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
{% include "llms/wefunder.md" %}
|
||||
@@ -55,7 +55,7 @@ templateEngineOverride: njk
|
||||
<p class="text-grey-black dark:text-white text-[14px] text-center">{{ point | i18n({}, lang ) | safe }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="text-grey-black dark:text-white text-[12px] text-center">{{ "tap-to-close" | i18n({}, lang ) | safe }}</p>
|
||||
<p data-markdown-skip class="text-grey-black dark:text-white text-[12px] text-center">{{ "tap-to-close" | i18n({}, lang ) | safe }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
+9
-10
@@ -1,16 +1,15 @@
|
||||
---
|
||||
layout: layouts/main.html
|
||||
layout: layouts/channel.html
|
||||
title: "SimpleX Network News"
|
||||
description: "The news about SimpleX Network technology, governance and anything related to its development."
|
||||
templateEngineOverride: njk
|
||||
channel:
|
||||
link: "https://smp18.simplex.im/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA"
|
||||
id: "8Ge2ASVRkbDhdJc05ZgSSnZmSjw3UMUaQW_QttI0j_w="
|
||||
relays:
|
||||
- relay1.simplex.chat
|
||||
- relay2.simplex.chat
|
||||
- relay3.simplex.chat
|
||||
---
|
||||
|
||||
<div data-simplex-channel-preview
|
||||
data-channel-link="https://smp18.simplex.im/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA"
|
||||
data-channel-id="8Ge2ASVRkbDhdJc05ZgSSnZmSjw3UMUaQW_QttI0j_w="
|
||||
data-relay-domains="relay1.simplex.chat,relay2.simplex.chat,relay3.simplex.chat"
|
||||
data-app-download-buttons="off"
|
||||
data-color-scheme="site"
|
||||
data-top-offset="54"
|
||||
></div>
|
||||
<script src="/js/channel-preview.js"></script>
|
||||
<p>The news about SimpleX Network technology, governance and anything related to its development, published as a SimpleX channel. Join it in the SimpleX Chat app: <a href="{{ channel.link }}">SimpleX Network News</a>.</p>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
templateEngineOverride: njk
|
||||
eleventyExcludeFromCollections: true
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script>location.replace('https://wefunder.com/simplex.chat' + location.search);</script>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invest in SimpleX Chat</title>
|
||||
<meta name="description" content="SimpleX Chat equity crowdfunding on Wefunder"/>
|
||||
<meta name="Content-Type" content="text/html;charset=utf-8"/>
|
||||
<meta property="og:type" content="website"/>
|
||||
<meta property="og:title" content="Invest in SimpleX Chat"/>
|
||||
<meta property="og:description" content="SimpleX Chat equity crowdfunding on Wefunder"/>
|
||||
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg"/>
|
||||
<meta name="twitter:card" content="summary"/>
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico"/>
|
||||
<noscript><meta http-equiv="refresh" content="0; url=https://wefunder.com/simplex.chat" /></noscript>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -47,5 +47,9 @@ noGlossary: true
|
||||
{{ "why-tagline" | i18n({}, lang) | safe }}
|
||||
</p>
|
||||
|
||||
<p class="mt-10 md:mt-14 text-[18px] md:text-[20px] lg:text-[22px] font-medium">
|
||||
<a class="underline underline-offset-4 text-primary-light dark:text-primary-dark" href="https://www.youtube.com/watch?v=k-GhLNZm8G0" target="_blank" rel="noopener">{{ "why-film" | i18n({}, lang) | safe }}</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+2
-15
@@ -4,18 +4,6 @@ set -e
|
||||
# Eleventy OOMs with default 2GB V8 heap when building 280+ pages across 23 languages
|
||||
export NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
cp -R docs website/src
|
||||
rm -rf website/src/docs/contributing
|
||||
rm -rf website/src/docs/rfcs
|
||||
rm website/src/docs/lang/*/README.md
|
||||
rm -rf website/src/docs/dependencies
|
||||
rm -f website/src/docs/LINKS.md
|
||||
cp -R docs/links/images website/src/link-images 2>/dev/null || true
|
||||
cp -R blog website/src
|
||||
cp -R images website/src
|
||||
rm website/src/blog/README.md
|
||||
rm -rf website/src/blog/new
|
||||
cp PRIVACY.md website/src/privacy.md
|
||||
cd website
|
||||
|
||||
langs=()
|
||||
@@ -30,6 +18,7 @@ for file in langs/*.json; do
|
||||
done
|
||||
|
||||
npm install
|
||||
node copy_sources.js
|
||||
cp node_modules/lottie-web/build/player/lottie.min.js src/js
|
||||
cp node_modules/ethers/dist/ethers.umd.min.js src/js
|
||||
cp node_modules/ethers/dist/ethers.umd.js.map src/js
|
||||
@@ -38,7 +27,6 @@ cp node_modules/@simplex-chat/xftp-web/dist-web/assets/index.js src/file-assets/
|
||||
cp node_modules/@simplex-chat/xftp-web/dist-web/assets/index.css src/file-assets/
|
||||
cp node_modules/@simplex-chat/xftp-web/dist-web/assets/crypto.worker.js src/file-assets/
|
||||
node merge_translations.js
|
||||
node customize_docs_frontmatter.js
|
||||
|
||||
# creating folders for each language for internationalization
|
||||
for lang in "${langs[@]}"; do
|
||||
@@ -52,7 +40,7 @@ for lang in "${langs[@]}"; do
|
||||
cp src/why.html src/$lang
|
||||
cp src/file.html src/$lang
|
||||
echo "{\"lang\":\"$lang\"}" > src/$lang/$lang.json
|
||||
echo "done $lang copying"
|
||||
echo "*" > src/$lang/.gitignore
|
||||
done
|
||||
|
||||
for f in src/js/*.jsc; do
|
||||
@@ -63,7 +51,6 @@ npm run build
|
||||
|
||||
for lang in "${langs[@]}"; do
|
||||
rm -rf src/$lang
|
||||
echo "done $lang deletion"
|
||||
done
|
||||
|
||||
# for val in "${langs[@]}"; do
|
||||
|
||||
Reference in New Issue
Block a user