diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml
index 9e059afa14..64d7a03cf7 100644
--- a/apps/multiplatform/android/src/main/AndroidManifest.xml
+++ b/apps/multiplatform/android/src/main/AndroidManifest.xml
@@ -100,12 +100,18 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts
index 413667c968..a03a7d4b27 100644
--- a/apps/multiplatform/common/build.gradle.kts
+++ b/apps/multiplatform/common/build.gradle.kts
@@ -143,6 +143,7 @@ kotlin {
}
// For jSystemThemeDetector only
implementation("net.java.dev.jna:jna-platform:5.14.0")
+ implementation("com.github.oshi:oshi-core:6.4.13")
implementation("com.sshtools:two-slices:0.9.1")
implementation("org.slf4j:slf4j-simple:2.0.12")
implementation("uk.co.caprica:vlcj:4.8.3")
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt
index 2323582c30..c798f875da 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt
@@ -1212,12 +1212,13 @@ fun SynchronizeConnectionButtonForce(syncConnectionForce: () -> Unit) {
}
@Composable
-fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
+fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit, disabled: Boolean = false) {
SettingsActionItem(
if (contactVerified) painterResource(MR.images.ic_verified_user) else painterResource(MR.images.ic_shield),
stringResource(if (contactVerified) MR.strings.view_security_code else MR.strings.verify_security_code),
click = onClick,
iconColor = MaterialTheme.colors.secondary,
+ disabled = disabled,
)
}
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
index 7e43db2e98..091924e3c7 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt
@@ -499,6 +499,15 @@ fun ChatView(
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
groupMembersJob.cancel()
+ val connStats = mutableStateOf(null)
+ val connectionCode = mutableStateOf(null)
+ val connectionLoaded = mutableStateOf(false)
+ if (chatsCtx.secondaryContextFilter == null) {
+ ModalManager.end.closeModals()
+ }
+ ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
+ GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
+ }
groupMembersJob = scope.launch(Dispatchers.Default) {
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -513,15 +522,9 @@ fun ChatView(
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember)
}
-
- if (chatsCtx.secondaryContextFilter == null) {
- ModalManager.end.closeModals()
- }
- ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close ->
- remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
- GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close)
- }
- }
+ connStats.value = stats
+ connectionCode.value = code
+ connectionLoaded.value = true
}
},
loadMessages = { chatId, pagination, visibleItemIndexes ->
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
index d2308a1bbf..ae04092755 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt
@@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView(
}
},
showMemberInfo = { member, groupRelay ->
+ val connStats = mutableStateOf(null)
+ val connectionCode = mutableStateOf(null)
+ val connectionLoaded = mutableStateOf(false)
+ ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
+ GroupMemberInfoView(rhId, groupInfo, member, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
+ closeCurrent()
+ close()
+ }
+ }
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId)
val stats = r?.second
@@ -132,14 +141,9 @@ fun ModalData.GroupChatInfoView(
} else {
member to null
}
- ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
- remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
- GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) {
- closeCurrent()
- close()
- }
- }
- }
+ connStats.value = stats
+ connectionCode.value = code
+ connectionLoaded.value = true
}
},
editGroupProfile = {
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
index d1ef467917..c5c63e4454 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt
@@ -45,10 +45,11 @@ import kotlinx.coroutines.*
fun GroupMemberInfoView(
rhId: Long?,
groupInfo: GroupInfo,
- member: GroupMember,
+ groupMember: GroupMember,
scrollToItemId: MutableState,
- connectionStats: ConnectionStats?,
- connectionCode: String?,
+ connStats: MutableState,
+ connectionCode: State,
+ connectionLoaded: State,
chatModel: ChatModel,
openedFromSupportChat: Boolean,
groupRelay: GroupRelay? = null,
@@ -60,7 +61,10 @@ fun GroupMemberInfoView(
}
BackHandler(onBack = close)
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId }
- val connStats = remember { mutableStateOf(connectionStats) }
+ // the passed member is shown until the loaded one is added to the model, so that the profile opens without waiting for the core
+ val member = remember(groupMember.groupMemberId) {
+ derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember }
+ }.value
val developerTools = chatModel.controller.appPrefs.developerTools.get()
var progressIndicator by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -91,7 +95,8 @@ fun GroupMemberInfoView(
connStats,
newRole,
developerTools,
- connectionCode,
+ connectionCode.value,
+ connectionLoaded.value,
groupRelay = groupRelay,
getContactChat = { chatModel.getContactChat(it) },
openDirectChat = { contactId ->
@@ -101,6 +106,7 @@ fun GroupMemberInfoView(
}
},
createMemberContact = {
+ val connectionStats = connStats.value
if (member.sendMsgEnabled) {
withBGApi {
progressIndicator = true
@@ -209,7 +215,7 @@ fun GroupMemberInfoView(
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
- connectionCode,
+ connectionCode.value,
mem.verified,
verify = { code ->
chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r ->
@@ -333,6 +339,7 @@ fun GroupMemberInfoLayout(
newRole: MutableState,
developerTools: Boolean,
connectionCode: String?,
+ connectionLoaded: Boolean,
groupRelay: GroupRelay? = null,
getContactChat: (Long) -> Chat?,
openDirectChat: (Long) -> Unit,
@@ -503,22 +510,23 @@ fun GroupMemberInfoLayout(
SectionDividerSpaced()
}
+ val memberConnected = member.memberActive || (groupInfo.useRelays && member.memberCurrent)
val showMemberSupportChat = !openedFromSupportChat &&
groupInfo.membership.memberRole >= GroupMemberRole.Moderator &&
member.memberRole != GroupMemberRole.Relay &&
((groupInfo.fullGroupPreferences.support.on && member.memberRole < GroupMemberRole.Moderator)
|| member.supportChat != null)
- val canVerifyCode = connectionCode != null && member.memberRole != GroupMemberRole.Relay
+ // the same condition decides whether the code is requested, so the row is shown disabled until it arrives
+ val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded)
val canSyncConn = cStats != null && cStats.ratchetSyncAllowed
- if ((member.memberActive || (groupInfo.useRelays && member.memberCurrent))
- && (showMemberSupportChat || canVerifyCode || canSyncConn)) {
+ if (memberConnected && (showMemberSupportChat || canVerifyCode || canSyncConn)) {
SectionView {
if (showMemberSupportChat) {
SupportChatButton()
}
if (canVerifyCode) {
- VerifyCodeButton(member.verified, verifyClicked)
+ VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null)
}
if (canSyncConn) {
SynchronizeConnectionButton(syncMemberConnection)
@@ -620,6 +628,20 @@ fun GroupMemberInfoLayout(
SimplexServers(stringResource(MR.strings.sending_via), sndServers)
}
}
+ } else if (!connectionLoaded && memberConnected) {
+ SectionDividerSpaced()
+ SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
+ // the status is set from the same receiving queues as the servers below, so it is reserved with them
+ SectionItemView {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(stringResource(MR.strings.network_status))
+ Icon(painterResource(MR.images.ic_info), stringResource(MR.strings.network_status), tint = MaterialTheme.colors.secondary)
+ }
+ }
+ SwitchAddressButton(disabled = true, switchAddress = {})
+ SimplexServers(stringResource(MR.strings.receiving_via), emptyList())
+ SimplexServers(stringResource(MR.strings.sending_via), emptyList())
+ }
}
val connFailedErr = member.activeConn?.connFailedErr
@@ -1082,6 +1104,7 @@ fun PreviewGroupMemberInfoLayout() {
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
developerTools = false,
connectionCode = "123",
+ connectionLoaded = true,
getContactChat = { Chat.sampleData },
openDirectChat = {},
createMemberContact = {},
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt
index 1ac6d6f048..0da3c46abb 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt
@@ -76,6 +76,15 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = {
+ val connStats = mutableStateOf(null)
+ val connectionCode = mutableStateOf(null)
+ val connectionLoaded = mutableStateOf(false)
+ ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
+ GroupMemberInfoView(rhId, groupInfo, scopeMember_, scrollToItemId, connStats, connectionCode, connectionLoaded, chatModel, openedFromSupportChat = true, close = closeCurrent) {
+ closeCurrent()
+ close()
+ }
+ }
withBGApi {
val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId)
val stats = r?.second
@@ -85,14 +94,9 @@ fun MemberSupportChatAppBar(
} else {
null
}
- ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent ->
- remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem ->
- GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) {
- closeCurrent()
- close()
- }
- }
- }
+ connStats.value = stats
+ connectionCode.value = code
+ connectionLoaded.value = true
}
},
onTop = !oneHandUI.value || !chatBottomBar.value,
diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt
index 26748b8425..3fb9bd600c 100644
--- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt
+++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt
@@ -76,12 +76,11 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid
override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat {
// libvlc passes the size the decoder padded the picture to, not the size of the picture (dav1d
// pads to a multiple of 128, so 1920x1080 arrives as 1920x1152), and vlc stretches the picture to
- // fill whatever size is returned. Ask for the size of the track being played instead. The format
- // is negotiated more than once, and vlc has not selected the track yet on the first calls
- val player = mediaPlayer
- val tracks = player?.media()?.info()?.videoTracks()
- val playingTrack = player?.video()?.track()
- val track = tracks?.firstOrNull { it.id() == playingTrack } ?: tracks?.singleOrNull()
+ // fill whatever size is returned. Ask for the size of the video track instead. Not the selected
+ // track: video().track() takes the input lock, which stop() holds while it waits for this thread
+ // to finish, so asking here deadlocks both, and every caller of the player after them
+ val tracks = mediaPlayer?.media()?.info()?.videoTracks()
+ val track = tracks?.singleOrNull()
// Both track sides or neither: one side from the track and the other from the padded size libvlc
// passed never described the same picture, and transposing such a pair compounds the mismatch
val trackW = track?.width() ?: 0
diff --git a/apps/simplex-calculator-bot/.gitignore b/apps/simplex-calculator-bot/.gitignore
new file mode 100644
index 0000000000..902b28133e
--- /dev/null
+++ b/apps/simplex-calculator-bot/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+data/
diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md
new file mode 100644
index 0000000000..028e470964
--- /dev/null
+++ b/apps/simplex-calculator-bot/README.md
@@ -0,0 +1,36 @@
+# SimpleX Calculator
+
+A calculator bot built with the [SimpleX Chat Node.js library](../../packages/simplex-chat-nodejs/).
+
+Each user who connects via the bot's business address receives a calculator message with keys as commands. The bot replaces this message after each input.
+
+- `×` and `÷` are applied before `+` and `-`, so `2 + 3 × 4 =` equals 14.
+- `%` is computed as in Apple's calculator: `100 + 15 % =` equals 115.
+- Tap `C` to clear the number, and `C` again to clear the calculation.
+- Numbers are shown without exponent, up to 15 digits; larger results are shown as `Error`.
+- You can also send numbers and keys like `+`, `=`, `add` or `c`. Expressions like `(2 + 3) × 4` are entered as one number, and the bot replies to them with the result.
+- The bot deletes keys and numbers, tapped or sent, and logs each computed operation, such as `15 + 10 = 25`.
+- After 10 minutes without input, the bot turns the calculator off and discards the calculation; tap any key to turn it on.
+- The bot sends keys like `/+` to apps with chat protocol version 21 or later, and keys like `/add` to older apps.
+
+## Run
+
+```bash
+npm install
+npm run build
+npm start
+```
+
+The bot prints its address and keeps its data in `./data`.
+
+To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`.
+
+To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`.
+
+## Test
+
+```bash
+npm test
+```
+
+A local SMP server is started for the end-to-end test. On Linux, `smp-server` is downloaded from [simplexmq releases](https://github.com/simplex-chat/simplexmq/releases); to use another build, or on other systems, set `SMP_SERVER` to the path of `smp-server`.
diff --git a/apps/simplex-calculator-bot/calculator.test.ts b/apps/simplex-calculator-bot/calculator.test.ts
new file mode 100644
index 0000000000..e7662666c4
--- /dev/null
+++ b/apps/simplex-calculator-bot/calculator.test.ts
@@ -0,0 +1,202 @@
+import {describe, test, expect} from "vitest"
+import {Calc, Key, calculatorText, initialCalc, press, textInput} from "./src/calculator.js"
+
+type Input = {tap: string} | {text: string}
+
+function run(inputs: Input[]): {calc: Calc, logLines: string[]} {
+ const logLines: string[] = []
+ const calc = inputs.reduce((current, input) => {
+ const [next, logLine] = "tap" in input ? press(current, input.tap as Key) : textInput(input.text)!.update(current)
+ if (logLine) logLines.push(logLine)
+ return next
+ }, initialCalc)
+ return {calc, logLines}
+}
+
+const tap = (keys: string) => run(keys.split(" ").map(key => ({tap: key})))
+const type = (...texts: string[]) => run(texts.map(text => ({text})))
+const display = (keys: string) => tap(keys).calc.display
+
+describe("keys", () => {
+ test("enter numbers", () => {
+ expect(display("1 2 . 5")).toBe("12.5")
+ expect(display("0 0 7")).toBe("7")
+ expect(display(". 5")).toBe("0.5")
+ expect(display("1 . . 5")).toBe("1.5")
+ })
+
+ test("compute × and ÷ before + and -", () => {
+ expect(tap("2 + 2 =")).toEqual({calc: expect.objectContaining({display: "4"}), logLines: ["2 + 2 = 4"]})
+ expect(tap("2 + 3 × 4 =").logLines).toEqual(["3 × 4 = 12\n2 + 12 = 14"])
+ expect(tap("2 × 3 + 4 =").logLines).toEqual(["2 × 3 = 6", "6 + 4 = 10"])
+ expect(display("1 0 - 2 × 3 =")).toBe("4")
+ expect(display("1 0 - 2 - 3 =")).toBe("5")
+ expect(display("8 ÷ 4 ÷ 2 =")).toBe("1")
+ })
+
+ test("show the operand of the pressed operator", () => {
+ expect(display("2 + 3 ×")).toBe("3")
+ expect(display("2 × 3 +")).toBe("6")
+ expect(display("2 + 3 × 4 ×")).toBe("12")
+ expect(display("2 + 3 × 4 +")).toBe("14")
+ })
+
+ test("log each computed operation", () => {
+ expect(tap("1 5 + 1 0 + 5 + 1 =").logLines).toEqual(["15 + 10 = 25", "25 + 5 = 30", "30 + 1 = 31"])
+ expect(tap("2 + 3 × 4 × 5 +").logLines).toEqual(["3 × 4 = 12", "12 × 5 = 60\n2 + 60 = 62"])
+ expect(tap("2 + 3 × 4").logLines).toEqual([])
+ expect(tap("5 =").logLines).toEqual([])
+ })
+
+ test("replace operator", () => {
+ expect(tap("2 + × 3 =").logLines).toEqual(["2 × 3 = 6"])
+ expect(tap("2 + 3 × + 4 =").logLines).toEqual(["2 + 3 = 5", "5 + 4 = 9"])
+ expect(tap("2 × 3 + × 4 =").logLines).toEqual(["2 × 3 = 6", "6 × 4 = 24"])
+ })
+
+ test("continue from result", () => {
+ expect(tap("2 + 2 = + 3 =").logLines).toEqual(["2 + 2 = 4", "4 + 3 = 7"])
+ expect(display("2 + 2 = 5")).toBe("5")
+ })
+
+ test("percent", () => {
+ expect(display("1 0 0 + 1 5 %")).toBe("15")
+ expect(tap("1 0 0 + 1 5 % =").logLines).toEqual(["100 + 15% = 115"])
+ expect(display("1 0 0 - 1 5 %")).toBe("15")
+ expect(display("1 0 0 - 1 5 % =")).toBe("85")
+ expect(display("5 0 × 1 0 %")).toBe("0.1")
+ expect(display("5 0 × 1 0 % =")).toBe("5")
+ expect(display("5 0 ÷ 1 0 %")).toBe("0.1")
+ expect(display("5 0 ÷ 1 0 % =")).toBe("500")
+ expect(display("1 5 %")).toBe("0.15")
+ expect(display("1 0 0 + 2 × 1 5 %")).toBe("0.15")
+ expect(tap("1 0 0 + 2 × 1 5 % =").logLines).toEqual(["2 × 15% = 0.3\n100 + 0.3 = 100.3"])
+ expect(display("2 × 3 + 1 0 %")).toBe("0.6")
+ })
+
+ test("square root and sign", () => {
+ expect(display("9 √")).toBe("3")
+ expect(tap("1 + 9 √ =").logLines).toEqual(["1 + √9 = 4"])
+ expect(display("5 ±")).toBe("-5")
+ expect(display("5 ± ±")).toBe("5")
+ expect(display("± 5")).toBe("-5")
+ expect(display("2 + 2 = ±")).toBe("-4")
+ })
+
+ test("clear entry, then clear all", () => {
+ expect(tap("2 + 3 C 4 =").logLines).toEqual(["2 + 4 = 6"])
+ expect(tap("2 + 3 C C").calc).toEqual(initialCalc)
+ })
+
+ test("display without exponent", () => {
+ expect(display("0 . 1 + 0 . 2 =")).toBe("0.3")
+ expect(display("1 ÷ 3 =")).toBe("0.33333333333333")
+ expect(display("1 ÷ 1 0 0 0 0 0 0 0 =")).toBe("0.0000001")
+ expect(display("1 ÷ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 =")).toBe("0.00000000000001")
+ expect(display(". 0 0 0 0 0 0 0 1 × . 0 0 0 0 0 0 0 1 =")).toBe("0")
+ })
+
+ test("limit digits and overflow", () => {
+ expect(display("1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7")).toBe("123456789012345")
+ expect(display("9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 + 1 =")).toBe("Error")
+ })
+
+ test("errors", () => {
+ expect(display("1 ÷ 0 =")).toBe("Error")
+ expect(display("2 ± √")).toBe("Error")
+ expect(display("1 ÷ 0 = 5")).toBe("5")
+ expect(display("1 ÷ 0 + 2 =")).toBe("Error")
+ expect(tap("1 ÷ 0 = C").calc).toEqual(initialCalc)
+ })
+})
+
+describe("typed messages", () => {
+ test("numbers and keys", () => {
+ expect(type("25", "+", "25", "=")).toEqual({calc: expect.objectContaining({display: "50"}), logLines: ["25 + 25 = 50"]})
+ expect(type("25", "add", "25", "eq").logLines).toEqual(["25 + 25 = 50"])
+ expect(type("6", "x", "7", "=").logLines).toEqual(["6 × 7 = 42"])
+ expect(type("5", "*", "5", "/", "2", "=").logLines).toEqual(["5 × 5 = 25", "25 ÷ 2 = 12.5"])
+ expect(type("25", "+", "3", "c", "4", "=").logLines).toEqual(["25 + 4 = 29"])
+ })
+
+ test("mixed with taps", () => {
+ expect(run([{text: "25"}, {tap: "+"}, {text: "25"}, {tap: "="}]).logLines).toEqual(["25 + 25 = 50"])
+ })
+
+ test("expressions are entered as a number", () => {
+ const result = (text: string) => type(text).calc.display
+ expect(result("2 + 2")).toBe("4")
+ expect(result("12 × 3 + 4")).toBe("40")
+ expect(result("12 * 3 + 4 =")).toBe("40")
+ expect(result("8 / 2")).toBe("4")
+ expect(result("3x3")).toBe("9")
+ expect(result("-5")).toBe("-5")
+ expect(result("2 × -3")).toBe("-6")
+ expect(result("100 + 15%")).toBe("115")
+ expect(result("42")).toBe("42")
+ expect(result("2 + 3 × 4")).toBe("14")
+ expect(result("(2 + 3) × 4")).toBe("20")
+ expect(result("2 × (3 + 4)")).toBe("14")
+ expect(result("((1 + 2) × 3)")).toBe("9")
+ expect(result("-(2 + 3)")).toBe("-5")
+ expect(result("100 + (10 + 5)%")).toBe("115")
+ expect(type("10", "×", "2 + 3", "=").logLines).toEqual(["10 × 5 = 50"])
+ expect(type("10", "+", "25=").calc.display).toBe("35")
+ })
+
+ test("result only for expressions", () => {
+ const result = (text: string) => textInput(text)?.result
+ expect(result("(2 + 3) × 4")).toBe("20")
+ expect(result("(2 + 3) × 4 =")).toBe("20")
+ expect(result("15%")).toBe("0.15")
+ expect(result("1 / 0")).toBe("Error")
+ expect(result("25")).toBeUndefined()
+ expect(result("-5")).toBeUndefined()
+ expect(result("25=")).toBeUndefined()
+ expect(result("+")).toBeUndefined()
+ })
+
+ test("overflow in typed numbers", () => {
+ expect(type("1234567890123456").calc.display).toBe("Error")
+ expect(type("123456789012345").calc.display).toBe("123456789012345")
+ })
+
+ test("reject other text", () => {
+ expect(textInput("hello")).toBeUndefined()
+ expect(textInput("2 ^ 3")).toBeUndefined()
+ expect(textInput("2.3.4")).toBeUndefined()
+ expect(textInput("(2 + 3")).toBeUndefined()
+ expect(textInput("2 + 3)")).toBeUndefined()
+ expect(textInput("()")).toBeUndefined()
+ expect(textInput("2(3)")).toBeUndefined()
+ expect(textInput("2 +")).toBeUndefined()
+ })
+})
+
+describe("calculator text", () => {
+ const nbsp = String.fromCharCode(0xa0)
+
+ test("symbol keys", () => {
+ expect(calculatorText(initialCalc, true)).toBe([
+ "*0*",
+ "/C /± /% /÷",
+ "/7 /8 /9 /×",
+ "/4 /5 /6 /-",
+ "/1 /2 /3 /+",
+ "/√ /0 /. /=",
+ ].join("\n"))
+ })
+
+ test("switched off: keys without number", () => {
+ const [displayLine, ...rows] = calculatorText(undefined, true).split("\n")
+ expect(displayLine).toBe(`*${nbsp}*`)
+ expect(rows).toEqual(calculatorText(initialCalc, true).split("\n").slice(1))
+ })
+
+ test("word keys padded to equal width", () => {
+ const pad = (n: number) => `\`${nbsp.repeat(n)}\``
+ const [displayLine, firstRow] = calculatorText(initialCalc, false).split("\n")
+ expect(displayLine).toBe("*0*")
+ expect(firstRow).toBe(`/C ${pad(4)}/neg ${pad(2)}/pct ${pad(2)}/div ${pad(2)}`)
+ })
+})
diff --git a/apps/simplex-calculator-bot/calculatorBot.test.ts b/apps/simplex-calculator-bot/calculatorBot.test.ts
new file mode 100644
index 0000000000..85a40c0550
--- /dev/null
+++ b/apps/simplex-calculator-bot/calculatorBot.test.ts
@@ -0,0 +1,74 @@
+import {afterAll, beforeAll, test, expect} from "vitest"
+import {mkdtempSync, rmSync} from "fs"
+import {tmpdir} from "os"
+import {join} from "path"
+import {T} from "@simplex-chat/types"
+import {api, bot, util} from "simplex-chat"
+import {runCalculatorBot} from "./src/calculatorBot.js"
+import {SmpServer, startSmpServer} from "./test/smpServer.js"
+
+let smpServer: SmpServer
+
+beforeAll(async () => { smpServer = await startSmpServer() }, 120000)
+
+afterAll(() => smpServer?.stop())
+
+async function useSmpServer(chat: api.ChatApi): Promise {
+ expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"})
+}
+
+async function prepareBotDatabase(dbOpts: bot.BotDbOpts): Promise {
+ const chat = await api.ChatApi.init(dbOpts)
+ await chat.apiCreateActiveUser({displayName: "SimpleX Calculator", fullName: ""})
+ await chat.startChat()
+ await useSmpServer(chat)
+ await chat.close()
+}
+
+type ItemCheck = (ci: T.AChatItem) => boolean
+
+const calculatorShows = (display: string): ItemCheck => ci => ci.chatItem.meta.itemText.startsWith(`*${display}*\n`)
+
+const hasText = (text: string): ItemCheck => ci => ci.chatItem.meta.itemText === text
+
+const repliesTo = (quoted: string, text: string): ItemCheck => ({chatItem}) =>
+ chatItem.meta.itemText === text && chatItem.quotedItem?.content.text === quoted
+
+test("calculator in business chat", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "calculator-bot-"))
+ const botDbOpts: bot.BotDbOpts = {type: "sqlite", filePrefix: join(dir, "bot")}
+ await prepareBotDatabase(botDbOpts)
+ const [calculator, _botUser, address] = await runCalculatorBot(botDbOpts)
+ const alice = await api.ChatApi.init({type: "sqlite", filePrefix: join(dir, "alice")})
+ const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""})
+ await alice.startChat()
+ await useSmpServer(alice)
+ const receives = (check: ItemCheck) => alice.wait("newChatItems", ({chatItems}) => chatItems.some(check), 30000)
+ const deletes = (text: string) =>
+ alice.wait("chatItemsDeleted", ({chatItemDeletions}) => chatItemDeletions.some(({deletedChatItem}) => hasText(text)(deletedChatItem)), 30000)
+ try {
+ const [_plan, link] = await alice.apiConnectPlan(aliceUser.userId, util.contactAddressStr(address!.connLinkContact))
+ const firstCalculator = receives(calculatorShows("0"))
+ await alice.apiConnect(aliceUser.userId, false, link)
+ const calculatorItem = (await firstCalculator)?.chatItems.find(calculatorShows("0"))
+ expect(calculatorItem?.chatInfo.type).toBe(T.ChatType.Group)
+ const groupId = (calculatorItem!.chatInfo as T.ChatInfo.Group).groupInfo.groupId
+ const send = async (texts: string[], ...events: Promise[]) => {
+ for (const text of texts) await alice.apiSendTextMessage([T.ChatType.Group, groupId], text)
+ for (const event of events) expect(await event).toBeDefined()
+ }
+ await send(["/2", "/+", "/2", "/="], receives(hasText("2 + 2 = 4")), receives(calculatorShows("4")), deletes("/+"))
+ await send(["2 × (3 + 4) - 1"], receives(repliesTo("2 × (3 + 4) - 1", "13")), receives(calculatorShows("13")))
+ await send(
+ ["15", "+", "10", "+", "5", "="],
+ receives(hasText("15 + 10 = 25")),
+ receives(hasText("25 + 5 = 30")),
+ receives(calculatorShows("30")),
+ deletes("+"),
+ )
+ } finally {
+ await alice.close()
+ await calculator.close()
+ rmSync(dir, {recursive: true, force: true})
+ }
+}, 120000)
diff --git a/apps/simplex-calculator-bot/package.json b/apps/simplex-calculator-bot/package.json
new file mode 100644
index 0000000000..262676ec3e
--- /dev/null
+++ b/apps/simplex-calculator-bot/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "simplex-calculator-bot",
+ "version": "0.1.0",
+ "private": true,
+ "main": "dist/index.js",
+ "scripts": {
+ "build": "tsc",
+ "start": "node dist/index.js",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@simplex-chat/types": "^0.12.0",
+ "simplex-chat": "^7.1.0-beta.4.2"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "typescript": "^5.9.3",
+ "vitest": "^1.6.1"
+ },
+ "author": "SimpleX Chat",
+ "license": "AGPL-3.0"
+}
diff --git a/apps/simplex-calculator-bot/src/calculator.ts b/apps/simplex-calculator-bot/src/calculator.ts
new file mode 100644
index 0000000000..12e33159f3
--- /dev/null
+++ b/apps/simplex-calculator-bot/src/calculator.ts
@@ -0,0 +1,221 @@
+type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
+type Operator = "+" | "-" | "×" | "÷"
+export type Key = Digit | Operator | "C" | "±" | "%" | "√" | "." | "="
+export type Update = (calc: Calc) => [Calc, string?]
+
+export interface Calc {
+ display: string
+ operand: string
+ terms: Term[]
+ mode: "typing" | "result" | "operator"
+}
+
+interface Operand {
+ text: string
+ value: number
+}
+
+interface Term extends Operand {
+ op: Operator
+}
+
+export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "typing"}
+
+const keypad: Key[][] = [
+ ["C", "±", "%", "÷"],
+ ["7", "8", "9", "×"],
+ ["4", "5", "6", "-"],
+ ["1", "2", "3", "+"],
+ ["√", "0", ".", "="],
+]
+
+const keyWords: Partial> = {
+ "±": "neg",
+ "%": "pct",
+ "÷": "div",
+ "×": "mul",
+ "-": "sub",
+ "+": "add",
+ "√": "sqrt",
+ ".": "dot",
+ "=": "eq",
+}
+
+function keyWord(key: Key): string {
+ return keyWords[key] ?? key
+}
+
+const operatorNames = new Map([
+ ["+", "+"], ["-", "-"], ["−", "-"], ["×", "×"], ["x", "×"], ["*", "×"], ["÷", "÷"], ["/", "÷"],
+])
+
+export const keyNames = new Map([
+ ...keypad.flat().flatMap((key): [string, Key][] => [[key, key], [keyWord(key), key]]),
+ ...operatorNames,
+ ["c", "C"],
+])
+
+const maxDigits = 15
+const errorDisplay = "Error"
+
+export function press(calc: Calc, key: Key): [Calc, string?] {
+ switch (key) {
+ case "C": return [clear(calc)]
+ case "=": return equals(calc)
+ case "+": case "-": case "×": case "÷": return operator(calc, key)
+ case "%": return [showNumber(calc, percent(calc), `${calc.operand}%`)]
+ case "√": return [showNumber(calc, Math.sqrt(value(calc)), `√${calc.operand}`)]
+ case "±": return [negate(calc)]
+ default: return [enter(calc, key)]
+ }
+}
+
+function clear(calc: Calc): Calc {
+ if (calc.display === "0" || calc.display === errorDisplay) return initialCalc
+ return {...calc, display: "0", operand: "0", mode: "typing"}
+}
+
+function enter(calc: Calc, key: Digit | "."): Calc {
+ const display = append(calc.mode === "typing" ? calc.display : "0", key)
+ return {...calc, display, operand: display, mode: "typing"}
+}
+
+function append(display: string, key: Digit | "."): string {
+ if (key === ".") return display.includes(".") ? display : `${display}.`
+ if (display.replace(/\D/g, "").length >= maxDigits) return display
+ return display.replace(/^(-?)0$/, "$1") + key
+}
+
+function negate(calc: Calc): Calc {
+ if (calc.mode !== "typing") return showNumber(calc, -value(calc))
+ const display = calc.display.startsWith("-") ? calc.display.slice(1) : `-${calc.display}`
+ return {...calc, display, operand: display}
+}
+
+function percent(calc: Calc): number {
+ const last = calc.terms.at(-1)
+ const share = value(calc) / 100
+ return last && isAdditive(last.op) ? last.value * share : share
+}
+
+function showNumber(calc: Calc, n: number, operand?: string): Calc {
+ const display = format(n)
+ return {...calc, display, operand: operand ?? display, mode: "result"}
+}
+
+function operator(calc: Calc, op: Operator): [Calc, string?] {
+ const terms = calc.mode === "operator" ? calc.terms.slice(0, -1) : calc.terms
+ const reduced = reduce(terms, {text: calc.operand, value: value(calc)}, precedence(op))
+ const display = format(reduced.right.value)
+ const logLine = reduced.lines.length > 0 ? reduced.lines.join("\n") : undefined
+ return [{display, operand: display, terms: [...reduced.terms, {...reduced.right, op}], mode: "operator"}, logLine]
+}
+
+function equals(calc: Calc): [Calc, string?] {
+ if (calc.terms.length === 0) return [calc]
+ const {right, lines} = reduce(calc.terms, {text: calc.operand, value: value(calc)}, 0)
+ return [{display: right.text, operand: right.text, terms: [], mode: "result"}, lines.join("\n")]
+}
+
+function reduce(terms: Term[], right: Operand, minPrecedence: number): {terms: Term[], right: Operand, lines: string[]} {
+ const left = terms.at(-1)
+ if (!left || precedence(left.op) < minPrecedence) return {terms, right, lines: []}
+ const text = format(apply(left.value, left.op, right.value))
+ const reduced = reduce(terms.slice(0, -1), {text, value: Number(text)}, minPrecedence)
+ return {...reduced, lines: [`${left.text} ${left.op} ${right.text} = ${text}`, ...reduced.lines]}
+}
+
+function precedence(op: Operator): number {
+ return isAdditive(op) ? 1 : 2
+}
+
+function isAdditive(op: Operator): boolean {
+ return op === "+" || op === "-"
+}
+
+function apply(a: number, op: Operator, b: number): number {
+ switch (op) {
+ case "+": return a + b
+ case "-": return a - b
+ case "×": return a * b
+ case "÷": return a / b
+ }
+}
+
+function value(calc: Calc): number {
+ return Number(calc.display)
+}
+
+function format(n: number): string {
+ const rounded = Number(n.toPrecision(maxDigits))
+ if (!Number.isFinite(rounded) || Math.abs(rounded) >= 10 ** maxDigits) return errorDisplay
+ const integerDigits = Math.trunc(Math.abs(rounded)).toString().length
+ const fixed = rounded.toFixed(maxDigits - integerDigits)
+ const trimmed = fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed
+ return trimmed === "-0" ? "0" : trimmed
+}
+
+export function textInput(text: string): {update: Update, result?: string} | undefined {
+ const input = text.replace(/\s/g, "")
+ const entry = input.replace(/=$/, "")
+ const n = expressionValue(entry)
+ if (n !== undefined) {
+ const update: Update = calc => {
+ const entered = showNumber(calc, n)
+ return input.endsWith("=") ? press(entered, "=") : [entered]
+ }
+ return {update, result: Number.isNaN(Number(entry)) ? format(n) : undefined}
+ }
+ const key = keyNames.get(input.toLowerCase())
+ return key ? {update: calc => press(calc, key)} : undefined
+}
+
+const tokenPattern = /\d+(?:\.\d*)?|\.\d+|[-+−×x*÷\/%()]/g
+
+interface Parsed {
+ value: number
+ rest: string[]
+}
+
+function expressionValue(text: string): number | undefined {
+ const tokens = text.match(tokenPattern) ?? []
+ const parsed = tokens.join("") === text ? expression(tokens, initialCalc) : undefined
+ return parsed?.rest.length === 0 ? parsed.value : undefined
+}
+
+function expression(tokens: string[], calc: Calc): Parsed | undefined {
+ const operand = term(tokens)
+ if (!operand) return undefined
+ const percent = operand.rest[0] === "%"
+ const shown = showNumber(calc, operand.value)
+ const entered = percent ? press(shown, "%")[0] : shown
+ const rest = percent ? operand.rest.slice(1) : operand.rest
+ const op = operatorNames.get(rest[0])
+ return op ? expression(rest.slice(1), press(entered, op)[0]) : {value: value(press(entered, "=")[0]), rest}
+}
+
+function term([first, ...rest]: string[]): Parsed | undefined {
+ if (first === "(") {
+ const inner = expression(rest, initialCalc)
+ return inner?.rest[0] === ")" ? {value: inner.value, rest: inner.rest.slice(1)} : undefined
+ }
+ if (operatorNames.get(first) === "-") {
+ const operand = term(rest)
+ return operand && {value: -operand.value, rest: operand.rest}
+ }
+ const n = Number(first)
+ return Number.isNaN(n) ? undefined : {value: n, rest}
+}
+
+const nbsp = String.fromCharCode(0xa0)
+const wordWidth = Math.max(...keypad.flat().map(key => keyWord(key).length))
+
+export function calculatorText(calc: Calc | undefined, symbolKeys: boolean): string {
+ const rows = keypad.map(row => symbolKeys ? row.map(key => `/${key}`).join(" ") : row.map(wordKey).join(""))
+ return [`*${calc?.display ?? nbsp}*`, ...rows].join("\n")
+}
+
+function wordKey(key: Key): string {
+ const word = keyWord(key)
+ return `/${word} \`${nbsp.repeat(wordWidth + 1 - word.length)}\``
+}
diff --git a/apps/simplex-calculator-bot/src/calculatorBot.ts b/apps/simplex-calculator-bot/src/calculatorBot.ts
new file mode 100644
index 0000000000..85cd1b50d4
--- /dev/null
+++ b/apps/simplex-calculator-bot/src/calculatorBot.ts
@@ -0,0 +1,100 @@
+import {T} from "@simplex-chat/types"
+import {api, bot, util} from "simplex-chat"
+import {Calc, Update, calculatorText, initialCalc, keyNames, press, textInput} from "./calculator.js"
+import {calculatorIcon} from "./icon.js"
+
+const anyTextCommandsVersion = 21
+const idleMinutes = 10
+
+const welcomeMessage = `Tap the keys or send numbers, keys like + and =, or expressions like (2 + 3) × 4.
+The calculator turns off after ${idleMinutes} minutes; any key turns it on.`
+const hint = "Send a number, a key like + or =, or an expression like (2 + 3) × 4."
+
+interface Session {
+ calc?: Calc
+ itemId: number
+ timer?: NodeJS.Timeout
+}
+
+interface Sender {
+ groupId: number
+ member: T.GroupMember
+}
+
+const sessions = new Map()
+
+function groupSender({chatInfo, chatItem}: T.AChatItem): Sender | undefined {
+ return chatInfo.type === "group" && chatItem.chatDir.type === "groupRcv"
+ ? {groupId: chatInfo.groupInfo.groupId, member: chatItem.chatDir.groupMember}
+ : undefined
+}
+
+async function showCalculator(chat: api.ChatApi, {groupId, member}: Sender, calc: Calc): Promise {
+ const symbolKeys = member.memberChatVRange.maxVersion >= anyTextCommandsVersion
+ const [sent] = await chat.apiSendTextMessage([T.ChatType.Group, groupId], calculatorText(calc, symbolKeys))
+ const itemId = sent.chatItem.meta.itemId
+ const previous = sessions.get(groupId)
+ const timer = setTimeout(() => turnOff(chat, groupId, itemId, symbolKeys), idleMinutes * 60_000)
+ sessions.set(groupId, {calc, itemId, timer})
+ if (previous) {
+ clearTimeout(previous.timer)
+ await chat.apiDeleteChatItems(T.ChatType.Group, groupId, [previous.itemId], T.CIDeleteMode.Broadcast)
+ }
+}
+
+function turnOff(chat: api.ChatApi, groupId: number, itemId: number, symbolKeys: boolean): void {
+ sessions.set(groupId, {itemId})
+ chat.apiUpdateChatItem(T.ChatType.Group, groupId, itemId, {type: "text", text: calculatorText(undefined, symbolKeys)}, false)
+ .catch(e => console.log("error turning calculator off", e))
+}
+
+async function updateCalculator(chat: api.ChatApi, sender: Sender, update: Update): Promise {
+ const [calc, logLine] = update(sessions.get(sender.groupId)?.calc ?? initialCalc)
+ if (logLine) await chat.apiSendTextMessage([T.ChatType.Group, sender.groupId], logLine)
+ await showCalculator(chat, sender, calc)
+}
+
+function tapCommand(update: Update) {
+ return async (ci: T.AChatItem, _command: util.BotCommand, chat: api.ChatApi): Promise => {
+ const sender = groupSender(ci)
+ if (!sender) return
+ await chat.apiDeleteMemberChatItem(sender.groupId, [ci.chatItem.meta.itemId])
+ await updateCalculator(chat, sender, update)
+ }
+}
+
+const keyCommands = Object.fromEntries([...keyNames].map(([name, key]) => [name, tapCommand(calc => press(calc, key))]))
+
+async function onMessage(ci: T.AChatItem, content: T.MsgContent, chat: api.ChatApi): Promise {
+ const sender = groupSender(ci)
+ if (!sender || content.type !== "text") return
+ const input = textInput(content.text)
+ if (!input) {
+ await chat.apiSendTextReply(ci, hint)
+ return
+ }
+ if (input.result) await chat.apiSendTextReply(ci, input.result)
+ else await chat.apiDeleteMemberChatItem(sender.groupId, [ci.chatItem.meta.itemId])
+ await updateCalculator(chat, sender, input.update)
+}
+
+export function runCalculatorBot(dbOpts: bot.BotDbOpts, simplexDomain?: string): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> {
+ return bot.run({
+ profile: {displayName: "SimpleX Calculator", fullName: "", image: calculatorIcon, preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}},
+ simplexDomain,
+ dbOpts,
+ options: {
+ addressSettings: {businessAddress: true, welcomeMessage},
+ commands: [{type: "command", keyword: "calc", label: "Show calculator"}],
+ },
+ onMessage,
+ onCommands: {
+ ...keyCommands,
+ calc: tapCommand(calc => [calc]),
+ "": async (ci, _command, chat) => { await chat.apiSendTextReply(ci, hint) },
+ },
+ events: {
+ joinedGroupMember: ({groupInfo, member}, chat) => showCalculator(chat, {groupId: groupInfo.groupId, member}, initialCalc),
+ },
+ })
+}
diff --git a/apps/simplex-calculator-bot/src/icon.ts b/apps/simplex-calculator-bot/src/icon.ts
new file mode 100644
index 0000000000..80bb55a347
--- /dev/null
+++ b/apps/simplex-calculator-bot/src/icon.ts
@@ -0,0 +1 @@
+export const calculatorIcon = "data:image/jpg;base64,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"
diff --git a/apps/simplex-calculator-bot/src/index.ts b/apps/simplex-calculator-bot/src/index.ts
new file mode 100644
index 0000000000..5464e25ec8
--- /dev/null
+++ b/apps/simplex-calculator-bot/src/index.ts
@@ -0,0 +1,12 @@
+import {mkdirSync} from "fs"
+import {parseArgs} from "util"
+import {runCalculatorBot} from "./calculatorBot.js"
+
+const {values: {domain}} = parseArgs({options: {domain: {type: "string"}}})
+
+mkdirSync("data", {recursive: true})
+
+runCalculatorBot({type: "sqlite", filePrefix: "./data/calculator"}, domain).catch(e => {
+ console.log("fatal error", e)
+ process.exit(1)
+})
diff --git a/apps/simplex-calculator-bot/test/smpServer.ts b/apps/simplex-calculator-bot/test/smpServer.ts
new file mode 100644
index 0000000000..c7f0d87ec7
--- /dev/null
+++ b/apps/simplex-calculator-bot/test/smpServer.ts
@@ -0,0 +1,101 @@
+import {ChildProcess, spawn} from "child_process"
+import {createHash} from "crypto"
+import {once} from "events"
+import {copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync} from "fs"
+import {AddressInfo, createConnection, createServer} from "net"
+import {tmpdir} from "os"
+import {dirname, join} from "path"
+
+const tlsFixtures = join(__dirname, "../../../tests/fixtures/tls")
+const smpServerRelease = "v7.1.0-beta.3"
+const releaseArch: Record = {x64: "x86-64", arm64: "aarch64"}
+
+export interface SmpServer {
+ address: string
+ stop: () => Promise
+}
+
+export async function startSmpServer(): Promise {
+ const port = await freePort()
+ const dir = mkdtempSync(join(tmpdir(), "smp-server-"))
+ const configDir = join(dir, "config")
+ mkdirSync(configDir)
+ for (const file of ["ca.crt", "server.crt", "server.key"]) copyFileSync(join(tlsFixtures, file), join(configDir, file))
+ const fingerprint = caFingerprint(join(configDir, "ca.crt"))
+ writeFileSync(join(configDir, "fingerprint"), `${fingerprint}\n`)
+ writeFileSync(join(configDir, "smp-server.ini"), `[STORE_LOG]\nenable: off\n\n[TRANSPORT]\nhost: localhost\nport: ${port}\nwebsockets: off\n`)
+ const server = spawn(await smpServerExecutable(), ["start"], {
+ env: {...process.env, SMP_SERVER_CFG_PATH: configDir, SMP_SERVER_LOG_PATH: join(dir, "logs")},
+ stdio: ["ignore", "ignore", "inherit"],
+ })
+ const stop = async () => {
+ if (server.exitCode === null) {
+ const exited = once(server, "exit")
+ server.kill()
+ await exited
+ }
+ rmSync(dir, {recursive: true, force: true})
+ }
+ try {
+ await once(server, "spawn")
+ await waitForServer(server, port)
+ } catch (e) {
+ await stop()
+ throw e
+ }
+ return {address: `smp://${fingerprint}@localhost:${port}`, stop}
+}
+
+async function smpServerExecutable(): Promise {
+ if (process.env.SMP_SERVER) return process.env.SMP_SERVER
+ const path = join(__dirname, "../node_modules/.cache/smp-server", `smp-server-${smpServerRelease}`)
+ if (!existsSync(path)) await downloadSmpServer(path)
+ return path
+}
+
+async function downloadSmpServer(path: string): Promise {
+ const arch = releaseArch[process.arch]
+ if (process.platform !== "linux" || !arch) {
+ throw new Error(`smp-server release binaries are not available for ${process.platform} ${process.arch}, set SMP_SERVER to the smp-server path`)
+ }
+ const url = `https://github.com/simplex-chat/simplexmq/releases/download/${smpServerRelease}/smp-server-ubuntu-22_04-${arch}`
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`error downloading ${url}: ${response.status}`)
+ mkdirSync(dirname(path), {recursive: true})
+ writeFileSync(`${path}.download`, Buffer.from(await response.arrayBuffer()), {mode: 0o755})
+ renameSync(`${path}.download`, path)
+}
+
+function caFingerprint(caFile: string): string {
+ const der = Buffer.from(readFileSync(caFile, "utf-8").replace(/-----[^-]+-----|\s/g, ""), "base64")
+ return createHash("sha256").update(der).digest("base64").replace(/\+/g, "-").replace(/\//g, "_")
+}
+
+function freePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const probe = createServer()
+ probe.on("error", reject)
+ probe.listen(0, "127.0.0.1", () => {
+ const {port} = probe.address() as AddressInfo
+ probe.close(() => resolve(port))
+ })
+ })
+}
+
+async function waitForServer(server: ChildProcess, port: number): Promise {
+ const deadline = Date.now() + 15_000
+ while (!(await canConnect(port))) {
+ if (server.exitCode !== null || Date.now() > deadline) throw new Error(`smp-server did not start on port ${port}`)
+ await new Promise(resolve => setTimeout(resolve, 100))
+ }
+}
+
+function canConnect(port: number): Promise {
+ return new Promise(resolve => {
+ const socket = createConnection({host: "localhost", port}, () => {
+ socket.destroy()
+ resolve(true)
+ })
+ socket.on("error", () => resolve(false))
+ })
+}
diff --git a/apps/simplex-calculator-bot/tsconfig.json b/apps/simplex-calculator-bot/tsconfig.json
new file mode 100644
index 0000000000..821fa663e3
--- /dev/null
+++ b/apps/simplex-calculator-bot/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "include": ["src"],
+ "compilerOptions": {
+ "declaration": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ES2022"],
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitAny": true,
+ "noImplicitReturns": true,
+ "noImplicitThis": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noEmitOnError": true,
+ "outDir": "dist",
+ "sourceMap": true,
+ "strict": true,
+ "strictNullChecks": true,
+ "target": "ES2022",
+ "types": ["node"]
+ }
+}
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg
index fcf54b281e..93f959845f 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg
index 1dec1bd4ee..d0de67a017 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg
index b14749dc91..782d2b3823 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg
index f1598e3732..b489708eca 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg
index 70badd7f13..56c0b20a37 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg
index 105f09d455..1406f27805 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg
index 0539fbe593..17e4bc94ca 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg differ
diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg
index ba7027bded..99d7484555 100644
Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg differ
diff --git a/bots/README.md b/bots/README.md
index 80c6689dce..ee2760ad78 100644
--- a/bots/README.md
+++ b/bots/README.md
@@ -43,7 +43,7 @@ To distinguish SimpleX user profile as a bot, set its `peerType` property to `"b
### Configure bot commands
-Bot commands are messages that start from `/` character. Normally, they would consist of lowercase latin letters, but commands can use any letters, digits and underscores. Commands can have parameters.
+Bot commands are messages that start from `/` character. Normally, they would consist of lowercase latin letters, but commands can use any characters, including symbols such as `/+` or `/-`. Commands can have parameters.
All commands in messages will be highlighted in the chats with the bot, and when users tap them, they will be instantly sent. If the message has a single line and starts from `/` character, the whole message will be highlighted. Otherwise, if command is included as part of the message, it will be highlighted until the first space after `/` character: e.g., `/list` command in Directory service shows user's groups.
diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md
index 973312e15d..0d67ea7da5 100644
--- a/bots/api/COMMANDS.md
+++ b/bots/api/COMMANDS.md
@@ -7,6 +7,7 @@ This file is generated automatically.
- [APIDeleteMyAddress](#apideletemyaddress)
- [APIShowMyAddress](#apishowmyaddress)
- [APISetProfileAddress](#apisetprofileaddress)
+- [APISetUserDomain](#apisetuserdomain)
- [APISetAddressSettings](#apisetaddresssettings)
[Message commands](#message-commands)
@@ -246,6 +247,53 @@ ChatCmdError: Command error (only used in WebSockets API).
---
+### APISetUserDomain
+
+Set or remove SimpleX name of bot address. The name must be registered with the address short link.
+
+*Network usage*: interactive.
+
+**Parameters**:
+- userId: int64
+- simplexDomain: string?
+
+**Syntax**:
+
+```
+/_set domain [ ]
+```
+
+```javascript
+'/_set domain ' + userId + (simplexDomain ? ' ' + simplexDomain : '') // JavaScript
+```
+
+```python
+'/_set domain ' + str(userId) + ((' ' + simplexDomain) if simplexDomain is not None else '') # Python
+```
+
+**Responses**:
+
+UserProfileUpdated: User profile updated.
+- type: "userProfileUpdated"
+- user: [User](./TYPES.md#user)
+- fromProfile: [Profile](./TYPES.md#profile)
+- toProfile: [Profile](./TYPES.md#profile)
+- updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary)
+
+UserProfileNoChange: User profile was not changed.
+- type: "userProfileNoChange"
+- user: [User](./TYPES.md#user)
+
+ChatCmdError: Command error (only used in WebSockets API).
+- type: "chatCmdError"
+- chatError: [ChatError](./TYPES.md#chaterror)
+
+**Errors**:
+- SimplexDomainNotReady: The name does not resolve to the address short link.
+
+---
+
+
### APISetAddressSettings
Set bot address settings.
diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs
index 4bcf7d3131..fdb2298dce 100644
--- a/bots/src/API/Docs/Commands.hs
+++ b/bots/src/API/Docs/Commands.hs
@@ -81,6 +81,14 @@ chatCommandsDocsData =
("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete_address " <> Param "userId"),
("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink", "CRChatCmdError"], [], Nothing, "/_show_address " <> Param "userId"),
("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"),
+ ( "APISetUserDomain",
+ [],
+ "Set or remove SimpleX name of bot address. The name must be registered with the address short link.",
+ ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"],
+ [TD "CESimplexDomainNotReady" "The name does not resolve to the address short link"],
+ Just UNInteractive,
+ "/_set domain " <> Param "userId" <> Optional "" (" " <> Param "$0") "simplexDomain"
+ ),
("APISetAddressSettings", [], "Set bot address settings.", ["CRUserContactLinkUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_address_settings " <> Param "userId" <> OnOffParam "pq_ratchet" "pqRatchet" Nothing <> " " <> Json "settings")
]
),
@@ -438,7 +446,6 @@ undocumentedCommands =
"APISetServerOperators",
"APISetUserContactReceipts",
"APISetUserGroupReceipts",
- "APISetUserDomain",
"APISetUserServers",
"APISetUserUIThemes",
"APIStandaloneFileInfo",
diff --git a/cabal.project b/cabal.project
index e1a402a940..0648c8b3e5 100644
--- a/cabal.project
+++ b/cabal.project
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
- tag: 69eb28b3abeeb6e344ebefc16a0862d9a7646b44
+ tag: 5eecd99bc2450705dfdc4982bb3604cc4942a51a
source-repository-package
type: git
diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json
index 4d353cbdae..24ea58b66b 100644
--- a/packages/simplex-chat-client/types/typescript/package.json
+++ b/packages/simplex-chat-client/types/typescript/package.json
@@ -1,6 +1,6 @@
{
"name": "@simplex-chat/types",
- "version": "0.11.4",
+ "version": "0.12.0",
"description": "TypeScript types for SimpleX Chat bot libraries",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts
index dffb45af3b..d6772bb0e5 100644
--- a/packages/simplex-chat-client/types/typescript/src/commands.ts
+++ b/packages/simplex-chat-client/types/typescript/src/commands.ts
@@ -66,6 +66,21 @@ export namespace APISetProfileAddress {
}
}
+// Set or remove SimpleX name of bot address. The name must be registered with the address short link.
+// Network usage: interactive.
+export interface APISetUserDomain {
+ userId: number // int64
+ simplexDomain?: string
+}
+
+export namespace APISetUserDomain {
+ export type Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError
+
+ export function cmdString(self: APISetUserDomain): string {
+ return '/_set domain ' + self.userId + (self.simplexDomain ? ' ' + self.simplexDomain : '')
+ }
+}
+
// Set bot address settings.
// Network usage: interactive.
export interface APISetAddressSettings {
diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md
index 739b41b34e..05c99eca5c 100644
--- a/packages/simplex-chat-nodejs/README.md
+++ b/packages/simplex-chat-nodejs/README.md
@@ -24,13 +24,13 @@ Simple bot that replies with squares of numbers you send to it:
const {bot} = await import("simplex-chat")
// if you are running from this GitHub repo:
// const {bot} = await import("../dist/index.js")
- const [chat, _user, _address] = await bot.run({
+ 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) => {
+ onMessage: async (ci, content, chat) => {
const n = +content.text
const reply = typeof n === "number" && !isNaN(n)
? `${n} * ${n} = ${n * n}`
@@ -62,6 +62,8 @@ There is an example with more options in [./examples/squaring-bot.ts](./examples
You can run it with: `npx ts-node ./examples/squaring-bot.ts`
+A larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/).
+
## PostgreSQL backend
By default, the package uses SQLite. To use PostgreSQL instead:
diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md
index bf40b3165c..13efcec7dd 100644
--- a/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md
+++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.EventSubscriberFunc.md
@@ -6,7 +6,7 @@
# Type Alias: EventSubscriberFunc\
-> **EventSubscriberFunc**\<`K`\> = (`event`) => `void` \| `Promise`\<`void`\>
+> **EventSubscriberFunc**\<`K`\> = (`event`, `chat`) => `void` \| `Promise`\<`void`\>
Defined in: [src/api.ts:50](../src/api.ts#L50)
@@ -22,6 +22,10 @@ Defined in: [src/api.ts:50](../src/api.ts#L50)
`ChatEvent` & `object`
+### chat
+
+[`ChatApi`](api.Class.ChatApi.md)
+
## Returns
`void` \| `Promise`\<`void`\>
diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md
index 5397cf3aeb..8fbb699f80 100644
--- a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md
+++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md
@@ -18,7 +18,7 @@ Defined in: [src/bot.ts:108](../src/bot.ts#L108)
### onMessage
-((`chatItem`, `content`) => `void` \| `Promise`\<`void`\>) \| `undefined`
+((`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\>) \| `undefined`
### commands
diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md
index f763f414da..3f7757041b 100644
--- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md
+++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md
@@ -34,13 +34,13 @@ Defined in: [src/bot.ts:42](../src/bot.ts#L42)
#### Index Signature
-\[`key`: `string`\]: ((`chatItem`, `command`) => `void` \| `Promise`\<`void`\>) \| `undefined`
+\[`key`: `string`\]: ((`chatItem`, `command`, `chat`) => `void` \| `Promise`\<`void`\>) \| `undefined`
***
### onMessage?
-> `optional` **onMessage?**: (`chatItem`, `content`) => `void` \| `Promise`\<`void`\>
+> `optional` **onMessage?**: (`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\>
Defined in: [src/bot.ts:40](../src/bot.ts#L40)
@@ -54,6 +54,10 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40)
`MsgContent`
+##### chat
+
+[`ChatApi`](api.Class.ChatApi.md)
+
#### Returns
`void` \| `Promise`\<`void`\>
diff --git a/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js b/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js
index 8899e9bf15..0b369a2053 100644
--- a/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js
+++ b/packages/simplex-chat-nodejs/examples/squaring-bot-readme.js
@@ -1,12 +1,12 @@
(async () => {
const {bot} = await import("../dist/index.js")
- const [chat, _user, _address] = await bot.run({
+ 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) => {
+ onMessage: async (ci, content, chat) => {
const n = +content.text
const reply = typeof n === "number" && !isNaN(n)
? `${n} * ${n} = ${n * n}`
diff --git a/packages/simplex-chat-nodejs/examples/squaring-bot.ts b/packages/simplex-chat-nodejs/examples/squaring-bot.ts
index dbb58d90dc..e3f746c1d8 100644
--- a/packages/simplex-chat-nodejs/examples/squaring-bot.ts
+++ b/packages/simplex-chat-nodejs/examples/squaring-bot.ts
@@ -3,7 +3,7 @@ import {bot, util} from "../dist"
(async () => {
const welcomeMessage = "Hello! I am a simple squaring bot.\n\nIf you send me a number, I will calculate its square."
- const [chat, _user, _address] = await bot.run({
+ await bot.run({
profile: {displayName: "Squaring bot example", fullName: ""},
dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"},
options: {
@@ -15,7 +15,7 @@ import {bot, util} from "../dist"
logContacts: true,
logNetwork: false
},
- onMessage: async (ci, content) => {
+ onMessage: async (ci, content, chat) => {
const n = +content.text
const reply = typeof n === "number" && !isNaN(n)
? `${n} * ${n} = ${n * n}`
@@ -23,11 +23,11 @@ import {bot, util} from "../dist"
await chat.apiSendTextReply(ci, reply)
},
onCommands: { // command handlers can be different from commands to be shown in client UI
- "help": async (ci: T.AChatItem, _cmd: util.BotCommand) => {
+ "help": async (ci: T.AChatItem, _cmd: util.BotCommand, chat) => {
await chat.apiSendTextMessage(ci.chatInfo, welcomeMessage)
},
// fallback handler that will be called for all other commands
- "": async (ci: T.AChatItem, _cmd: util.BotCommand) => {
+ "": async (ci: T.AChatItem, _cmd: util.BotCommand, chat) => {
await chat.apiSendTextReply(ci, "This command is not supported")
}
},
diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json
index f405be260d..0a57bdc51d 100644
--- a/packages/simplex-chat-nodejs/package.json
+++ b/packages/simplex-chat-nodejs/package.json
@@ -1,6 +1,6 @@
{
"name": "simplex-chat",
- "version": "7.1.0-beta.4",
+ "version": "7.1.0-beta.4.2",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts
index 3bb3365305..176d84d609 100644
--- a/packages/simplex-chat-nodejs/src/api.ts
+++ b/packages/simplex-chat-nodejs/src/api.ts
@@ -47,7 +47,7 @@ export const defaultBotAddressSettings: BotAddressSettings = {
businessAddress: false
}
-export type EventSubscriberFunc = (event: ChatEvent & {type: K}) => void | Promise
+export type EventSubscriberFunc = (event: ChatEvent & {type: K}, chat: ChatApi) => void | Promise
export type EventSubscribers = {[K in CEvt.Tag]?: EventSubscriberFunc}
@@ -177,7 +177,7 @@ export class ChatApi {
if (subs) {
for (const {subscriber, once} of [...subs]) {
try {
- const p = (subscriber as EventSubscriberFunc)(event)
+ const p = (subscriber as EventSubscriberFunc)(event, this)
if (p instanceof Promise) await p
} catch(e) {
console.log(`${event.type} event processing error`, e)
@@ -187,7 +187,7 @@ export class ChatApi {
}
for (const r of [...this.receivers]) {
try {
- const p = r(event)
+ const p = r(event, this)
if (p instanceof Promise) await p
} catch(e) {
console.log(`${event.type} event processing error`, e)
@@ -422,7 +422,18 @@ export class ChatApi {
const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings}))
if (r.type !== "userContactLinkUpdated") {
throw new ChatCommandError("error changing user contact address settings", r)
- }
+ }
+ }
+
+ async apiSetUserDomain(userId: number, simplexDomain?: string): Promise {
+ const r = await this.sendChatCmd(CC.APISetUserDomain.cmdString({userId, simplexDomain}))
+ switch (r.type) {
+ case "userProfileUpdated":
+ case "userProfileNoChange":
+ return r.user
+ default:
+ throw new ChatCommandError("error setting SimpleX name", r)
+ }
}
/**
diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts
index 68e787ca3e..83037e310e 100644
--- a/packages/simplex-chat-nodejs/src/bot.ts
+++ b/packages/simplex-chat-nodejs/src/bot.ts
@@ -35,17 +35,18 @@ const defaultOpts: Required = {
export interface BotConfig {
profile: T.Profile,
+ simplexDomain?: string | null,
dbOpts: BotDbOpts,
options: BotOptions,
- onMessage?: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise,
+ onMessage?: (chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise,
// command handlers can be different from commands to be shown in client UI
- onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)},
+ onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise)},
// If you use `onMessage` and to subscribe "newChatItems" event, exclude content messages from processing
// If you use `onCommands` and to subscribe "newChatItems" event, exclude commands from processing
events?: api.EventSubscribers
}
-export async function run({profile, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> {
+export async function run({profile, simplexDomain, dbOpts, options = defaultOpts, onMessage, onCommands = {}, events = {}}: BotConfig): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> {
const bot = await api.ChatApi.init(dbOpts, dbOpts.confirmMigrations || core.MigrationConfirmation.YesUp, dbOpts.queueSize)
const opts = fullOptions(options)
if (onMessage || Object.keys(onCommands).length > 0) subscribeChatItems(bot, onMessage, onCommands)
@@ -60,7 +61,8 @@ export async function run({profile, dbOpts, options = defaultOpts, onMessage, on
console.log(`Bot address: ${addressLink}`)
if (opts.useBotProfile) botProfile.contactLink = addressLink
}
- await updateBotUserProfile(bot, user, botProfile, opts)
+ const namedUser = await updateBotSimplexDomain(bot, user, simplexDomain, opts)
+ await updateBotUserProfile(bot, namedUser, botProfile, opts)
return [bot, user, address]
}
@@ -107,8 +109,8 @@ function mkBotProfile(profile: T.Profile, opts: Required): T.Profile
export function subscribeChatItems(
bot: api.ChatApi,
- onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise) | undefined,
- commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise)}
+ onMessage: ((chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise) | undefined,
+ commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise)}
) {
bot.on("newChatItems", async ({chatItems}) => {
for (const ci of chatItems) {
@@ -117,8 +119,8 @@ export function subscribeChatItems(
const cmd = util.ciBotCommand(ci.chatItem)
const cmdFunc = cmd && (commands[cmd.keyword] || commands[""])
try {
- if (cmd && cmdFunc) await cmdFunc(ci, cmd)
- else if (onMessage) await onMessage(ci, content.msgContent)
+ if (cmd && cmdFunc) await cmdFunc(ci, cmd, bot)
+ else if (onMessage) await onMessage(ci, content.msgContent, bot)
} catch (e) {
console.log(cmd && cmdFunc ? `${cmd.keyword} command processing error` : "message processing error", e)
}
@@ -180,15 +182,33 @@ async function createOrUpdateAddress(bot: api.ChatApi, user: T.User, opts: Requi
}
}
- return address
+ return address
+}
+
+async function updateBotSimplexDomain(bot: api.ChatApi, user: T.User, simplexDomain: string | null | undefined, opts: Required): Promise {
+ if (simplexDomain === undefined) return user
+ const domain = simplexDomain === null ? undefined : simplexDomain.toLowerCase()
+ if (user.profile.contactDomain?.domain === domain) return user
+ if (!opts.updateAddress) {
+ console.log("Bot SimpleX name changed")
+ return user
+ }
+ console.log("Bot SimpleX name changed, updating...")
+ try {
+ return await bot.apiSetUserDomain(user.userId, domain)
+ } catch (e) {
+ console.log("Error updating bot SimpleX name", e)
+ return user
+ }
}
async function updateBotUserProfile(bot: api.ChatApi, user: T.User, profile: T.Profile, opts: Required): Promise {
const {userId} = user
- if (!equal(util.fromLocalProfile(user.profile), profile)) {
+ const {contactDomain, ...currentProfile} = util.fromLocalProfile(user.profile)
+ if (!equal(currentProfile, profile)) {
if (opts.updateProfile) {
console.log("Bot profile changed, updating...")
- const summary = await bot.apiUpdateProfile(userId, profile)
+ const summary = await bot.apiUpdateProfile(userId, {...profile, contactDomain})
console.log(
summary
? `Bot profile updated: ${summary.updateSuccesses} updated contact(s), ${summary.updateFailures} failed contact update(s).`
diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts
index dffb0ce1bc..6b8915e93a 100644
--- a/packages/simplex-chat-nodejs/src/util.ts
+++ b/packages/simplex-chat-nodejs/src/util.ts
@@ -53,8 +53,8 @@ export function botAddressSettings({addressSettings}: T.UserContactLink): BotAdd
}
}
-export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType}: T.LocalProfile): T.Profile {
- const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType}
+export function fromLocalProfile({displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain}: T.LocalProfile): T.Profile {
+ const profile = {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, contactDomain: contactDomain && {domain: contactDomain.domain}}
for (const key in profile) {
if (typeof (profile as any)[key] === "undefined") delete (profile as any)[key]
}
diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts
index c5a4367b11..b6b92bf93b 100644
--- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts
+++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts
@@ -1,4 +1,4 @@
-import {ChatResponse, T} from "@simplex-chat/types"
+import {ChatEvent, ChatResponse, T} from "@simplex-chat/types"
import * as api from "../src/api"
import * as core from "../src/core"
@@ -30,6 +30,13 @@ describe("documented success responses", () => {
await expect(chat.apiSetProfileAddress(1, true)).resolves.toEqual({updateSuccesses: 0, updateFailures: 0, changedContacts: []})
})
+ it("apiSetUserDomain sets and removes the name", async () => {
+ const chat = await chatWithResponse({type: "userProfileNoChange", user})
+ await expect(chat.apiSetUserDomain(1, "calc.simplex")).resolves.toEqual(user)
+ await expect(chat.apiSetUserDomain(1)).resolves.toEqual(user)
+ expect(jest.mocked(core.chatSendCmd).mock.calls.map(([, cmd]) => cmd)).toEqual(["/_set domain 1 calc.simplex", "/_set domain 1"])
+ })
+
it("apiReceiveFile reports a file cancelled by sender", async () => {
const chat = await chatWithResponse({type: "rcvFileAcceptedSndCancelled", user, rcvFileTransfer: {}})
await expect(chat.apiReceiveFile(3)).rejects.toThrow("file cancelled by sender")
@@ -94,6 +101,15 @@ describe("startChat lifecycle", () => {
await chat.stopChat()
})
+ it("passes the chat api to event subscribers", async () => {
+ const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"})
+ jest.mocked(core.chatRecvMsgWait).mockResolvedValueOnce({type: "hostConnected"} as ChatEvent)
+ const subscriberChat = new Promise(resolve => chat.on("hostConnected", (_event, c) => resolve(c)))
+ await chat.startChat()
+ await expect(subscriberChat).resolves.toBe(chat)
+ await chat.stopChat()
+ })
+
it("receives with a 500 ms wait", async () => {
const chat = await chatWithResponses()
await chat.recvChatEvent()
diff --git a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts
index 39f9358996..62ff10aab4 100644
--- a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts
+++ b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts
@@ -1,6 +1,6 @@
import {ChatEvent, T} from "@simplex-chat/types"
import * as api from "../src/api"
-import {subscribeChatItems} from "../src/bot"
+import {run, subscribeChatItems} from "../src/bot"
type Handler = (evt: ChatEvent) => Promise
@@ -40,6 +40,14 @@ describe("subscribeChatItems", () => {
expect(calls).toEqual(["message:/unknown", "message:hello"])
})
+ it("passes the chat api to handlers", async () => {
+ const {bot, deliver} = fakeBot()
+ const chats: api.ChatApi[] = []
+ subscribeChatItems(bot, async (_ci, _content, chat) => { chats.push(chat) }, {help: async (_ci, _cmd, chat) => { chats.push(chat) }})
+ await deliver([item("rcvMsgContent", "/help"), item("rcvMsgContent", "hello")])
+ expect(chats).toEqual([bot, bot])
+ })
+
it("ignores sent items", async () => {
const {bot, deliver} = fakeBot()
const calls: string[] = []
@@ -48,3 +56,75 @@ describe("subscribeChatItems", () => {
expect(calls).toEqual([])
})
})
+
+describe("run", () => {
+ const address = {
+ connLinkContact: {connFullLink: "full", connShortLink: "short"},
+ addressSettings: {businessAddress: false, autoAccept: {acceptIncognito: false}},
+ } as unknown as T.UserContactLink
+
+ function fakeChat(contactDomain?: T.SimplexDomainClaim) {
+ const user = {userId: 1, profile: {displayName: "Old", fullName: "", contactDomain}} as unknown as T.User
+ const chat = {
+ on: jest.fn(),
+ apiGetActiveUser: jest.fn().mockResolvedValue(user),
+ startChat: jest.fn(),
+ apiGetUserAddress: jest.fn().mockResolvedValue(address),
+ apiSetAddressSettings: jest.fn(),
+ apiSetUserDomain: jest.fn(async (_userId: number, domain?: string) => ({...user, profile: {...user.profile, contactDomain: domain && {domain}}})),
+ apiUpdateProfile: jest.fn().mockResolvedValue({updateSuccesses: 0, updateFailures: 0}),
+ }
+ jest.spyOn(api.ChatApi, "init").mockResolvedValue(chat as unknown as api.ChatApi)
+ return chat
+ }
+
+ const runBot = (simplexDomain?: string | null, options = {}) =>
+ run({profile: {displayName: "Calculator", fullName: ""}, simplexDomain, dbOpts: {type: "sqlite", filePrefix: "unused"}, options})
+
+ const updatedProfile = (chat: ReturnType) => chat.apiUpdateProfile.mock.calls[0][1]
+
+ beforeEach(() => jest.spyOn(console, "log").mockImplementation(() => {}))
+ afterEach(() => jest.restoreAllMocks())
+
+ it("sets the configured SimpleX name", async () => {
+ const chat = fakeChat()
+ await runBot("Calc.simplex")
+ expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, "calc.simplex")
+ expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"})
+ })
+
+ it("removes the SimpleX name set to null", async () => {
+ const chat = fakeChat({domain: "calc.simplex"})
+ await runBot(null)
+ expect(chat.apiSetUserDomain).toHaveBeenCalledWith(1, undefined)
+ expect(updatedProfile(chat).contactDomain).toBeUndefined()
+ })
+
+ it("keeps the stored SimpleX name when it is not configured", async () => {
+ const chat = fakeChat({domain: "calc.simplex"})
+ await runBot()
+ expect(chat.apiSetUserDomain).not.toHaveBeenCalled()
+ expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"})
+ })
+
+ it("keeps the SimpleX name when updating the profile", async () => {
+ const chat = fakeChat({domain: "calc.simplex", proof: {presHeader: "header", signature: "signature"}})
+ await runBot("calc.simplex")
+ expect(chat.apiSetUserDomain).not.toHaveBeenCalled()
+ expect(updatedProfile(chat).displayName).toBe("Calculator")
+ expect(updatedProfile(chat).contactDomain).toEqual({domain: "calc.simplex"})
+ })
+
+ it("continues when the SimpleX name cannot be set", async () => {
+ const chat = fakeChat()
+ chat.apiSetUserDomain.mockRejectedValue(new Error("simplexDomainNotReady"))
+ await expect(runBot("calc.simplex")).resolves.toBeDefined()
+ expect(updatedProfile(chat).contactDomain).toBeUndefined()
+ })
+
+ it("does not change the SimpleX name without updateAddress", async () => {
+ const chat = fakeChat()
+ await runBot("calc.simplex", {updateAddress: false})
+ expect(chat.apiSetUserDomain).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py
index 5312505fa9..98c043dae7 100644
--- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py
+++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py
@@ -59,6 +59,19 @@ def APISetProfileAddress_cmd_string(self: APISetProfileAddress) -> str:
APISetProfileAddress_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError
+# Set or remove SimpleX name of bot address. The name must be registered with the address short link.
+# Network usage: interactive.
+class APISetUserDomain(TypedDict):
+ userId: int # int64
+ simplexDomain: NotRequired[str]
+
+
+def APISetUserDomain_cmd_string(self: APISetUserDomain) -> str:
+ return '/_set domain ' + str(self['userId']) + ((' ' + self.get('simplexDomain')) if self.get('simplexDomain') is not None else '')
+
+APISetUserDomain_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError
+
+
# Set bot address settings.
# Network usage: interactive.
class APISetAddressSettings(TypedDict):
diff --git a/plans/2026-09-10-member-profile-open-without-core.md b/plans/2026-09-10-member-profile-open-without-core.md
new file mode 100644
index 0000000000..01e9cebd3f
--- /dev/null
+++ b/plans/2026-09-10-member-profile-open-without-core.md
@@ -0,0 +1,213 @@
+# Open Member Profile Without Waiting for the Core
+
+## Context
+
+`#7388` removed `apiListMembers` from the member profile tap, which was O(group
+size) and took seconds in a group with 10000 members. It did not remove the
+dependency on the core.
+
+`showMemberInfo` (ChatView.kt:499) still awaited two calls before the modal was
+created:
+
+```kotlin
+val r = chatModel.controller.apiGroupMemberInfo(...) // awaited
+val (updatedMember, code) = if (...) {
+ val memCode = chatModel.controller.apiGetGroupMemberCode(...) // awaited
+ ...
+}
+...
+ModalManager.end.showModalCloseable(...) { ... } // only now
+```
+
+Both are single-row queries, 1-2 ms when the core is idle, which is why the tap
+looks instant in testing. But `sendCmd` is serialized against everything else the
+core is doing, so while it is busy - startup, a batch of incoming events, a long
+database operation - the tap produces nothing at all until the core drains. The
+profile opens at the speed of the core rather than the speed of the UI.
+
+The same wait is in `GroupChatInfoView.kt:125` and `MemberSupportChatView.kt:78`.
+
+## Root Cause
+
+The modal was created inside the coroutine, after both awaits, and the loaded
+values were passed into the view as plain parameters:
+
+```kotlin
+fun GroupMemberInfoView(
+ ...
+ connectionStats: ConnectionStats?,
+ connectionCode: String?,
+ ...
+)
+```
+
+A plain parameter can only be supplied by a caller that has already waited.
+
+Each call site also resolved the member through the model and rendered nothing
+when it was absent:
+
+```kotlin
+remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> ... }
+```
+
+In a large group where members were never loaded, that is an empty card - which
+is why `#7388` had to write the member into the model *before* showing the modal.
+
+## Solution
+
+Open the modal first and let the loaded values arrive afterwards. The two
+parameters become state the caller creates at the tap and fills in when the
+queries return:
+
+```kotlin
+val connStats = mutableStateOf(null)
+val connectionCode = mutableStateOf(null)
+ModalManager.end.showModalCloseable(...) { close ->
+ GroupMemberInfoView(chatRh, groupInfo, member, scrollToItemId, connStats, connectionCode, ...)
+}
+groupMembersJob = scope.launch(Dispatchers.Default) {
+ ...unchanged...
+ connStats.value = r?.second
+ connectionCode.value = code
+}
+```
+
+The card is shown from the member that is already known at the tap - from the
+chat item, or from the members list - with the model's copy preferred once it is
+there:
+
+```kotlin
+val member = remember(groupMember.groupMemberId) {
+ derivedStateOf { chatModel.getGroupMember(groupMember.groupMemberId) ?: groupMember }
+}.value
+```
+
+The `?: groupMember` fallback is what removes the dependency: the card no longer
+needs the model to contain the member, so nothing has to happen before the modal
+opens.
+
+## Technical Design
+
+### The state is created at the tap, not in the view
+
+`ModalManager.showInView` drives `AnimatedContent` from `modalCount` alone
+(ModalView.kt:207), so the card is disposed while a sub-screen is open and
+re-composed on return. State held by `remember` inside the view would lose the
+loaded values there, and the rows would disappear. A `MutableState` created in
+the click handler is captured by the modal lambda and outlives every
+recomposition of the card, with no storage of its own.
+
+### The loaded code cannot come from the model
+
+`APIGetGroupMemberCode` returns the live code beside the member
+(Commands.hs:2047); the member it returns carries only the verification record,
+which the core *clears* when the code no longer matches. So the code has to be
+held by the UI - it cannot be read back off `member.activeConn`.
+
+### The rows are reserved, not gated and not inserted
+
+iOS wraps everything below the action buttons in `if connectionLoaded`
+(GroupMemberInfoView.swift:123-288) - the action section, Address, Member,
+Servers, Connection failed and For console. Until both queries return its card is
+a header and three buttons over blank space. That is tolerable when the core
+answers in 1-2 ms; it is the wrong trade for the case this change exists for,
+where the core is busy and the wait is what the user sees.
+
+Letting each row appear as its data arrives is no better: the card is complete
+from the first frame but grows under the finger.
+
+So the rows that depend on the load are rendered from the first frame in their
+final positions, disabled, and enabled in place when the data arrives:
+
+```kotlin
+val canVerifyCode = member.memberRole != GroupMemberRole.Relay && (connectionCode != null || !connectionLoaded)
+...
+VerifyCodeButton(member.verified, verifyClicked, disabled = connectionCode == null)
+```
+
+`SectionItemView` already drops the `clickable` modifier when `disabled`
+(Section.kt:213), so a disabled row is inert and identical in size to the enabled
+one - nothing moves when it is enabled.
+
+Only rows that can be predicted locally are reserved:
+
+- **Verify security code.** The enclosing condition is already
+ `memberActive || (useRelays && memberCurrent)`, so inside it the loader's
+ `getCode` reduces to `memberRole != Relay` - known from the member at the tap.
+- **Servers.** Reserved as a whole section with a disabled "Change receiving
+ address" and empty Receiving/Sending rows, for a member the same condition says
+ is connected.
+- **Fix connection is not reserved.** `canSyncConn` needs
+ `cStats.ratchetSyncAllowed`, which is false except during ratchet desync, so a
+ placeholder for it would disappear on nearly every open - the same jump in the
+ opposite direction.
+
+A third state, `connectionLoaded`, is created at the tap beside the other two and
+set when the queries return, including on failure. Without it a failed load would
+leave the row disabled forever, because "no code yet" and "no code at all" look
+the same.
+
+Network status is reserved with them. It looks like agent state that cannot be
+predicted, but `connSubStatus` (simplexmq Agent.hs:2736) returns `Just` whenever
+`rcvQueuesInfo` is non-empty and `Nothing` only when it is empty - the same list
+that decides whether "Receiving via" is rendered. The two rows appear together,
+so reserving one and not the other is what leaves a gap.
+
+The remaining shift is "Abort changing address", which renders only while
+`rcvSwitchStatus != null` on a receiving queue. That is a switch the user
+started, so it is absent in normal use and reserving it would add a row that
+disappears on nearly every open.
+
+iOS needs its gate for a reason Kotlin does not have: `newRole` is `@State`
+initialised to a placeholder `.member` and corrected inside `.task`
+(GroupMemberInfoView.swift:30, :297), so the role picker must not render before
+that runs. Kotlin seeds it from the member inline -
+`remember { mutableStateOf(member.memberRole) }` - and is correct on the first
+frame.
+
+### The chat-change guard is kept
+
+`#7388` added `chatModel.chatId.value != groupInfo.id` so that a load finishing
+after the chat changed does not write to the model. The modal now opens before
+the guard is reached, and `KeyChangeEffect` (:56) closes the card on chat change
+as before, so the guard is left exactly as it was.
+
+`groupMembersJob.cancel()` is kept in the tap handler: `info` (ChatView.kt:390)
+preloads members *before* showing its modal, so without the cancel a pending chat
+info load would open its modal on top of the member card.
+
+## Consequences
+
+- The card renders from the member known at the tap, so it can be marginally
+ staler for the duration of the load. iOS has the same window - it renders the
+ header before `connectionLoaded`.
+- "Verify security code" and the Servers section are shown disabled until the
+ queries return, so the card does not change height as they arrive. This is a
+ third behaviour, matching neither the old Kotlin (rows inserted as data landed)
+ nor iOS (whole body hidden until loaded); porting it to iOS would be the way to
+ converge them.
+- "Fix connection" still appears on arrival when the ratchet needs syncing. It is
+ rare enough that reserving space for it would move the card more often than it
+ saves.
+- "Send message" for a member with `!sendMsgEnabled` does nothing if tapped
+ before the stats arrive. iOS is identical: `createMemberContactButton` has the
+ same `else if let connStats` with no else, and renders the action buttons
+ before `connectionLoaded`.
+- The modal is now opened from the click handler on the main thread instead of
+ from `Dispatchers.Default` / `withBGApi`. All three callers are UI click
+ handlers, so this is strictly safer - `modalViews` is a plain `ArrayList`.
+
+## Out of Scope
+
+- **Collapsing the three copies of the fetch.** The same two queries and the same
+ condition are still written out at all three call sites. That duplication has
+ already drifted once - `#7255` widened the condition for channel members in two
+ of the three and in iOS, and `ChatView.kt` was left behind until `#7486` - and
+ moving the load into the view, as iOS has done since `#5008`, would remove it.
+ It is a restructure rather than a fix, so it belongs in its own change.
+- Message *Info* (ChatView.kt:706) still loads all members; it needs them to
+ resolve delivery recipients. A narrower core API would be required.
+- Opening a second member's profile while one is already open does not switch the
+ card: `closeModals()` sets `modalCount` to 0 and `showCustomModal` back to 1, so
+ `AnimatedContent`'s target never changes. The close and open are back to back in
+ both the old and the new code, so this is neither introduced nor worsened.
diff --git a/plans/2026-09-23-android-app-links-exact-paths.md b/plans/2026-09-23-android-app-links-exact-paths.md
new file mode 100644
index 0000000000..605f540924
--- /dev/null
+++ b/plans/2026-09-23-android-app-links-exact-paths.md
@@ -0,0 +1,86 @@
+# Fix Android app opening simplex.chat web pages as connection links
+
+## Problem
+
+Tapping `https://simplex.chat/crowdfunding/` in a chat and confirming "Open" in the
+"Open web link?" alert shows "Invalid connection link" instead of opening the page
+in the browser. `/call/`, `/credits/` and `/about/` are affected the same way. iOS and
+desktop are not.
+
+## Cause
+
+"Open" calls `safeOpenUri` → `uriHandler.openUri` (`TextItemView.kt`), which on Android
+fires an `ACTION_VIEW` intent. The app's own verified app-link filter in
+`android/src/main/AndroidManifest.xml` matches the `simplex.chat` host with:
+
+```xml
+
+
+
+
+
+
+```
+
+`pathPrefix` is a plain string prefix, so `/c` also matches `/crowdfunding/`, `/call/`
+and `/credits/`, and `/a` matches `/about/`. The single-letter prefixes were added
+for short links in #5824. Android delivers the intent back to SimpleX, `processIntent`
+sets `appOpenUrl`, and `connectIfOpenedViaUri` → `planAndConnect` tries to connect via
+the web page URL and fails.
+
+iOS is not affected: `website/src/.well-known/apple-app-site-association` lists exact
+components (`/c` and `/c/*`, etc.), not string prefixes. Desktop registers no https
+handler.
+
+## Fix
+
+Make the Android filter match the iOS one: replace each `pathPrefix="/X"` with an exact
+`path="/X"` plus `pathPrefix="/X/"`.
+
+```xml
+
+
+```
+
+Connection links carry their data in the fragment (`/contact#/?v=…`, `/a#key`), which
+is not part of the matched path, so they still match the exact path. Links with a
+trailing slash or a subpath (`/contact/#…`, `/c/…`) match the `/X/` prefix, the same as
+`/X/*` on iOS.
+
+App-link verification (`autoVerify`, `assetlinks.json`) is per host and is not changed
+by the path list.
+
+## Alternatives considered
+
+- Detecting non-connection URLs in `processIntent` and re-opening them in the browser:
+ a second `ACTION_VIEW` resolves back to the same filter, so the app would need an
+ explicit browser component. That still sends every such link through SimpleX first,
+ including links tapped in other apps.
+- `pathPattern`: it has no anchors or alternation that `path` + `pathPrefix` doesn't
+ already cover, and it is harder to read.
+
+## Verification
+
+Checked on an API 27 emulator using stub APKs that contain only the app's intent
+filter, with `cmd package query-activities -a android.intent.action.VIEW -c
+android.intent.category.BROWSABLE -d `:
+
+| URL | before | after |
+|---|---|---|
+| `https://simplex.chat/crowdfunding/` | SimpleX + browser | browser |
+| `https://simplex.chat/call/` | SimpleX + browser | browser |
+| `https://simplex.chat/credits/` | SimpleX + browser | browser |
+| `https://simplex.chat/about/` | SimpleX + browser | browser |
+| `https://simplex.chat/contacts` | SimpleX + browser | browser |
+| `https://simplex.chat/blog/` | browser | browser |
+| `https://simplex.chat/contact#/?v=1` | SimpleX + browser | SimpleX + browser |
+| `https://simplex.chat/contact/#/?v=1` | SimpleX + browser | SimpleX + browser |
+| `https://simplex.chat/invitation#/?v=1` | SimpleX + browser | SimpleX + browser |
+| `https://smp4.simplex.im/a#abc` | SimpleX + browser | SimpleX + browser |
+| `https://simplex.chat/c#abc` | SimpleX + browser | SimpleX + browser |
+| `https://simplex.chat/c/abc` | SimpleX + browser | SimpleX + browser |
+| `https://simplex.chat/g#x` | SimpleX + browser | SimpleX + browser |
+| `https://simplex.chat/i#x` | SimpleX + browser | SimpleX + browser |
+
+The built arm64 debug APK's manifest was dumped with `aapt2` and contains the new
+`path` / `pathPrefix` entries.
diff --git a/plans/2026-09-24-desktop-video-stop-deadlock.md b/plans/2026-09-24-desktop-video-stop-deadlock.md
new file mode 100644
index 0000000000..e9293efba6
--- /dev/null
+++ b/plans/2026-09-24-desktop-video-stop-deadlock.md
@@ -0,0 +1,49 @@
+# Desktop: app freezes when a video is stopped while its format is being negotiated
+
+## Problem
+
+On desktop, playing a video in the chat and then opening it fullscreen (sometimes after a few attempts) leaves the fullscreen view black. The whole app stops responding: the back button, other views and the window close button all do nothing. A process restart is the only way out.
+
+It reproduces with a video that makes VLC renegotiate its output format repeatedly (the log is full of `main filter error: Failed to create video converter`). Test videos that negotiate once did not reproduce it.
+
+## Evidence
+
+Reproduced on a build of `master` (VLC 3.0.23 bundled) by looping: play inline, click to stop, click to open fullscreen, back. It froze on the second iteration. Two `jcmd Thread.print` dumps taken 2 s apart were identical:
+
+| Thread | Where it is |
+|---|---|
+| `pool-3-thread-1` (`VideoPlayer.playerThread`) | `libvlc_media_player_stop` ← `VideoPlayer.stop()` (`VideoPlayer.desktop.kt:117`) |
+| `Thread-10825` (vlc decoder thread in a JNA callback) | `libvlc_video_get_track` ← `SkiaBitmapBufferFormatCallback.getBufferFormat` (`SkiaBitmapVideoSurface.kt:83`, `player.video().track()`) |
+| `AWT-EventQueue-0` | `libvlc_media_player_get_time` ← `VideoPlayer.currentPosition` ← progress coroutine on `Dispatchers.Main` (`VideoPlayer.desktop.kt:94`) |
+
+The JVM needs `-XX:+StartAttachListener` for `jcmd` to attach, because the Haskell RTS takes over `SIGQUIT`.
+
+## Cause
+
+In VLC 3.0.23:
+
+- `libvlc_media_player_stop` (`lib/media_player.c:1065`) takes `lock_input` and, while holding it, calls `release_input_thread`, which ends in `input_Stop` + `input_Close` (`:183-184`). These wait for the input thread, and its decoder threads, to exit.
+- `libvlc_video_get_track` (`lib/video.c:628`) and `libvlc_media_player_get_time` (`lib/media_player.c:1358`) both go through `libvlc_get_input_thread`, which takes the same `lock_input` (`lib/media_player.c:198`).
+
+`getBufferFormat` runs on the decoder thread during format negotiation. When `stop()` arrives at that moment, `stop` holds the lock and waits for the decoder thread, and the decoder thread waits for the lock in `video().track()`. The UI thread's 50 ms `get_time` poll then blocks on the same lock, which freezes the app.
+
+The `track()` call was added in #7391, which fixed stretched AV1 playback by using the track size instead of the padded size that libvlc passes.
+
+## Fix
+
+Stop calling `video().track()` in `getBufferFormat`. Take the track from `media().info().videoTracks()` only, using the `singleOrNull()` fallback the code already used before VLC had selected a track.
+
+Why the remaining calls cannot deadlock the same way:
+
+- `release_input_thread` is called only from `stop` (`:1066`) and `set_media` (`:886`), which hold only `lock_input` while waiting, and from `destroy` (`:819`), which holds no lock.
+- `media()` → `libvlc_media_player_get_media` takes `object_lock` (`:922`), which is never held while waiting for the input thread.
+- `info()` → `libvlc_media_tracks_get` takes only the media item's lock (`lib/media.c:967`).
+
+Behaviour change: none for a file with one video track, which is every video SimpleX sends. A file with several video tracks gets the size libvlc passed, as it did before #7391.
+
+## Alternatives considered
+
+- **No libvlc calls in the callback at all.** Read the track size on `playerThread` before playback and hand it to the surface. This is more code and adds a metadata read before every play, and the lock analysis above shows it buys nothing for this deadlock.
+- **Crop the padded frame instead of asking for the track size.** This does not work: vlc scales the picture to whatever size the callback returns, which is why AV1 was stretched before #7391.
+- **Size from vlcj elementary-stream events.** These can arrive after the format has been negotiated, so the first frames would use the padded size.
+- **Moving the progress polling and click handlers' libvlc calls off the UI thread.** This would stop any future libvlc stall from freezing the whole app, but it is a separate change across several files and is left out of this bugfix.
diff --git a/plans/desktop-oshi-windows-npe.md b/plans/desktop-oshi-windows-npe.md
new file mode 100644
index 0000000000..9c8f9b3d54
--- /dev/null
+++ b/plans/desktop-oshi-windows-npe.md
@@ -0,0 +1,47 @@
+# Desktop: crash at startup on Windows when WMI does not answer
+
+Fixes #7580.
+
+## Problem
+
+The desktop app exits at startup on some Windows 10 machines:
+
+```
+java.lang.ExceptionInInitializerError
+ at com.jthemedetecor.OsThemeDetector.getDetector(OsThemeDetector.java:43)
+ at chat.simplex.common.platform.Resources_desktopKt.(Resources.desktop.kt:29)
+ ...
+Caused by: java.lang.NullPointerException: Cannot invoke "String.compareTo(String)" because "buildNumber" is null
+ at oshi.software.os.OperatingSystem$OSVersionInfo.(OperatingSystem.java:510)
+ at oshi.software.os.windows.WindowsOperatingSystem.queryFamilyVersionInfo(WindowsOperatingSystem.java:172)
+ ...
+ at com.jthemedetecor.util.OsInfo.(OsInfo.java:27)
+```
+
+## Cause
+
+`jSystemThemeDetector:3.8` depends on `oshi-core:5.8.6`. Its `OsInfo` static initialiser calls `OperatingSystem.getVersionInfo()`.
+
+In oshi 5.8.6, `WindowsOperatingSystem.queryFamilyVersionInfo()` starts with `buildNumber = null` and sets it only if the WMI query for `Win32_OperatingSystem` returns a row. `WmiQueryHandler.queryWMI` returns an empty result on any `COMException` (invalid namespace/class, access denied, broken WMI repository or service) or on a timeout. The `OSVersionInfo` constructor then runs:
+
+```java
+if ("10".equals(version) && buildNumber.compareTo("22000") >= 0) {
+```
+
+It throws when `os.name` is "Windows 10" and WMI returned nothing. So only machines with a failing WMI query crash; a healthy WMI returns the build number.
+
+The app calls `OsThemeDetector.getDetector()` in top-level `val` initialisers (`Resources.desktop.kt`, `Theme.desktop.kt`). The failure is an `Error` raised while the class loads, so nothing catches it. `settings` lives in the same file, so the first preference read in `runMigrations` fails and the app exits.
+
+## Fix
+
+Add `com.github.oshi:oshi-core:6.4.13` to `desktopMain`. Gradle conflict resolution replaces the transitive 5.8.6 with it.
+
+- The `buildNumber.compareTo` check is in every oshi release up to 6.3.0 and was removed in 6.3.1. There the constructor just stores the null build number.
+- 6.4.13 is built against JNA 5.14.0, the version the app already pins (`jna`, `jna-platform`). 6.3.1 used 5.12.1 and 6.6.x uses 5.16.0.
+- jSystemThemeDetector 3.8 uses only `SystemInfo.`, `getOperatingSystem`, `getCurrentPlatform`, `OperatingSystem.getFamily`, `getVersionInfo`, `OSVersionInfo.getVersion` and `PlatformEnum.{WINDOWS,MACOS,LINUX}`. All of them exist unchanged in 6.4.13.
+
+## Verification
+
+- `new OSVersionInfo("10", "", null)` throws the reported NPE with oshi 5.8.6 and returns `10` with 6.4.13.
+- With 6.4.13, jSystemThemeDetector 3.8 and JNA 5.14.0 on the classpath, `OsThemeDetector.getDetector()` loads and returns a detector.
+- `:common:dependencyInsight --configuration desktopRuntimeClasspath --dependency oshi-core` shows `5.8.6 -> 6.4.13`.
diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix
index 418d068be7..074cf7123b 100644
--- a/scripts/nix/sha256map.nix
+++ b/scripts/nix/sha256map.nix
@@ -1,5 +1,5 @@
{
- "https://github.com/simplex-chat/simplexmq.git"."69eb28b3abeeb6e344ebefc16a0862d9a7646b44" = "1wgrv44azdsn1j5kkzmrxaxxa3rlljh80795jz903cmfa97i50cx";
+ "https://github.com/simplex-chat/simplexmq.git"."5eecd99bc2450705dfdc4982bb3604cc4942a51a" = "08jwx1inj96dgbq9vwry9fcprx9sjf9w2a7d4cdfbrfp2xwv52kg";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index acb5a121ac..2d6e4bd1f0 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
-version: 7.1.0.7
+version: 7.1.0.8
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs
index 1342a8ff4b..546477514c 100644
--- a/src/Simplex/Chat/Controller.hs
+++ b/src/Simplex/Chat/Controller.hs
@@ -446,7 +446,7 @@ data ChatCommand
| APIGetCallInvitations
| APICallStatus ContactId WebRTCCallStatus
| APIUpdateProfile {userId :: UserId, profile :: Profile}
- | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe SimplexDomain}
+ | APISetUserDomain {userId :: UserId, simplexDomain :: Maybe (StrJSON "SimplexDomain" SimplexDomain)}
| APISetContactPrefs {contactId :: ContactId, preferences :: Preferences}
| APISetContactAlias {contactId :: ContactId, localAlias :: LocalAlias}
| APISetGroupAlias {groupId :: GroupId, localAlias :: LocalAlias}
diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs
index 5cacf1b8bb..603250ac51 100644
--- a/src/Simplex/Chat/Library/Commands.hs
+++ b/src/Simplex/Chat/Library/Commands.hs
@@ -1600,7 +1600,8 @@ processChatCommand cxt nm = \case
withCurrentCall contactId $ \user ct call ->
updateCallItemStatus user ct call receivedStatus Nothing $> Just call
APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile)
- APISetUserDomain userId domain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} ->
+ APISetUserDomain userId strDomain_ -> withUserId userId $ \user@User {profile = p@LocalProfile {contactLink, contactDomain}} -> do
+ let domain_ = unStrJSON <$> strDomain_
if (claimDomain <$> contactDomain) == domain_
then pure $ CRUserProfileNoChange user
else do
@@ -6149,7 +6150,7 @@ chatCommandP =
"/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP),
"/_call get" $> APIGetCallInvitations,
"/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP),
- "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> strP)),
+ "/_set domain " *> (APISetUserDomain <$> A.decimal <*> optional (A.space *> (StrJSON <$> strP))),
"/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
"/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
"/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs
index c877a22c2b..c90cd54fab 100644
--- a/src/Simplex/Chat/Markdown.hs
+++ b/src/Simplex/Chat/Markdown.hs
@@ -518,11 +518,11 @@ displayNameTextP_ = (,"") <$> quoted '\'' <|> splitPunctuation <$> takeNameTill
refChar c = c > ' ' && c /= '#' && c /= '@' && c /= '\''
commandTextP :: Parser (Text, Text)
-commandTextP = do
- (cmd, punct) <- displayNameTextP_
- case T.words cmd of
- (keyword : _) | T.all (\c -> isAlpha c || isDigit c || c == '_') keyword -> pure (cmd, punct)
- _ -> fail "invalid command keyword"
+commandTextP = commandText <$> displayNameTextP_
+ where
+ commandText (cmd, punct)
+ | T.null cmd = (punct, "")
+ | otherwise = (cmd, punct)
splitPunctuation :: Text -> (Text, Text)
splitPunctuation s = (T.dropWhileEnd isPunctuation s, T.takeWhileEnd isPunctuation s)
diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs
index 2526f2015b..6e1fab8571 100644
--- a/src/Simplex/Chat/Protocol.hs
+++ b/src/Simplex/Chat/Protocol.hs
@@ -87,6 +87,7 @@ import Simplex.Messaging.Version hiding (version)
-- 18 - relay web capabilities (2026-05-31)
-- 19 - group roster (2026-06-18)
-- 20 - p2p group member keys for signing (2026-07-26)
+-- 21 - commands with any text, link notices restricting reconnection via links (2026-09-27)
-- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig.
-- This indirection is needed for backward/forward compatibility testing.
@@ -140,6 +141,9 @@ groupRosterVersion = VersionChat 19
groupMemberKeyVersion :: VersionChat
groupMemberKeyVersion = VersionChat 20
+anyTextCommandsVersion :: VersionChat
+anyTextCommandsVersion = VersionChat 21
+
linkNoticeVersion :: VersionChat
linkNoticeVersion = VersionChat 21
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt
index aec4d76287..833a1ae602 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt
@@ -144,7 +144,7 @@ SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chu
SEARCH s USING INTEGER PRIMARY KEY (rowid=?)
Query:
- SELECT c.corr_id, cs.user_id, c.command
+ SELECT c.corr_id, cs.user_id, c.command, c.created_at
FROM commands c
JOIN connections cs USING (conn_id)
WHERE c.command_id = ?
@@ -249,6 +249,26 @@ Plan:
SEARCH snd_files USING INDEX idx_snd_files_status_created_at (status=? AND created_at>?)
USE TEMP B-TREE FOR ORDER BY
+Query:
+ SELECT DISTINCT
+ s.xftp_host, s.xftp_port, s.xftp_key_hash
+ FROM rcv_file_chunk_replicas r
+ JOIN xftp_servers s ON s.xftp_server_id = r.xftp_server_id
+ JOIN rcv_file_chunks c ON c.rcv_file_chunk_id = r.rcv_file_chunk_id
+ JOIN rcv_files f ON f.rcv_file_id = c.rcv_file_id
+ WHERE (f.rcv_file_id = ? OR f.redirect_id = ?) AND r.replica_number = 1
+
+Plan:
+MULTI-INDEX OR
+INDEX 1
+SEARCH f USING INTEGER PRIMARY KEY (rowid=?)
+INDEX 2
+SEARCH f USING COVERING INDEX idx_rcv_files_redirect_id (redirect_id=?)
+SEARCH c USING COVERING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?)
+SEARCH r USING INDEX idx_rcv_file_chunk_replicas_rcv_file_chunk_id (rcv_file_chunk_id=?)
+SEARCH s USING INTEGER PRIMARY KEY (rowid=?)
+USE TEMP B-TREE FOR DISTINCT
+
Query:
SELECT MAX(internal_id)
FROM messages
@@ -1001,7 +1021,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?)
SEARCH c USING PRIMARY KEY (conn_id=?)
Query:
- SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
+ SELECT c.user_id, q.conn_id, q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id
FROM rcv_queues q
JOIN servers s ON q.host = s.host AND q.port = s.port
@@ -1013,7 +1033,7 @@ SEARCH q USING PRIMARY KEY (host=? AND port=?)
SEARCH c USING PRIMARY KEY (conn_id=?)
Query:
- SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
+ SELECT c.user_id, q.conn_id, q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id
FROM rcv_queues q
JOIN servers s ON q.host = s.host AND q.port = s.port
@@ -1025,7 +1045,7 @@ SEARCH q USING PRIMARY KEY (host=? AND port=?)
SEARCH c USING PRIMARY KEY (conn_id=?)
Query:
- SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
+ SELECT c.user_id, q.conn_id, q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id,
q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id
FROM rcv_queues q
JOIN servers s ON q.host = s.host AND q.port = s.port
@@ -1374,6 +1394,10 @@ Query: UPDATE rcv_files SET key = ?, nonce = ?, chunk_size = ?, updated_at = ? W
Plan:
SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
+Query: UPDATE rcv_files SET status = ?, updated_at = ? WHERE (rcv_file_id = ? OR redirect_id = ?) AND status = ?
+Plan:
+SEARCH rcv_files USING INDEX idx_rcv_files_status_created_at (status=?)
+
Query: UPDATE rcv_files SET status = ?, updated_at = ? WHERE rcv_file_id = ?
Plan:
SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
@@ -1430,6 +1454,10 @@ Query: UPDATE snd_files SET status = ?, updated_at = ? WHERE snd_file_id = ?
Plan:
SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?)
+Query: UPDATE snd_files SET status = ?, updated_at = ? WHERE snd_file_id = ? AND status = ?
+Plan:
+SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?)
+
Query: UPDATE snd_messages SET rcpt_internal_id = ?, rcpt_status = ? WHERE conn_id = ? AND internal_snd_id = ?
Plan:
SEARCH snd_messages USING PRIMARY KEY (conn_id=? AND internal_snd_id=?)
diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs
index 1f9936044c..903e3b3773 100644
--- a/tests/MarkdownTests.hs
+++ b/tests/MarkdownTests.hs
@@ -406,13 +406,20 @@ textWithCommands = describe "text with commands" do
"/filter 1" <==> command "filter" "/filter" <> " 1" -- this is parsed as full command by parseMaybeMarkdownList
"send /'filter 1'." <==> "send " <> command "filter 1" "/'filter 1'" <> "."
"send /'filter 1.'!" <==> "send " <> command "filter 1." "/'filter 1.'" <> "!"
+ "send /he?lp" <==> "send " <> command "he?lp" "/he?lp"
+ "/-" <==> command "-" "/-"
+ "send /+." <==> "send " <> command "+" "/+" <> "."
+ "/'+'" <==> command "+" "/'+'"
+ it "calculator keys" do
+ "/C /± /% /÷" <==> command "C" "/C" <> " " <> command "±" "/±" <> " " <> command "%" "/%" <> " " <> command "÷" "/÷"
+ "/√ /0 /. /=" <==> command "√" "/√" <> " " <> command "0" "/0" <> " " <> command "." "/." <> " " <> command "=" "/="
+ "/C `\160\160\160\160`/neg `\160\160`" <==> command "C" "/C" <> " " <> markdown Snippet "\160\160\160\160" <> command "neg" "/neg" <> " " <> markdown Snippet "\160\160"
it "ignored as markdown" $ do
"send /'filter 1" <==> "send /'filter 1"
"send /help /'filter 1" <==> "send " <> command "help" "/help" <> " /'filter 1"
"send / help!" <==> "send / help!"
"send /help / filter" <==> "send " <> command "help" "/help" <> " / filter"
"send /help /" <==> "send " <> command "help" "/help" <> " /"
- "send /he?lp" <==> "send /he?lp"
uri' :: Text -> FormattedText
uri' = FormattedText $ Just Uri
@@ -471,6 +478,7 @@ multilineMarkdownList = describe "multiline markdown" do
it "command markdown" do
"/link 1" <<==>> [command' "link 1" "/link 1"]
" /link 1" <<==>> [command' "link 1" " /link 1"]
+ "*0*\n/7 /+" <<==>> [FormattedText (Just Bold) "0", "\n", command' "7" "/7", " ", command' "+" "/+"]
testSanitizeUri :: Spec
testSanitizeUri = describe "sanitizeUri" $ do
diff --git a/website/src/_data/languages.json b/website/src/_data/languages.json
index a3fbc62792..65ffa53fd5 100644
--- a/website/src/_data/languages.json
+++ b/website/src/_data/languages.json
@@ -23,21 +23,24 @@
"name": "Čeština",
"flag": "/img/flags/cs.svg",
"enabled": true,
- "home": true
+ "home": true,
+ "why": true
},
{
"label": "de",
"name": "Deutsch",
"flag": "/img/flags/de.svg",
"enabled": true,
- "home": true
+ "home": true,
+ "why": true
},
{
"label": "es",
"name": "Español",
"flag": "/img/flags/es.svg",
"enabled": true,
- "home": true
+ "home": true,
+ "why": true
},
{
"label": "fi",
@@ -50,7 +53,8 @@
"name": "Français",
"flag": "/img/flags/fr.svg",
"enabled": true,
- "home": true
+ "home": true,
+ "why": true
},
{
"label": "he",
@@ -64,21 +68,24 @@
"name": "Magyar",
"flag": "/img/flags/hu.svg",
"enabled": true,
- "home": true
+ "home": true,
+ "why": true
},
{
"label": "id",
"name": "Indonesia",
"flag": "/img/flags/id.svg",
"enabled": true,
- "home": true
+ "home": true,
+ "why": true
},
{
"label": "it",
"name": "Italiano",
"flag": "/img/flags/it.svg",
"enabled": true,
- "home": true
+ "home": true,
+ "why": true
},
{
"label": "ja",
@@ -98,7 +105,8 @@
"name": "Polski",
"flag": "/img/flags/pl.svg",
"enabled": true,
- "home": true
+ "home": true,
+ "why": true
},
{
"label": "pt_BR",
@@ -117,7 +125,8 @@
"label": "zh_Hans",
"name": "简体中文",
"flag": "/img/flags/cn.svg",
- "enabled": true
+ "enabled": true,
+ "why": true
},
{
"label": "nb_NO",