Merge branch 'master' into badge-in-app-checkout

This commit is contained in:
spaced4ndy
2026-10-09 15:00:36 +04:00
94 changed files with 2255 additions and 405 deletions
+13
View File
@@ -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.
+5
View File
@@ -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
}
+2
View File
@@ -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
}
+6 -2
View File
@@ -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 {
+1
View File
@@ -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 {
+3 -1
View File
@@ -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
)
}
@@ -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 }
@@ -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
@@ -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()) {
@@ -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)
}
@@ -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
@@ -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 &amp; 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
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: c0a1b7bd2d79c4af5163d9a863cb0780f556d7a9
tag: e24d537eaaa32230eec1fefcd957d474d7b8d4d5
source-repository-package
type: git
+2
View File
@@ -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)
-3
View File
@@ -325,9 +325,6 @@
"introInvitation": {
"properties": {
"groupConnReq": {"ref": "connReqUri"}
},
"optionalProperties": {
"directConnReq": {"ref": "connReqUri"}
}
},
"groupMemberRole": {
+1 -1
View File
@@ -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";
+7 -1
View File
@@ -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
+3 -3
View File
@@ -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 $ \_ ->
+37 -22
View File
@@ -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
+12 -9
View File
@@ -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"
+14 -16
View File
@@ -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
+2 -2
View File
@@ -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
}
+5 -1
View File
@@ -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]
+10 -2
View File
@@ -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
+1 -1
View File
@@ -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).
+2 -32
View File
@@ -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 =
+17 -17
View File
@@ -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=?)
+5 -4
View File
@@ -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
+1 -2
View File
@@ -1085,8 +1085,7 @@ data MemberIdRole = MemberIdRole
deriving (Eq, Show)
data IntroInvitation = IntroInvitation
{ groupConnReq :: ConnReqInvitation,
directConnReq :: Maybe ConnReqInvitation
{ groupConnReq :: ConnReqInvitation
}
deriving (Eq, Show)
+1
View File
@@ -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
+2 -4
View File
@@ -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>"
}
}
+16
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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 =
+60 -3
View File
@@ -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
+120
View File
@@ -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
+115
View File
@@ -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 =
+7
View File
@@ -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
+3 -9
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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',
+81
View File
@@ -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()
+50 -81
View File
@@ -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;
+1 -1
View File
@@ -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 &mdash; <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í.",
+1
View File
@@ -331,6 +331,7 @@
"why-p7": "The oldest human freedom &mdash; to speak to another person without being watched &mdash; 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.",
+272
View File
@@ -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,
}
+2 -2
View File
@@ -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"
}
}
+99
View File
@@ -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`.
+2
View File
@@ -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
+219
View File
@@ -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" }]
}
]
}
]
}
+6 -6
View File
@@ -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>
+1
View File
@@ -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"/>
+1
View File
@@ -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" />
+1
View File
@@ -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">
+1
View File
@@ -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" />
+1
View File
@@ -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" />
+157
View File
@@ -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.
+73
View File
@@ -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.
+12
View File
@@ -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 %}
+27
View File
@@ -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)) |
+194
View File
@@ -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>
+8 -10
View File
@@ -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>
+3 -1
View File
@@ -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 &mdash; 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="">
+1
View File
@@ -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">
+6 -5
View File
@@ -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>
+3 -3
View File
@@ -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 %}
+1
View File
@@ -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)">
+5
View File
@@ -0,0 +1,5 @@
---
permalink: /llms/agents.md
eleventyExcludeFromCollections: true
---
{% include "llms/agents.md" %}
+5
View File
@@ -0,0 +1,5 @@
---
permalink: /llms/answers.md
eleventyExcludeFromCollections: true
---
{% include "llms/answers.md" %}
+11
View File
@@ -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 %}
+5
View File
@@ -0,0 +1,5 @@
---
permalink: /llms/facts.json
eleventyExcludeFromCollections: true
---
{{ llms_facts | dump(2) | safe }}
+5
View File
@@ -0,0 +1,5 @@
---
permalink: /llms/facts.md
eleventyExcludeFromCollections: true
---
{% include "llms/facts.md" %}
+63
View File
@@ -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" %}
+88
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
---
permalink: /robots.txt
eleventyExcludeFromCollections: true
---
User-agent: *
Allow: /
Sitemap: https://simplex.chat/sitemap.xml
+12
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
---
permalink: /llms/timeline.md
eleventyExcludeFromCollections: true
---
{% include "llms/timeline.md" %}
+5
View File
@@ -0,0 +1,5 @@
---
permalink: /wefunder.md
eleventyExcludeFromCollections: true
---
{% include "llms/wefunder.md" %}
+1 -1
View File
@@ -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
View File
@@ -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>
+24
View File
@@ -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>
+4
View File
@@ -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
View File
@@ -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