Merge branch 'master' into ep/link-notices
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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
data/
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const calculatorIcon = "data:image/jpg;base64,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 truncated
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 637 KiB After Width: | Height: | Size: 171 KiB |
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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": [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).`
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=?)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||