Compare commits

...

13 Commits

Author SHA1 Message Date
Avently 4c4794fe33 lib path 2024-12-13 10:31:34 -08:00
Avently f179db9545 package 2024-12-13 10:19:43 -08:00
Avently 52160f2602 change lib name 2024-12-13 10:14:37 -08:00
Avently 2a99a5235b changes 2024-12-13 19:57:06 +07:00
Avently 4fe7b674e6 rename 2024-12-13 16:50:22 +07:00
Avently a5e7fa2f9f nodejs: addon 2024-12-12 22:11:42 +07:00
spaced4ndy 708368c9c5 android, desktop: fix operators text (#5371) 2024-12-12 07:26:17 +00:00
Evgeny Poberezkin 5d8e11c3a2 blog: typo in screenshot 2024-12-10 18:39:14 +00:00
Evgeny Poberezkin df8ce2b06d blog: fix image 2024-12-10 18:13:11 +00:00
Evgeny 6ff5f31bee blog: v6.2 announcement (#5359)
* blog: v6.2 announcement

* update, images

* update

* readme

* correction

* update
2024-12-10 17:16:34 +00:00
Stanislav Dmitrenko 5e29cda27b desktop: start using continuous runtime for AppImage again (#5366) 2024-12-10 11:09:01 +00:00
Stanislav Dmitrenko 2e573662d5 android, desktop: hiding counters while scrolling (#5363)
* android, desktop: hiding counters while scrolling

* change
2024-12-10 10:07:49 +00:00
spaced4ndy 4075c26dd2 ui: offer to fix connection on call and message buttons (#5358) 2024-12-09 21:03:56 +04:00
27 changed files with 695 additions and 142 deletions
+3
View File
@@ -79,3 +79,6 @@ website/package-lock.json
website/.cache website/.cache
website/test/stubs-layout-cache/_includes/*.js website/test/stubs-layout-cache/_includes/*.js
apps/android/app/release apps/android/app/release
packages/simplex-chat-nodejs/build
packages/simplex-chat-nodejs/.vscode
packages/simplex-chat-nodejs/libs
+4 -8
View File
@@ -233,7 +233,7 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates: Recent and important updates:
[Nov 25, 2025. Servers operated by Flux - true privacy and decentralization for all users](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md) [Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) [Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
@@ -243,20 +243,14 @@ Recent and important updates:
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) [Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md). [Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md). [Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md). [Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). [Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
[All updates](./blog) [All updates](./blog)
## :zap: Quick installation of a terminal app ## :zap: Quick installation of a terminal app
@@ -384,9 +378,11 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Improve sending videos (including encryption of locally stored videos). - ✅ Improve sending videos (including encryption of locally stored videos).
- ✅ Post-quantum resistant key exchange in double ratchet protocol. - ✅ Post-quantum resistant key exchange in double ratchet protocol.
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic). - ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- ✅ Support multiple network operators in the app.
- 🏗 Large groups, communities and public channels.
- 🏗 Short links to connect and join groups.
- 🏗 Improve stability and reduce battery usage. - 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users. - 🏗 Improve experience for the new users.
- 🏗 Large groups, communities and public channels.
- Privacy & security slider - a simple way to set all settings at once. - Privacy & security slider - a simple way to set all settings at once.
- SMP queue redundancy and rotation (manual is supported). - SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address. - Include optional message into connection request sent via contact address.
+78 -29
View File
@@ -156,8 +156,8 @@ struct ChatInfoView: View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4 let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth) searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) } AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) } VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth) muteButton(width: buttonWidth)
} }
} }
@@ -314,7 +314,15 @@ struct ChatInfoView: View {
case .networkStatusAlert: return networkStatusAlert() case .networkStatusAlert: return networkStatusAlert()
case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) }) case .syncConnectionForceAlert:
return syncConnectionForceAlert({
Task {
if let stats = await syncContactConnection(contact, force: true, showAlert: { alert = .someAlert(alert: $0) }) {
connectionStats = stats
dismiss()
}
}
})
case let .queueInfo(info): return queueInfoAlert(info) case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return mkAlert(title: title, message: error)
@@ -493,7 +501,12 @@ struct ChatInfoView: View {
private func synchronizeConnectionButton() -> some View { private func synchronizeConnectionButton() -> some View {
Button { Button {
syncContactConnection(force: false) Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: { alert = .someAlert(alert: $0) }) {
connectionStats = stats
dismiss()
}
}
} label: { } label: {
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath") Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.foregroundColor(.orange) .foregroundColor(.orange)
@@ -612,25 +625,6 @@ struct ChatInfoView: View {
} }
} }
private func syncContactConnection(force: Bool) {
Task {
do {
let stats = try apiSyncContactRatchet(contact.apiId, force)
connectionStats = stats
await MainActor.run {
chatModel.updateContactConnectionStats(contact, stats)
dismiss()
}
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
}
}
}
private func savePreferences() { private func savePreferences() {
Task { Task {
do { do {
@@ -649,9 +643,32 @@ struct ChatInfoView: View {
} }
} }
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
do {
let stats = try apiSyncContactRatchet(contact.apiId, force)
await MainActor.run {
ChatModel.shared.updateContactConnectionStats(contact, stats)
}
return stats
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
showAlert(
SomeAlert(
alert: mkAlert(title: a.title, message: a.message),
id: "syncContactConnection error"
)
)
}
return nil
}
}
struct AudioCallButton: View { struct AudioCallButton: View {
var chat: Chat var chat: Chat
var contact: Contact var contact: Contact
@Binding var connectionStats: ConnectionStats?
var width: CGFloat var width: CGFloat
var showAlert: (SomeAlert) -> Void var showAlert: (SomeAlert) -> Void
@@ -659,6 +676,7 @@ struct AudioCallButton: View {
CallButton( CallButton(
chat: chat, chat: chat,
contact: contact, contact: contact,
connectionStats: $connectionStats,
image: "phone.fill", image: "phone.fill",
title: "call", title: "call",
mediaType: .audio, mediaType: .audio,
@@ -671,6 +689,7 @@ struct AudioCallButton: View {
struct VideoButton: View { struct VideoButton: View {
var chat: Chat var chat: Chat
var contact: Contact var contact: Contact
@Binding var connectionStats: ConnectionStats?
var width: CGFloat var width: CGFloat
var showAlert: (SomeAlert) -> Void var showAlert: (SomeAlert) -> Void
@@ -678,6 +697,7 @@ struct VideoButton: View {
CallButton( CallButton(
chat: chat, chat: chat,
contact: contact, contact: contact,
connectionStats: $connectionStats,
image: "video.fill", image: "video.fill",
title: "video", title: "video",
mediaType: .video, mediaType: .video,
@@ -690,6 +710,7 @@ struct VideoButton: View {
private struct CallButton: View { private struct CallButton: View {
var chat: Chat var chat: Chat
var contact: Contact var contact: Contact
@Binding var connectionStats: ConnectionStats?
var image: String var image: String
var title: LocalizedStringKey var title: LocalizedStringKey
var mediaType: CallMediaType var mediaType: CallMediaType
@@ -701,12 +722,40 @@ private struct CallButton: View {
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) { InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall { if canCall {
if CallController.useCallKit() { if let connStats = connectionStats {
CallController.shared.startCall(contact, mediaType) if connStats.ratchetSyncState == .ok {
} else { if CallController.useCallKit() {
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first CallController.shared.startCall(contact, mediaType)
dismissAllSheets(animated: true) { } else {
CallController.shared.startCall(contact, mediaType) // When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
dismissAllSheets(animated: true) {
CallController.shared.startCall(contact, mediaType)
}
}
} else if connStats.ratchetSyncAllowed {
showAlert(SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: showAlert) {
connectionStats = stats
}
}
},
secondaryButton: .cancel()
),
id: "can't call contact, fix connection"
))
} else {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Encryption renegotiation in progress."
),
id: "can't call contact, encryption renegotiation in progress"
))
} }
} }
} else if contact.nextSendGrpInv { } else if contact.nextSendGrpInv {
@@ -20,6 +20,9 @@ struct GroupMemberInfoView: View {
@State private var connectionStats: ConnectionStats? = nil @State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil @State private var connectionCode: String? = nil
@State private var connectionLoaded: Bool = false @State private var connectionLoaded: Bool = false
@State private var knownContactChat: Chat? = nil
@State private var knownContact: Contact? = nil
@State private var knownContactConnectionStats: ConnectionStats? = nil
@State private var newRole: GroupMemberRole = .member @State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert? @State private var alert: GroupMemberInfoViewAlert?
@State private var sheet: PlanAndConnectActionSheet? @State private var sheet: PlanAndConnectActionSheet?
@@ -119,8 +122,8 @@ struct GroupMemberInfoView: View {
} label: { } label: {
Label("Share address", systemImage: "square.and.arrow.up") Label("Share address", systemImage: "square.and.arrow.up")
} }
if let contactId = member.memberContactId { if member.memberContactId != nil {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if knownContactChat == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
connectViaAddressButton(contactLink) connectViaAddressButton(contactLink)
} }
} else { } else {
@@ -229,6 +232,18 @@ struct GroupMemberInfoView: View {
} }
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
} }
if let contactId = member.memberContactId, let (contactChat, contact) = knownDirectChat(contactId) {
knownContactChat = contactChat
knownContact = contact
do {
let (stats, _) = try await apiContactInfo(contactChat.chatInfo.apiId)
await MainActor.run {
knownContactConnectionStats = stats
}
} catch let error {
logger.error("apiContactInfo error: \(responseError(error))")
}
}
} }
.onChange(of: newRole) { newRole in .onChange(of: newRole) { newRole in
if newRole != member.memberRole { if newRole != member.memberRole {
@@ -274,10 +289,10 @@ struct GroupMemberInfoView: View {
GeometryReader { g in GeometryReader { g in
let buttonWidth = g.size.width / 4 let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) { if let chat = knownContactChat, let contact = knownContact {
knownDirectChatButton(chat, width: buttonWidth) knownDirectChatButton(chat, width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) } AudioCallButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) } VideoButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId { if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth) newDirectChatButton(contactId, width: buttonWidth)
@@ -366,25 +381,49 @@ struct GroupMemberInfoView: View {
func createMemberContactButton(width: CGFloat) -> some View { func createMemberContactButton(width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) { InfoViewButton(image: "message.fill", title: "message", width: width) {
progressIndicator = true if let connStats = connectionStats {
Task { if connStats.ratchetSyncState == .ok {
do { progressIndicator = true
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId) Task {
await MainActor.run { do {
progressIndicator = false let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) await MainActor.run {
ItemsModel.shared.loadOpenChat(memberContact.id) { progressIndicator = false
dismissAllSheets(animated: true) chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
}
} }
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
} }
} else if connStats.ratchetSyncAllowed {
alert = .someAlert(alert: SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
syncMemberConnection(force: false)
},
secondaryButton: .cancel()
),
id: "can't message member, fix connection"
))
} else {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't message member",
message: "Encryption renegotiation in progress."
),
id: "can't message contact, encryption renegotiation in progress"
))
} }
} }
} }
@@ -54,11 +54,10 @@ add_library( # Sets the name of the library.
simplex-api.c) simplex-api.c)
add_library( simplex SHARED IMPORTED ) add_library( simplex SHARED IMPORTED )
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libsimplex.${OS_LIB_EXT})
if(WIN32) if(WIN32)
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT})
set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB}) set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB})
else() else()
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex-chat*.${OS_LIB_EXT})
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB})
endif() endif()
@@ -131,26 +131,14 @@ fun ChatInfoView(
}, },
syncContactConnection = { syncContactConnection = {
withBGApi { withBGApi {
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) syncContactConnection(chatRh, contact, connStats, force = false)
connStats.value = cStats
if (cStats != null) {
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
close.invoke() close.invoke()
} }
}, },
syncContactConnectionForce = { syncContactConnectionForce = {
showSyncConnectionForceAlert(syncConnectionForce = { showSyncConnectionForceAlert(syncConnectionForce = {
withBGApi { withBGApi {
val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = true) syncContactConnection(chatRh, contact, connStats, force = true)
connStats.value = cStats
if (cStats != null) {
withChats {
updateContactConnectionStats(chatRh, contact, cStats)
}
}
close.invoke() close.invoke()
} }
}) })
@@ -189,6 +177,16 @@ fun ChatInfoView(
} }
} }
suspend fun syncContactConnection(rhId: Long?, contact: Contact, connectionStats: MutableState<ConnectionStats?>, force: Boolean) {
val cStats = chatModel.controller.apiSyncContactRatchet(rhId, contact.contactId, force = force)
connectionStats.value = cStats
if (cStats != null) {
withChats {
updateContactConnectionStats(rhId, contact, cStats)
}
}
}
sealed class SendReceipts { sealed class SendReceipts {
object Yes: SendReceipts() object Yes: SendReceipts()
object No: SendReceipts() object No: SendReceipts()
@@ -505,7 +503,7 @@ fun ChatInfoLayout(
currentUser: User, currentUser: User,
sendReceipts: State<SendReceipts>, sendReceipts: State<SendReceipts>,
setSendReceipts: (SendReceipts) -> Unit, setSendReceipts: (SendReceipts) -> Unit,
connStats: State<ConnectionStats?>, connStats: MutableState<ConnectionStats?>,
contactNetworkStatus: NetworkStatus, contactNetworkStatus: NetworkStatus,
customUserProfile: Profile?, customUserProfile: Profile?,
localAlias: String, localAlias: String,
@@ -553,8 +551,8 @@ fun ChatInfoLayout(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
SearchButton(modifier = Modifier.fillMaxWidth(0.25f), chat, contact, close, onSearchClicked) SearchButton(modifier = Modifier.fillMaxWidth(0.25f), chat, contact, close, onSearchClicked)
AudioCallButton(modifier = Modifier.fillMaxWidth(0.33f), chat, contact) AudioCallButton(modifier = Modifier.fillMaxWidth(0.33f), chat, contact, connStats)
VideoButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact) VideoButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, connStats)
MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, contact) MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, contact)
} }
} }
@@ -825,12 +823,14 @@ fun MuteButton(
fun AudioCallButton( fun AudioCallButton(
modifier: Modifier, modifier: Modifier,
chat: Chat, chat: Chat,
contact: Contact contact: Contact,
connectionStats: MutableState<ConnectionStats?>
) { ) {
CallButton( CallButton(
modifier = modifier, modifier = modifier,
chat, chat,
contact, contact,
connectionStats,
icon = painterResource(MR.images.ic_call), icon = painterResource(MR.images.ic_call),
title = generalGetString(MR.strings.info_view_call_button), title = generalGetString(MR.strings.info_view_call_button),
mediaType = CallMediaType.Audio mediaType = CallMediaType.Audio
@@ -841,12 +841,14 @@ fun AudioCallButton(
fun VideoButton( fun VideoButton(
modifier: Modifier, modifier: Modifier,
chat: Chat, chat: Chat,
contact: Contact contact: Contact,
connectionStats: MutableState<ConnectionStats?>
) { ) {
CallButton( CallButton(
modifier = modifier, modifier = modifier,
chat, chat,
contact, contact,
connectionStats,
icon = painterResource(MR.images.ic_videocam), icon = painterResource(MR.images.ic_videocam),
title = generalGetString(MR.strings.info_view_video_button), title = generalGetString(MR.strings.info_view_video_button),
mediaType = CallMediaType.Video mediaType = CallMediaType.Video
@@ -858,6 +860,7 @@ fun CallButton(
modifier: Modifier, modifier: Modifier,
chat: Chat, chat: Chat,
contact: Contact, contact: Contact,
connectionStats: MutableState<ConnectionStats?>,
icon: Painter, icon: Painter,
title: String, title: String,
mediaType: CallMediaType mediaType: CallMediaType
@@ -879,7 +882,23 @@ fun CallButton(
disabledLook = !canCall, disabledLook = !canCall,
onClick = onClick =
when { when {
canCall -> { { startChatCall(chat.remoteHostId, chat.chatInfo, mediaType) } } canCall -> { {
val connStats = connectionStats.value
if (connStats != null) {
if (connStats.ratchetSyncState == RatchetSyncState.Ok) {
startChatCall(chat.remoteHostId, chat.chatInfo, mediaType)
} else if (connStats.ratchetSyncAllowed) {
showFixConnectionAlert(syncConnection = {
withBGApi { syncContactConnection(chat.remoteHostId, contact, connectionStats, force = false) }
})
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cant_call_contact_alert_title),
generalGetString(MR.strings.encryption_renegotiation_in_progress)
)
}
}
} }
contact.nextSendGrpInv -> { { showCantCallContactSendMessageAlert() } } contact.nextSendGrpInv -> { { showCantCallContactSendMessageAlert() } }
!contact.active -> { { showCantCallContactDeletedAlert() } } !contact.active -> { { showCantCallContactDeletedAlert() } }
!contact.ready -> { { showCantCallContactConnectingAlert() } } !contact.ready -> { { showCantCallContactConnectingAlert() } }
@@ -1265,6 +1284,15 @@ fun showSyncConnectionForceAlert(syncConnectionForce: () -> Unit) {
) )
} }
fun showFixConnectionAlert(syncConnection: () -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.sync_connection_question),
text = generalGetString(MR.strings.sync_connection_desc),
confirmText = generalGetString(MR.strings.sync_connection_confirm),
onConfirm = syncConnection,
)
}
fun queueInfoText(info: Pair<RcvMsgInfo?, ServerQueueInfo>): String { fun queueInfoText(info: Pair<RcvMsgInfo?, ServerQueueInfo>): String {
val (rcvMsgInfo, qInfo) = info val (rcvMsgInfo, qInfo) = info
val msgInfo: String = if (rcvMsgInfo != null) json.encodeToString(rcvMsgInfo) else generalGetString(MR.strings.message_queue_info_none) val msgInfo: String = if (rcvMsgInfo != null) json.encodeToString(rcvMsgInfo) else generalGetString(MR.strings.message_queue_info_none)
@@ -984,6 +984,7 @@ fun BoxScope.ChatItemsList(
}) })
val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } }
val loadingMoreItems = remember { mutableStateOf(false) } val loadingMoreItems = remember { mutableStateOf(false) }
val animatedScrollingInProgress = remember { mutableStateOf(false) }
val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf<Long>() } val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf<Long>() }
if (!loadingMoreItems.value) { if (!loadingMoreItems.value) {
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
@@ -1004,7 +1005,7 @@ fun BoxScope.ChatItemsList(
val chatInfoUpdated = rememberUpdatedState(chatInfo) val chatInfoUpdated = rememberUpdatedState(chatInfo)
val highlightedItems = remember { mutableStateOf(setOf<Long>()) } val highlightedItems = remember { mutableStateOf(setOf<Long>()) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scrollToItem: (Long) -> Unit = remember { scrollToItem(searchValue, loadingMoreItems, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } val scrollToItem: (Long) -> Unit = remember { scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) }
val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) }
LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) LoadLastItems(loadingMoreItems, remoteHostId, chatInfo)
@@ -1314,7 +1315,7 @@ fun BoxScope.ChatItemsList(
} }
} }
} }
FloatingButtons(loadingMoreItems, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState) FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState)
FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(true)).align(Alignment.TopCenter), mergedItems, listState) FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(true)).align(Alignment.TopCenter), mergedItems, listState)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -1323,6 +1324,15 @@ fun BoxScope.ChatItemsList(
chatViewScrollState.value = it chatViewScrollState.value = it
} }
} }
LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress }
.filter { !it }
.collect {
if (animatedScrollingInProgress.value) {
animatedScrollingInProgress.value = false
}
}
}
} }
@Composable @Composable
@@ -1400,6 +1410,7 @@ private fun NotifyChatListOnFinishingComposition(
@Composable @Composable
fun BoxScope.FloatingButtons( fun BoxScope.FloatingButtons(
loadingMoreItems: MutableState<Boolean>, loadingMoreItems: MutableState<Boolean>,
animatedScrollingInProgress: MutableState<Boolean>,
mergedItems: State<MergedItems>, mergedItems: State<MergedItems>,
unreadCount: State<Int>, unreadCount: State<Int>,
maxHeight: State<Int>, maxHeight: State<Int>,
@@ -1439,8 +1450,14 @@ fun BoxScope.FloatingButtons(
bottomUnreadCount, bottomUnreadCount,
showBottomButtonWithCounter, showBottomButtonWithCounter,
showBottomButtonWithArrow, showBottomButtonWithArrow,
animatedScrollingInProgress,
composeViewHeight, composeViewHeight,
onClick = { scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) } } } onClick = {
scope.launch {
animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(0) }
}
}
) )
// Don't show top FAB if is in search // Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return if (searchValue.value.isNotEmpty()) return
@@ -1451,11 +1468,15 @@ fun BoxScope.FloatingButtons(
TopEndFloatingButton( TopEndFloatingButton(
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(true)).align(Alignment.TopEnd), Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(true)).align(Alignment.TopEnd),
topUnreadCount, topUnreadCount,
animatedScrollingInProgress,
onClick = { onClick = {
val index = mergedItems.value.items.indexOfLast { it.hasUnread() } val index = mergedItems.value.items.indexOfLast { it.hasUnread() }
if (index != -1) { if (index != -1) {
// scroll to the top unread item // scroll to the top unread item
scope.launch { tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) } } scope.launch {
animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) }
}
} }
}, },
onLongClick = { showDropDown.value = true } onLongClick = { showDropDown.value = true }
@@ -1595,10 +1616,11 @@ fun MemberImage(member: GroupMember) {
private fun TopEndFloatingButton( private fun TopEndFloatingButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
unreadCount: State<Int>, unreadCount: State<Int>,
animatedScrollingInProgress: State<Boolean>,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit onLongClick: () -> Unit
) { ) {
if (unreadCount.value > 0) { if (remember { derivedStateOf { unreadCount.value > 0 && !animatedScrollingInProgress.value } }.value) {
val interactionSource = interactionSourceWithDetection(onClick, onLongClick) val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
FloatingActionButton( FloatingActionButton(
{}, // no action here {}, // no action here
@@ -1839,6 +1861,7 @@ private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State<Int>, de
private fun scrollToItem( private fun scrollToItem(
searchValue: State<String>, searchValue: State<String>,
loadingMoreItems: MutableState<Boolean>, loadingMoreItems: MutableState<Boolean>,
animatedScrollingInProgress: MutableState<Boolean>,
highlightedItems: MutableState<Set<Long>>, highlightedItems: MutableState<Set<Long>>,
chatInfo: State<ChatInfo>, chatInfo: State<ChatInfo>,
maxHeight: State<Int>, maxHeight: State<Int>,
@@ -1876,6 +1899,7 @@ private fun scrollToItem(
highlightedItems.value = setOf(itemId) highlightedItems.value = setOf(itemId)
} else { } else {
withContext(scope.coroutineContext) { withContext(scope.coroutineContext) {
animatedScrollingInProgress.value = true
listState.value.animateScrollToItem(min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) listState.value.animateScrollToItem(min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value)
highlightedItems.value = setOf(itemId) highlightedItems.value = setOf(itemId)
} }
@@ -1937,10 +1961,11 @@ private fun BoxScope.BottomEndFloatingButton(
unreadCount: State<Int>, unreadCount: State<Int>,
showButtonWithCounter: State<Boolean>, showButtonWithCounter: State<Boolean>,
showButtonWithArrow: State<Boolean>, showButtonWithArrow: State<Boolean>,
animatedScrollingInProgress: State<Boolean>,
composeViewHeight: State<Dp>, composeViewHeight: State<Dp>,
onClick: () -> Unit onClick: () -> Unit
) = when { ) = when {
showButtonWithCounter.value -> { showButtonWithCounter.value && !animatedScrollingInProgress.value -> {
FloatingActionButton( FloatingActionButton(
onClick = onClick, onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
@@ -1954,7 +1979,7 @@ private fun BoxScope.BottomEndFloatingButton(
) )
} }
} }
showButtonWithArrow.value -> { showButtonWithArrow.value && !animatedScrollingInProgress.value -> {
FloatingActionButton( FloatingActionButton(
onClick = onClick, onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
@@ -8,8 +8,6 @@ import SectionSpacer
import SectionTextFooter import SectionTextFooter
import SectionView import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import java.net.URI
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
@@ -58,6 +56,19 @@ fun GroupMemberInfoView(
val developerTools = chatModel.controller.appPrefs.developerTools.get() val developerTools = chatModel.controller.appPrefs.developerTools.get()
var progressIndicator by remember { mutableStateOf(false) } var progressIndicator by remember { mutableStateOf(false) }
fun syncMemberConnection() {
withBGApi {
val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false)
if (r != null) {
connStats.value = r.second
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
}
if (chat != null) { if (chat != null) {
val newRole = remember { mutableStateOf(member.memberRole) } val newRole = remember { mutableStateOf(member.memberRole) }
GroupMemberInfoLayout( GroupMemberInfoLayout(
@@ -78,19 +89,30 @@ fun GroupMemberInfoView(
} }
}, },
createMemberContact = { createMemberContact = {
withBGApi { if (connectionStats != null) {
progressIndicator = true if (connectionStats.ratchetSyncState == RatchetSyncState.Ok) {
val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId) withBGApi {
if (memberContact != null) { progressIndicator = true
val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId)
withChats { if (memberContact != null) {
addChat(memberChat) val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf())
openLoadedChat(memberChat) withChats {
addChat(memberChat)
openLoadedChat(memberChat)
}
closeAll()
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
}
progressIndicator = false
} }
closeAll() } else if (connectionStats.ratchetSyncAllowed) {
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) showFixConnectionAlert(syncConnection = { syncMemberConnection() })
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cant_send_message_to_member_alert_title),
generalGetString(MR.strings.encryption_renegotiation_in_progress)
)
} }
progressIndicator = false
} }
}, },
connectViaAddress = { connReqUri -> connectViaAddress = { connReqUri ->
@@ -149,16 +171,7 @@ fun GroupMemberInfoView(
}) })
}, },
syncMemberConnection = { syncMemberConnection = {
withBGApi { syncMemberConnection()
val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false)
if (r != null) {
connStats.value = r.second
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
}, },
syncMemberConnectionForce = { syncMemberConnectionForce = {
showSyncConnectionForceAlert(syncConnectionForce = { showSyncConnectionForceAlert(syncConnectionForce = {
@@ -335,9 +348,20 @@ fun GroupMemberInfoLayout(
val knownChat = if (contactId != null) knownDirectChat(contactId) else null val knownChat = if (contactId != null) knownDirectChat(contactId) else null
if (knownChat != null) { if (knownChat != null) {
val (chat, contact) = knownChat val (chat, contact) = knownChat
val knownContactConnectionStats: MutableState<ConnectionStats?> = remember { mutableStateOf(null) }
LaunchedEffect(contact.contactId) {
withBGApi {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId)
if (contactInfo != null) {
knownContactConnectionStats.value = contactInfo.first
}
}
}
OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) })
AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact) AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats)
VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact) VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats)
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) { if (contactId != null) {
OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group
@@ -735,7 +735,10 @@ private fun ConditionsAppliedToOtherOperatorsText(userServers: List<UserOperator
} }
if (otherOperatorsToApply.value.isNotEmpty()) { if (otherOperatorsToApply.value.isNotEmpty()) {
ReadableText(MR.strings.operator_conditions_will_be_applied) ReadableText(
MR.strings.operator_conditions_will_be_applied,
args = otherOperatorsToApply.value.joinToString(", ") { it.legalName_ }
)
} }
} }
@@ -524,6 +524,10 @@
<string name="sync_connection_force_question">Renegotiate encryption?</string> <string name="sync_connection_force_question">Renegotiate encryption?</string>
<string name="sync_connection_force_desc">The encryption is working and the new encryption agreement is not required. It may result in connection errors!</string> <string name="sync_connection_force_desc">The encryption is working and the new encryption agreement is not required. It may result in connection errors!</string>
<string name="sync_connection_force_confirm">Renegotiate</string> <string name="sync_connection_force_confirm">Renegotiate</string>
<string name="sync_connection_question">Fix connection?</string>
<string name="sync_connection_desc">Connection requires encryption renegotiation.</string>
<string name="sync_connection_confirm">Fix</string>
<string name="encryption_renegotiation_in_progress">Encryption renegotiation in progress.</string>
<string name="view_security_code">View security code</string> <string name="view_security_code">View security code</string>
<string name="verify_security_code">Verify security code</string> <string name="verify_security_code">Verify security code</string>
@@ -68,7 +68,7 @@ So, for Android we can now deliver instant message notifications without comprom
Please let us know what needs to be improved - it's only the first version of instant notifications for Android! Please let us know what needs to be improved - it's only the first version of instant notifications for Android!
## Our iOS approach has one trade-off ## iOS notifications require a server
iOS is much more protective of what apps are allowed to run on the devices, and the solution that worked on Android is not viable on iOS. iOS is much more protective of what apps are allowed to run on the devices, and the solution that worked on Android is not viable on iOS.
@@ -1,23 +1,88 @@
--- ---
layout: layouts/article.html layout: layouts/article.html
title: "Servers operated by Flux - true privacy and decentralization for all users" title: "SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps"
date: 2024-12-10 date: 2024-12-10
# previewBody: blog_previews/20241210.html previewBody: blog_previews/20241210.html
# image: images/simplexonflux.png image: images/20241210-operators-1.png
# imageWide: true
draft: true
permalink: "/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html" permalink: "/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"
--- ---
# SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps # SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps
**Will be published:** Dec 10, 2024 **Published:** Dec 10, 2024
This is a placeholder page for the upcoming v6.2 release announcement! What's new in v6.2:
- Preset servers are now operated by two companies - SimpleX Chat and Flux. Read [this post](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md). - [SimpleX Chat and Flux](#simplex-chat-and-flux-improve-metadata-privacy-in-simplex-network) improve metadata privacy in SimpleX network.
- Business chats to provide support from your business to users of SimpleX network. Read [this page](../docs/BUSINESS.md). - [Business chats](#business-chats) to provide support from your business to users of SimpleX network.
- and more! - [Better user experience](#better-user-experience): open on the first unread, jump to quoted messages, see who reacted.
- [Improving notifications in iOS app](#improving-notifications-in-ios-app).
## What's new in v6.2
### SimpleX Chat and Flux improve metadata privacy in SimpleX network
<img src="./images/20241210-operators-1.png" width=288 class="float-to-right"> <img src="./images/20241210-operators-2.png" width=288 class="float-to-right">
SimpleX Chat and [Flux](https://runonflux.com) (Influx Technology Limited) made an agreement to include messaging and file servers operated by Flux into the app.
SimpleX network is decentralized by design, but in the users of the previous app versions had to find other servers online or host servers themselves to use any other servers than operated by us.
Now all users can choose between servers of two companies, use both of them, and continue using any other servers they host or available online.
To use Flux servers enable them when the app offers it, or at any point later via Network & servers settings in the app.
When both SimpleX Chat and Flux servers are enabled, the app will use servers of both operators in each connection to receive messages and for [private message routing](./20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md), increasing metadata privacy for all users.
Read more about why SimpleX network benefits from multiple operators in [our previous post](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md).
You can also read about our plan [how network operators will make money](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-04-26-commercial-model.md), while continuing to protect users privacy, based on network design rather than on trust to operators, and without any cryptocurrency emission.
### Business chats
<img src="./images/20241210-business.png" width=288 class="float-to-right">
We use SimpleX Chat to provide support to SimpleX Chat users, and we also see some other companies offering SimpleX Chat as a support channel.
One of the problem of providing support via general purpose messengers is that the customers don't see who they talk to, as they can in all dedicated support systems.
It is not possible in most messengers, including SimpleX Chat prior to v6.2 - every new customer joins a one-to-one conversation, where the customers see that they talk to a company, not knowing who they talk to, and if it's a bot or a human.
The new business chats in SimpleX Chat solve this problem: to use them enable the toggle under the contact address in your chat profile. It is safe to do, and you can always toggle it off, if needed - the address itself does not change.
Once you do it, the app will be creating a new business chat with each connecting customer where multiple people can participate. Business chat is a hybrid of one-to-one and group conversation. In the list of chats you will see customer names and avatars, and the customer will see your business name and avatar, like with one-to-one conversations. But inside it works as a group, allowing customer to see who sent the message, and allowing you to add other participants from the business side, for delegation and escalation of customer questions.
This can be done manually, or you can automate these conversations using bots that can answer some customer questions and then add a human to the conversation when appropriate or requested by the customer. We will be offering more bot-related features to the app and a simpler way to program bots very soon - watch our announcements.
### Better user experience
<img src="./images/20241210-reactions.png" width=288 class="float-to-right">
**Chat navigation**
This has been a long-standing complaint from the users: *why does the app opens conversations on the last message, and not on the first unread message*?
Android and desktop apps now open the chat on the first unread message. It will soon be done in the iOS app too.
Also, the app can scroll to the replied message anywhere in the conversation (when you tap it), even if it was sent a very long time ago.
**See who reacted!**
This is a small but important change - you can now see who reacted to your messages!
### Improving notifications in iOS app
iOS notifications in a decentralized network is a complex problems. We [support iOS notifications](./20220404-simplex-chat-instant-notifications.md#ios-notifications-require-a-server) from early versions of the app, focussing on preserving privacy as much as possible. But the reliability of notifications was not good enough.
We solved several problems of notification delivery in this release:
- messaging servers no longer lose notifications while notification servers are restarted.
- Apple can drop notifications while your device is offline - about 15-20% of notifications are dropped because of it. The servers and the new version of the app work around this problem by delivering several last notifications, to show notifications correctly even when Apple drops them.
With these changes the iOS notifications remained as private and secure as before. The notifications only contain metadata, without the actual messages, and even the metadata is end-to-end encrypted between SimpleX notification servers and the client device, inaccessible to Apple push notification servers.
There are two remaining problems we will solve soon:
- iOS only allows to use 25mb of device memory when processing notifications in the background. This limit didn't change for many years, and it is challenging for decentralized design. If the app uses more memory, iOS kills it and the notification is not shown approximately 10% of notifications can be lost because of that.
- for notifications to work, the app communicates with the notification server. If the user puts the app in background too quickly, the app may fail to enable notification for the new contacts. We plan to change clients and servers to delegate this task to messaging servers, to remove the need for this additional communication entirely, without any impact on privacy and security. This will happen early next year.
## SimpleX network ## SimpleX network
+10 -1
View File
@@ -1,6 +1,15 @@
# Blog # Blog
Nov 25, 2025 [Servers operated by Flux - true privacy and decentralization for all users](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md) Dec 10, 2024 [SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
- SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app to improve metadata privacy in SimpleX network.
- Business chats for better privacy and support of your customers.
- Better user experience: open on the first unread, jump to quoted messages, see who reacted.
- Improving notifications in iOS app.
--
Nov 25, 2024 [Servers operated by Flux - true privacy and decentralization for all users](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.md)
- Welcome, Flux - the new servers in v6.2-beta.1! - Welcome, Flux - the new servers in v6.2-beta.1!
- What's the problem? - What's the problem?
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"targets": [
{
"target_name": "addon",
"sources": [ "cpp/simplex.cc" ],
"libraries": [
"-Wl,-rpath,libs",
"-L<(module_root_dir)/build/Release/",
"-L<(module_root_dir)/libs",
"-lsimplex"
]
}
]
}
+188
View File
@@ -0,0 +1,188 @@
#include <sstream>
#include <string>
#include <node.h>
#include "simplex.h"
namespace simplex
{
using v8::Array;
using v8::ArrayBuffer;
using v8::Context;
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Maybe;
using v8::Number;
using v8::Object;
using v8::String;
using v8::Value;
void haskell_init()
{
int argc = 5;
char *argv[] = {
"simplex",
"+RTS", // requires `hs_init_with_rtsopts`
"-A64m", // chunk size for new allocations
"-H64m", // initial heap size
"-xn", // non-moving GC
0};
char **pargv = argv;
hs_init_with_rtsopts(&argc, &pargv);
}
void ChatMigrateInit(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
std::string path(*(v8::String::Utf8Value(isolate, args[0])));
std::string key(*(v8::String::Utf8Value(isolate, args[1])));
std::string confirm(*(v8::String::Utf8Value(isolate, args[2])));
long *ctrl(0);
std::string res = chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl);
std::stringstream ss;
ss << ctrl
<< "\n"
<< res;
// printf("ChatCtrl after init %d", cChatCtrl);
// v8::Local<v8::String> ctrl = v8::String::NewFromUtf8(isolate, cppChatCtrl.c_str(), v8::String::kNormalString);
args.GetReturnValue().Set(
String::NewFromUtf8(
isolate, ss.str().c_str())
.ToLocalChecked());
}
void ChatCloseStore(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
long *ctrl = (long *)((long)args[0].As<Number>()->Value());
args.GetReturnValue().Set(
String::NewFromUtf8(
isolate, chat_close_store(ctrl))
.ToLocalChecked());
}
void ChatSendCmd(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
long *ctrl = (long *)((long)args[0].As<Number>()->Value());
std::string cmd(*(v8::String::Utf8Value(isolate, args[1])));
args.GetReturnValue().Set(
String::NewFromUtf8(
isolate, chat_send_cmd(ctrl, cmd.c_str()))
.ToLocalChecked());
}
void ChatRecvMsgWait(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
long *ctrl = (long *)((long)args[0].As<Number>()->Value());
long wait = ((long)args[1].As<Number>()->Value());
args.GetReturnValue().Set(
String::NewFromUtf8(
isolate, chat_recv_msg_wait(ctrl, wait))
.ToLocalChecked());
}
void ChatWriteFile(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
long *ctrl = (long *)((long)args[0].As<Number>()->Value());
std::string path(*(v8::String::Utf8Value(isolate, args[1])));
Local<v8::ArrayBuffer> arr = args[2].As<ArrayBuffer>();
char *buffer = (char *)arr->Data();
int len(arr->ByteLength());
// std::array address(*arr);
// v8::Array a = *buffer;
// std::string data(*((isolate, args[1])));
args.GetReturnValue().Set(
String::NewFromUtf8(
isolate, chat_write_file(ctrl, path.c_str(), buffer, len))
.ToLocalChecked());
}
void ChatReadFile(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
long *ctrl = (long *)((long)args[0].As<Number>()->Value());
std::string path(*(v8::String::Utf8Value(isolate, args[1])));
Local<v8::ArrayBuffer> arr = args[2].As<ArrayBuffer>();
char *buffer = (char *)arr->Data();
int len(arr->ByteLength());
args.GetReturnValue().Set(
String::NewFromUtf8(
isolate, chat_write_file(ctrl, path.c_str(), buffer, len))
.ToLocalChecked());
}
void ChatEncryptFile(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
long *ctrl = (long *)((long)args[0].As<Number>()->Value());
std::string fromPath(*(v8::String::Utf8Value(isolate, args[1])));
std::string toPath(*(v8::String::Utf8Value(isolate, args[2])));
args.GetReturnValue().Set(
String::NewFromUtf8(
isolate, chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()))
.ToLocalChecked());
}
void ChatDecryptFile(const FunctionCallbackInfo<Value> &args)
{
Isolate *isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
std::string fromPath(*(v8::String::Utf8Value(isolate, args[0])));
std::string key(*(v8::String::Utf8Value(isolate, args[1])));
std::string nonce(*(v8::String::Utf8Value(isolate, args[2])));
std::string toPath(*(v8::String::Utf8Value(isolate, args[3])));
args.GetReturnValue().Set(
String::NewFromUtf8(
isolate, chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()))
.ToLocalChecked());
}
void Initialize(Local<Object> exports)
{
haskell_init();
NODE_SET_METHOD(exports, "chat_migrate_init", ChatMigrateInit);
NODE_SET_METHOD(exports, "chat_close_store", ChatCloseStore);
NODE_SET_METHOD(exports, "chat_send_cmd", ChatSendCmd);
NODE_SET_METHOD(exports, "chat_recv_msg_wait", ChatRecvMsgWait);
NODE_SET_METHOD(exports, "chat_write_file", ChatWriteFile);
NODE_SET_METHOD(exports, "chat_read_file", ChatReadFile);
NODE_SET_METHOD(exports, "chat_encrypt_file", ChatEncryptFile);
NODE_SET_METHOD(exports, "chat_decrypt_file", ChatDecryptFile);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
} // namespace simplex
@@ -0,0 +1,46 @@
//
// simplex.h
// SimpleX
//
// Created by Evgeny on 30/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
#ifndef SimpleX_h
#define SimpleX_h
extern "C" void hs_init(int argc, char **argv[]);
extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]);
typedef long* chat_ctrl;
// the last parameter is used to return the pointer to chat controller
extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
extern "C" char *chat_close_store(chat_ctrl ctrl);
extern "C" char *chat_reopen_store(chat_ctrl ctrl);
extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern "C" char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern "C" char *chat_parse_markdown(const char *str);
extern "C" char *chat_parse_server(const char *str);
extern "C" char *chat_password_hash(const char *pwd, const char *salt);
extern "C" char *chat_valid_name(const char *name);
extern "C" int chat_json_length(const char *str);
extern "C" char *chat_encrypt_media(chat_ctrl ctrl, const char *key, const char *frame, const int len);
extern "C" char *chat_decrypt_media(const char *key, const char *frame, const int len);
// chat_write_file returns null-terminated string with JSON of WriteFileResult
extern "C" char *chat_write_file(chat_ctrl ctrl, const char *path, const char *data, const int len);
// chat_read_file returns a buffer with:
// result status (1 byte), then if
// status == 0 (success): buffer length (uint32, 4 bytes), buffer of specified length.
// status == 1 (error): null-terminated error message string.
extern "C" char *chat_read_file(const char *path, const char *key, const char *nonce);
// chat_encrypt_file returns null-terminated string with JSON of WriteFileResult
extern "C" char *chat_encrypt_file(chat_ctrl ctrl, const char *fromPath, const char *toPath);
// chat_decrypt_file returns null-terminated string with the error message
extern "C" char *chat_decrypt_file(const char *fromPath, const char *key, const char *nonce, const char *toPath);
#endif /* simplex_h */
+31
View File
@@ -0,0 +1,31 @@
{
"name": "simplexjs",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"install-tools": "npm install -g node-gyp",
"configure": "node-gyp configure; mkdir libs 2> /dev/null | true",
"rebuild": "node-gyp rebuild",
"build": "node-gyp build",
"run": "node src/index.js",
"build-run": "node-gyp build && node src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/simplex-chat/simplex-chat.git"
},
"keywords": [
"messenger",
"chat",
"privacy",
"security"
],
"author": "SimpleX Chat",
"license": "AGPL-3.0",
"bugs": {
"url": "https://github.com/simplex-chat/simplex-chat/issues"
},
"homepage": "https://github.com/simplex-chat/simplex-chat/packages/simplex-chat-nodejs#readme",
"description": ""
}
+22
View File
@@ -0,0 +1,22 @@
const addon = require('../build/Release/addon');
const fs = require('fs');
const ctrlAndRes = addon.chat_migrate_init("db", "a", "yesUp")
const ctrl = Number(ctrlAndRes.split("\n")[0])
const res = ctrlAndRes.split("\n")[1]
console.log("Migrate ctrl:", ctrl, "res:", res)
console.log(addon.chat_send_cmd(ctrl, "/v"))
// const wait = 15_000_000
// console.log(ctrl, addon.chat_recv_msg_wait(ctrl, wait))
const path = "/tmp/write_file.txt"
const data = [0, 1, 2]
console.log(addon.chat_write_file(ctrl, path, new ArrayBuffer(data)))
fs.writeFileSync("/tmp/file_unencrypted.txt", "unencrypted")
const encRes = JSON.parse(addon.chat_encrypt_file(ctrl, "/tmp/file_unencrypted.txt", "/tmp/file_encrypted.txt"))
const key = encRes.cryptoArgs.fileKey
const nonce = encRes.cryptoArgs.fileNonce
console.log(encRes)
console.log(addon.chat_decrypt_file("/tmp/file_encrypted.txt", key, nonce, "/tmp/file_decrypted.txt"))
+8 -7
View File
@@ -24,18 +24,19 @@ exports=( $(sed 's/foreign export ccall "chat_migrate_init_key"//' src/Simplex/C
for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc -l); if [ $count -ne 1 ]; then echo Wrong exports in libsimplex.dll.def. Add \"$elem\" to that file; exit 1; fi ; done
for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done
rm -rf $BUILD_DIR #rm -rf $BUILD_DIR
cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -flink-rts -threaded' --constraint 'simplexmq +client_library' cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' --constraint 'simplexmq +client_library'
cd $BUILD_DIR/build cd $BUILD_DIR/build
#patchelf --add-needed libHSrts_thr-ghc${GHC_VERSION}.so libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so mv libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so libsimplex.so 2> /dev/null || true
#patchelf --add-rpath '$ORIGIN' libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so #patchelf --add-needed libHSrts_thr-ghc${GHC_VERSION}.so libsimplex.so
#patchelf --add-rpath '$ORIGIN' libsimplex.so
# GitHub's Ubuntu 20.04 runner started to set libffi.so.7 as a dependency while Ubuntu 20.04 on user's devices may not have it # GitHub's Ubuntu 20.04 runner started to set libffi.so.7 as a dependency while Ubuntu 20.04 on user's devices may not have it
# but libffi.so.8 is shipped as an external library with other libs # but libffi.so.8 is shipped as an external library with other libs
patchelf --replace-needed "libffi.so.7" "libffi.so.8" libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so patchelf --replace-needed "libffi.so.7" "libffi.so.8" libsimplex.so
mkdir deps 2> /dev/null || true mkdir deps 2> /dev/null || true
ldd libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so | grep "ghc" | cut -d' ' -f 3 | xargs -I {} cp {} ./deps/ ldd libsimplex.so | grep "ghc" | cut -d' ' -f 3 | xargs -I {} cp {} ./deps/
cd - cd -
@@ -44,7 +45,7 @@ rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libsimplex.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
scripts/desktop/prepare-vlc-linux.sh scripts/desktop/prepare-vlc-linux.sh
links_dir=apps/multiplatform/build/links links_dir=apps/multiplatform/build/links
+4 -3
View File
@@ -14,7 +14,7 @@ else
fi fi
LIB_EXT=dylib LIB_EXT=dylib
LIB=libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT LIB=libsimplex.$LIB_EXT
GHC_LIBS_DIR=$(ghc --print-libdir) GHC_LIBS_DIR=$(ghc --print-libdir)
BUILD_DIR=dist-newstyle/build/$ARCH-*/ghc-*/simplex-chat-* BUILD_DIR=dist-newstyle/build/$ARCH-*/ghc-*/simplex-chat-*
@@ -24,9 +24,10 @@ for elem in "${exports[@]}"; do count=$(grep -R "$elem$" libsimplex.dll.def | wc
for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done for elem in "${exports[@]}"; do count=$(grep -R "\"$elem\"" flake.nix | wc -l); if [ $count -ne 2 ]; then echo Wrong exports in flake.nix. Add \"$elem\" in two places of the file; exit 1; fi ; done
rm -rf $BUILD_DIR rm -rf $BUILD_DIR
cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-install_name,@rpath/$LIB -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library'
cd $BUILD_DIR/build cd $BUILD_DIR/build
mv libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT libsimplex.dylib 2> /dev/null || true
mkdir deps 2> /dev/null || true mkdir deps 2> /dev/null || true
# It's not included by default for some reason. Compiled lib tries to find system one but it's not always available # It's not included by default for some reason. Compiled lib tries to find system one but it's not always available
@@ -95,7 +96,7 @@ rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps/* apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/$LIB apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
+4 -4
View File
@@ -40,10 +40,10 @@ if [ ! -f ../appimagetool-x86_64.AppImage ]; then
wget --secure-protocol=TLSv1_3 https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O ../appimagetool-x86_64.AppImage wget --secure-protocol=TLSv1_3 https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O ../appimagetool-x86_64.AppImage
chmod +x ../appimagetool-x86_64.AppImage chmod +x ../appimagetool-x86_64.AppImage
fi fi
if [ ! -f ../runtime-fuse3-x86_64 ]; then if [ ! -f ../runtime-x86_64 ]; then
wget --secure-protocol=TLSv1_3 https://github.com/AppImage/type2-runtime/releases/download/old/runtime-fuse3-x86_64 -O ../runtime-fuse3-x86_64 wget --secure-protocol=TLSv1_3 https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64 -O ../runtime-x86_64
chmod +x ../runtime-fuse3-x86_64 chmod +x ../runtime-x86_64
fi fi
../appimagetool-x86_64.AppImage --runtime-file ../runtime-fuse3-x86_64 . ../appimagetool-x86_64.AppImage --runtime-file ../runtime-x86_64 .
mv *imple*.AppImage ../../ mv *imple*.AppImage ../../
@@ -1,5 +1,3 @@
<p></p>
<ul> <ul>
<li><strong>Welcome, Flux</strong> &mdash; the new servers in <strong>v6.2-beta.1!</strong></li> <li><strong>Welcome, Flux</strong> &mdash; the new servers in <strong>v6.2-beta.1!</strong></li>
<li>What's the problem?</li> <li>What's the problem?</li>
@@ -0,0 +1,8 @@
<p><strong>v6.2 is released:</strong></p>
<ul>
<li>SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app to improve metadata privacy in SimpleX network.</li>
<li>Business chats for better privacy and support of your customers.</li>
<li>Better user experience: open on the first unread, jump to quoted messages, see who reacted.</li>
<li>Improving notifications in iOS app.</li>
</ul>