Merge branch 'master' into ep/link-notices

This commit is contained in:
Evgeny @ SimpleX Chat committed 2026-09-27 16:26:17 +00:00
commit 6ebfd08def
59 files changed
+1608 -108

No files matched your search

@@ -100,12 +100,18 @@
<data android:host="smp4.simplexonflux.com" />
<data android:host="smp5.simplexonflux.com" />
<data android:host="smp6.simplexonflux.com" />
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
<data android:pathPrefix="/a" />
<data android:pathPrefix="/c" />
<data android:pathPrefix="/g" />
<data android:pathPrefix="/i" />
<data android:path="/invitation" />
<data android:path="/contact" />
<data android:path="/a" />
<data android:path="/c" />
<data android:path="/g" />
<data android:path="/i" />
<data android:pathPrefix="/invitation/" />
<data android:pathPrefix="/contact/" />
<data android:pathPrefix="/a/" />
<data android:pathPrefix="/c/" />
<data android:pathPrefix="/g/" />
<data android:pathPrefix="/i/" />
</intent-filter>
<!-- Receive files from other apps -->
<intent-filter>
@@ -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")
@@ -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,
)
}
@@ -499,6 +499,15 @@ fun ChatView(
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
groupMembersJob.cancel()
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(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 ->
@@ -123,6 +123,15 @@ fun ModalData.GroupChatInfoView(
}
},
showMemberInfo = { member, groupRelay ->
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(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 = {
@@ -45,10 +45,11 @@ import kotlinx.coroutines.*
fun GroupMemberInfoView(
rhId: Long?,
groupInfo: GroupInfo,
member: GroupMember,
groupMember: GroupMember,
scrollToItemId: MutableState<Long?>,
connectionStats: ConnectionStats?,
connectionCode: String?,
connStats: MutableState<ConnectionStats?>,
connectionCode: State<String?>,
connectionLoaded: State<Boolean>,
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<GroupMemberRole>,
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 = {},
@@ -76,6 +76,15 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = {
val connStats = mutableStateOf<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(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,
@@ -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
+2
View File
@@ -0,0 +1,2 @@
node_modules/
data/
+36
View File
@@ -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`.
@@ -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)}`)
})
})
@@ -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<void> {
expect(await chat.sendChatCmd(`/smp ${smpServer.address}`)).toMatchObject({type: "cmdOk"})
}
async function prepareBotDatabase(dbOpts: bot.BotDbOpts): Promise<void> {
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<unknown>[]) => {
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)
+22
View File
@@ -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"
}
@@ -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<Record<Key, string>> = {
"±": "neg",
"%": "pct",
"÷": "div",
"×": "mul",
"-": "sub",
"+": "add",
"√": "sqrt",
".": "dot",
"=": "eq",
}
function keyWord(key: Key): string {
return keyWords[key] ?? key
}
const operatorNames = new Map<string, Operator>([
["+", "+"], ["-", "-"], ["−", "-"], ["×", "×"], ["x", "×"], ["*", "×"], ["÷", "÷"], ["/", "÷"],
])
export const keyNames = new Map<string, Key>([
...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)}\``
}
@@ -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<number, Session>()
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<void> {
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<void> {
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<void> => {
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<void> {
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),
},
})
}
+1
View File
@@ -0,0 +1 @@
export const calculatorIcon = "data:image/jpg;base64,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 truncated
+12
View File
@@ -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)
})
@@ -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<string, string | undefined> = {x64: "x86-64", arm64: "aarch64"}
export interface SmpServer {
address: string
stop: () => Promise<void>
}
export async function startSmpServer(): Promise<SmpServer> {
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<string> {
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<void> {
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<number> {
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<void> {
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<boolean> {
return new Promise(resolve => {
const socket = createConnection({host: "localhost", port}, () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => resolve(false))
})
}
+23
View File
@@ -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"]
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 637 KiB

After

Width:  |  Height:  |  Size: 171 KiB

+1 -1
View File
@@ -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.
+48
View File
@@ -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 <userId>[ <simplexDomain>]
```
```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.
+8 -1
View File
@@ -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",
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 69eb28b3abeeb6e344ebefc16a0862d9a7646b44
tag: 5eecd99bc2450705dfdc4982bb3604cc4942a51a
source-repository-package
type: git
@@ -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",
@@ -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 {
+4 -2
View File
@@ -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:
@@ -6,7 +6,7 @@
# Type Alias: EventSubscriberFunc\<K\>
> **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`\>
@@ -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
@@ -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`\>
@@ -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}`
@@ -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")
}
},
+1 -1
View File
@@ -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": [
+15 -4
View File
@@ -47,7 +47,7 @@ export const defaultBotAddressSettings: BotAddressSettings = {
businessAddress: false
}
export type EventSubscriberFunc<K extends CEvt.Tag> = (event: ChatEvent & {type: K}) => void | Promise<void>
export type EventSubscriberFunc<K extends CEvt.Tag> = (event: ChatEvent & {type: K}, chat: ChatApi) => void | Promise<void>
export type EventSubscribers = {[K in CEvt.Tag]?: EventSubscriberFunc<K>}
@@ -177,7 +177,7 @@ export class ChatApi {
if (subs) {
for (const {subscriber, once} of [...subs]) {
try {
const p = (subscriber as EventSubscriberFunc<typeof event.type>)(event)
const p = (subscriber as EventSubscriberFunc<typeof event.type>)(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<T.User> {
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)
}
}
/**
+31 -11
View File
@@ -35,17 +35,18 @@ const defaultOpts: Required<BotOptions> = {
export interface BotConfig {
profile: T.Profile,
simplexDomain?: string | null,
dbOpts: BotDbOpts,
options: BotOptions,
onMessage?: (chatItem: T.AChatItem, content: T.MsgContent) => void | Promise<void>,
onMessage?: (chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise<void>,
// 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<void>)},
onCommands?: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise<void>)},
// 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<BotOptions>): T.Profile
export function subscribeChatItems(
bot: api.ChatApi,
onMessage: ((chatItem: T.AChatItem, content: T.MsgContent) => void | Promise<void>) | undefined,
commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand) => void | Promise<void>)}
onMessage: ((chatItem: T.AChatItem, content: T.MsgContent, chat: api.ChatApi) => void | Promise<void>) | undefined,
commands: {[K in string]?: ((chatItem: T.AChatItem, command: util.BotCommand, chat: api.ChatApi) => void | Promise<void>)}
) {
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<BotOptions>): Promise<T.User> {
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<BotOptions>): Promise<void> {
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).`
+2 -2
View File
@@ -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]
}
@@ -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()
@@ -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<void>
@@ -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<typeof fakeChat>) => 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()
})
})
@@ -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):
@@ -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<ConnectionStats?>(null)
val connectionCode = mutableStateOf<String?>(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.
@@ -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
<data android:pathPrefix="/invitation" />
<data android:pathPrefix="/contact" />
<data android:pathPrefix="/a" />
<data android:pathPrefix="/c" />
<data android:pathPrefix="/g" />
<data android:pathPrefix="/i" />
```
`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
<data android:path="/c" />
<data android:pathPrefix="/c/" />
```
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>`:
| 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.
@@ -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.
+47
View File
@@ -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.<clinit>(Resources.desktop.kt:29)
...
Caused by: java.lang.NullPointerException: Cannot invoke "String.compareTo(String)" because "buildNumber" is null
at oshi.software.os.OperatingSystem$OSVersionInfo.<init>(OperatingSystem.java:510)
at oshi.software.os.windows.WindowsOperatingSystem.queryFamilyVersionInfo(WindowsOperatingSystem.java:172)
...
at com.jthemedetecor.util.OsInfo.<clinit>(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.<init>`, `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`.
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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}
+3 -2
View File
@@ -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 "")),
+5 -5
View File
@@ -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)
+4
View File
@@ -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
@@ -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=?)
+9 -1
View File
@@ -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
+18 -9
View File
@@ -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",