Compare commits

...

34 Commits

Author SHA1 Message Date
Evgeny Poberezkin
75480eabda update rfc 2024-01-30 17:31:34 +00:00
Evgeny Poberezkin
fbc586a9da store images as blobs 2024-01-29 11:48:29 +00:00
Evgeny Poberezkin
af946b45c2 rfc: more space-efficient message encoding 2024-01-28 21:02:14 +00:00
Evgeny Poberezkin
bce829ef58 v5.5.1: ios 195, android 177, desktop 27 2024-01-27 23:23:29 +00:00
Evgeny Poberezkin
7df300cf36 core: 5.5.1.0 2024-01-27 19:18:41 +00:00
Stanislav Dmitrenko
9b35ff6f11 android, desktop: possible fix of chat items race (#3520)
* android, desktop: possible fix of chat items race

* fix compose threads desynchronization

* optimization and preventing race

* removed unused logs

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-01-27 18:46:27 +00:00
M. Sarmad Qadeer
c4cbb49f57 website: update contact page layout if JS is disabled (#3331)
* website: update page layout for the case if javascript is disabled in browser.

* website: add noscript tag & update heading

* website: do some changes in layout of contact page without JS

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-01-27 16:51:21 +00:00
Stanislav Dmitrenko
2b3eebb7a2 android, desktop: show different text when database migrates (#3762)
* android, desktop: show different text when database migrates

* one more check

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-01-26 23:08:48 +00:00
Evgeny Poberezkin
a1328c287c core: remove unused events from api (#3764)
* core: remove unused events from api

* fix test
2024-01-26 18:54:08 +00:00
Stanislav Dmitrenko
f2d498dd79 android, desktop: removed timer for some long running jobs (#3761) 2024-01-26 18:15:20 +00:00
Stanislav Dmitrenko
6b8fc6fdcf android, desktop: lower limit of terminal items for non-developers (#3763) 2024-01-26 18:14:53 +00:00
spaced4ndy
0e585d5e5b core: fix group invitation marked deleted 2024-01-26 20:30:21 +04:00
Stanislav Dmitrenko
520d8868ef android: refactor clipboard access to prevent Android error in logs (#3758) 2024-01-26 14:00:53 +00:00
spaced4ndy
7192448303 core: fix invitation as rejected when deleting group (#3759) 2024-01-26 17:56:17 +04:00
spaced4ndy
0f0f65533a android: group welcome message byte limit (#3756)
* android: group welcome message byte limit

* text

* change footer
2024-01-26 09:57:04 +00:00
spaced4ndy
78a38cb080 ios: group welcome message byte limit (#3751)
* ios: group welcome message character limit

* confirmation dialogue key

* use chatJsonLength

* text

* change footer

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-01-26 09:37:49 +00:00
Stanislav Dmitrenko
f102f39147 android, desktop: protocol servers fix (#3755)
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-01-25 20:59:02 +00:00
Stanislav Dmitrenko
cd349e80ce desktop: propertly updating delivery tab of chat item info page (#3752) 2024-01-25 17:51:20 +00:00
Stanislav Dmitrenko
430dc5bd2e ui: don't show context menu on non-sent yet live message (#3754)
* android, desktop: don't show context menu on non-sent yet live message

* ios: don't show context menu on non-sent yet live message

---------

Co-authored-by: Avently <avently@local>
2024-01-25 17:48:40 +00:00
spaced4ndy
3e0b863b64 core: add cChatJsonLength function (#3753) 2024-01-25 18:57:38 +04:00
Stanislav Dmitrenko
d6afee11bc desktop: prevent clicking enter on alert and text field at the same time (#3714) 2024-01-25 14:50:53 +00:00
spaced4ndy
6ef3a9e668 ios: fix welcome view (#3743)
* welcome view (still doesn't keep change on re-open)

* fix

* remove debug log

* rename
2024-01-25 16:08:10 +04:00
spaced4ndy
fbe3353434 ui: fix link preview cancellation (#3750) 2024-01-25 14:58:39 +04:00
Evgeny Poberezkin
d6d2e6e1eb Merge branch 'stable' 2024-01-24 19:55:28 +00:00
Evgeny Poberezkin
14d5078404 blog: v5.5 announcement (#3744)
* blog: v5.5 announcement

* update

* update

* readme

* images, update
2024-01-24 19:54:47 +00:00
Stanislav Dmitrenko
da1d20c17f desktop: alignment for reactions (#3747) 2024-01-24 16:25:44 +00:00
Stanislav Dmitrenko
afc324dc4f android, desktop: marking chat as read if it was set unread (#3746) 2024-01-24 16:24:49 +00:00
Stanislav Dmitrenko
f81e457e09 android: trying to start service again in case it was destroyed (#3745) 2024-01-24 16:22:29 +00:00
Stanislav Dmitrenko
bd30b80e15 desktop: custom time picker (#3741)
* desktop: custom time picker

* text color

* formatting

* changes in UI

* optimization

* desktop: opening SimpleX links inside the app (#3738)

* 5.5: ios 194, android 175, desktop 26

* docs: update downloads page

* ui: fix chat preview showing incorrect timestamp when chat is empty (#3739)

* ui: align call buttons with calls preference (#3740)

* ui: deleted item preview (#3726)

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-01-24 16:15:27 +00:00
spaced4ndy
da9a7f4642 ui: exclude not ready and active contacts from list of contacts to add to group (e.g. simplex team contact) (#3737) 2024-01-24 13:56:38 +04:00
spaced4ndy
838a759a76 ui: deleted item preview (#3726) 2024-01-24 13:44:29 +04:00
spaced4ndy
f1ff27218c ui: align call buttons with calls preference (#3740) 2024-01-24 13:43:18 +04:00
spaced4ndy
8738cf332f ui: fix chat preview showing incorrect timestamp when chat is empty (#3739) 2024-01-24 13:37:29 +04:00
Evgeny Poberezkin
4f602d4571 docs: update downloads page 2024-01-23 23:22:00 +00:00
75 changed files with 1057 additions and 531 deletions

View File

@@ -234,6 +234,8 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
[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).
[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).
@@ -299,7 +301,7 @@ What is already implemented:
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
12. Manual messaging queue rotations to move conversation to another SMP relay.
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
14. Local files encryption, except videos (to be added later).
14. Local files encryption.
We plan to add:
@@ -371,12 +373,13 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Desktop client.
- ✅ Encryption of local files stored in the app.
- ✅ Using mobile profiles from the desktop app.
- ✅ Private notes.
- ✅ Improve sending videos (including encryption of locally stored videos).
- 🏗 Improve experience for the new users.
- 🏗 Post-quantum resistant key exchange in double ratchet protocol.
- 🏗 Large groups, communities and public channels.
- 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).
- Privacy & security slider - a simple way to set all settings at once.
- Improve sending videos (including encryption of locally stored videos).
- SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address.
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).

View File

@@ -1172,7 +1172,7 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats
.compactMap{ $0.chatInfo.contact }
.filter{ !memberContactIds.contains($0.apiId) }
.filter{ c in c.ready && c.active && !memberContactIds.contains(c.apiId) }
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
}

View File

@@ -65,6 +65,8 @@ struct MarkedDeletedItemView: View {
}
}
// same texts are in markedDeletedText in ChatPreviewView, but it returns String;
// can be refactored into a single function if functions calling these are changed to return same type
var markedDeletedText: LocalizedStringKey {
switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"

View File

@@ -159,12 +159,13 @@ struct ChatView: View {
switch cInfo {
case let .direct(contact):
HStack {
if contact.allowsFeature(.calls) {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
}
Menu {
if contact.allowsFeature(.calls) {
if callsPrefEnabled {
Button {
CallController.shared.startCall(contact, .video)
} label: {
@@ -748,7 +749,9 @@ struct ChatView: View {
if ci.meta.editable && !mc.isVoice && !live {
menu.append(editAction(ci))
}
menu.append(viewInfoUIAction(ci))
if !ci.isLiveDummy {
menu.append(viewInfoUIAction(ci))
}
if revealed {
menu.append(hideUIAction())
}

View File

@@ -978,6 +978,9 @@ struct ComposeView: View {
}
private func cancelLinkPreview() {
if let pendingLink = pendingLinkUrl?.absoluteString {
cancelledLinks.insert(pendingLink)
}
if let uri = composeState.linkPreview?.uri.absoluteString {
cancelledLinks.insert(uri)
}

View File

@@ -370,7 +370,11 @@ struct GroupChatInfoView: View {
private func addOrEditWelcomeMessage() -> some View {
NavigationLink {
GroupWelcomeView(groupId: groupInfo.groupId, groupInfo: $groupInfo)
GroupWelcomeView(
groupInfo: $groupInfo,
groupProfile: groupInfo.groupProfile,
welcomeText: groupInfo.groupProfile.description ?? ""
)
.navigationTitle("Welcome message")
.navigationBarTitleDisplayMode(.large)
} label: {

View File

@@ -11,29 +11,32 @@ import SimpleXChat
struct GroupWelcomeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var m: ChatModel
var groupId: Int64
@Binding var groupInfo: GroupInfo
@State private var welcomeText: String = ""
@State var groupProfile: GroupProfile
@State var welcomeText: String
@State private var editMode = true
@FocusState private var keyboardVisible: Bool
@State private var showSaveDialog = false
let maxByteCount = 1200
var body: some View {
VStack {
if groupInfo.canEdit {
editorView()
.modifier(BackButton {
if welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil) {
if welcomeTextUnchanged() {
dismiss()
} else {
showSaveDialog = true
}
})
.confirmationDialog("Save welcome message?", isPresented: $showSaveDialog) {
Button("Save and update group profile") {
save()
dismiss()
.confirmationDialog(
welcomeTextFitsLimit() ? "Save welcome message?" : "Welcome message is too long",
isPresented: $showSaveDialog
) {
if welcomeTextFitsLimit() {
Button("Save and update group profile") { save() }
}
Button("Exit without saving") { dismiss() }
}
@@ -47,14 +50,15 @@ struct GroupWelcomeView: View {
}
}
.onAppear {
welcomeText = groupInfo.groupProfile.description ?? ""
keyboardVisible = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
keyboardVisible = true
}
}
}
private func textPreview() -> some View {
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false)
.frame(minHeight: 140, alignment: .topLeading)
.frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -74,7 +78,7 @@ struct GroupWelcomeView: View {
}
.padding(.horizontal, -5)
.padding(.top, -8)
.frame(height: 140, alignment: .topLeading)
.frame(height: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
@@ -93,6 +97,9 @@ struct GroupWelcomeView: View {
}
.disabled(welcomeText.isEmpty)
copyButton()
} footer: {
Text(!welcomeTextFitsLimit() ? "Message too large" : "")
.foregroundColor(.red)
}
Section {
@@ -113,7 +120,15 @@ struct GroupWelcomeView: View {
Button("Save and update group profile") {
save()
}
.disabled(welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil))
.disabled(welcomeTextUnchanged() || !welcomeTextFitsLimit())
}
private func welcomeTextUnchanged() -> Bool {
welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil)
}
private func welcomeTextFitsLimit() -> Bool {
chatJsonLength(welcomeText) <= maxByteCount
}
private func save() {
@@ -123,11 +138,13 @@ struct GroupWelcomeView: View {
if welcome?.count == 0 {
welcome = nil
}
var groupProfileUpdated = groupInfo.groupProfile
groupProfileUpdated.description = welcome
groupInfo = try await apiUpdateGroup(groupId, groupProfileUpdated)
m.updateGroup(groupInfo)
welcomeText = welcome ?? ""
groupProfile.description = welcome
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run {
groupInfo = gInfo
ChatModel.shared.updateGroup(gInfo)
dismiss()
}
} catch let error {
logger.error("apiUpdateGroup error: \(responseError(error))")
}
@@ -137,6 +154,6 @@ struct GroupWelcomeView: View {
struct GroupWelcomeView_Previews: PreviewProvider {
static var previews: some View {
GroupWelcomeView(groupId: 1, groupInfo: Binding.constant(GroupInfo.sampleData))
GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData)
}
}

View File

@@ -34,7 +34,7 @@ struct ChatPreviewView: View {
HStack(alignment: .top) {
chatPreviewTitle()
Spacer()
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.updatedAt))
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
@@ -171,10 +171,21 @@ struct ChatPreviewView: View {
}
func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
func markedDeletedText() -> String {
switch cItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
}
}
func attachment() -> String? {
switch cItem.content.msgContent {
case .file: return "doc.fill"

View File

@@ -61,11 +61,6 @@
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; };
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
5C83A1AD2B5EF67D00AE0A4A /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1A82B5EF67D00AE0A4A /* libgmp.a */; };
5C83A1AE2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */; };
5C83A1AF2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */; };
5C83A1B02B5EF67D00AE0A4A /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AB2B5EF67D00AE0A4A /* libffi.a */; };
5C83A1B12B5EF67D00AE0A4A /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */; };
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; };
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; };
@@ -142,6 +137,11 @@
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
5CEB651B2B65B25500EF2982 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB65162B65B25400EF2982 /* libgmpxx.a */; };
5CEB651C2B65B25500EF2982 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB65172B65B25400EF2982 /* libffi.a */; };
5CEB651D2B65B25500EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB65182B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a */; };
5CEB651E2B65B25500EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB65192B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a */; };
5CEB651F2B65B25500EF2982 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB651A2B65B25500EF2982 /* libgmp.a */; };
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; };
5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; };
5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; };
@@ -325,11 +325,6 @@
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
5C83A1A82B5EF67D00AE0A4A /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a"; sourceTree = "<group>"; };
5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a"; sourceTree = "<group>"; };
5C83A1AB2B5EF67D00AE0A4A /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -429,6 +424,11 @@
5CE6C7B42AAB1527007F345C /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
5CEB65162B65B25400EF2982 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CEB65172B65B25400EF2982 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CEB65182B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a"; sourceTree = "<group>"; };
5CEB65192B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a"; sourceTree = "<group>"; };
5CEB651A2B65B25500EF2982 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = "<group>"; };
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = "<group>"; };
5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = "<group>"; };
@@ -514,13 +514,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5CEB651B2B65B25500EF2982 /* libgmpxx.a in Frameworks */,
5CEB651F2B65B25500EF2982 /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C83A1B02B5EF67D00AE0A4A /* libffi.a in Frameworks */,
5C83A1AD2B5EF67D00AE0A4A /* libgmp.a in Frameworks */,
5C83A1AE2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a in Frameworks */,
5C83A1AF2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a in Frameworks */,
5CEB651D2B65B25500EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5C83A1B12B5EF67D00AE0A4A /* libgmpxx.a in Frameworks */,
5CEB651C2B65B25500EF2982 /* libffi.a in Frameworks */,
5CEB651E2B65B25500EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -582,11 +582,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C83A1AB2B5EF67D00AE0A4A /* libffi.a */,
5C83A1A82B5EF67D00AE0A4A /* libgmp.a */,
5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */,
5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */,
5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */,
5CEB65172B65B25400EF2982 /* libffi.a */,
5CEB651A2B65B25500EF2982 /* libgmp.a */,
5CEB65162B65B25400EF2982 /* libgmpxx.a */,
5CEB65192B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a */,
5CEB65182B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -1509,7 +1509,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194;
CURRENT_PROJECT_VERSION = 195;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1531,7 +1531,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 5.5;
MARKETING_VERSION = 5.5.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1552,7 +1552,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194;
CURRENT_PROJECT_VERSION = 195;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -1574,7 +1574,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 5.5;
MARKETING_VERSION = 5.5.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1633,7 +1633,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194;
CURRENT_PROJECT_VERSION = 195;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1646,7 +1646,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 5.5;
MARKETING_VERSION = 5.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1665,7 +1665,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194;
CURRENT_PROJECT_VERSION = 195;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -1678,7 +1678,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 5.5;
MARKETING_VERSION = 5.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1697,7 +1697,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194;
CURRENT_PROJECT_VERSION = 195;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1721,7 +1721,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.5;
MARKETING_VERSION = 5.5.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -1743,7 +1743,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 194;
CURRENT_PROJECT_VERSION = 195;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1767,7 +1767,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Libraries/sim",
);
MARKETING_VERSION = 5.5;
MARKETING_VERSION = 5.5.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;

View File

@@ -105,6 +105,11 @@ public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? {
return nil
}
public func chatJsonLength(_ s: String) -> Int {
var c = s.cString(using: .utf8)!
return Int(chat_json_length(&c))
}
struct ParsedMarkdown: Decodable {
var formattedText: [FormattedText]?
}

View File

@@ -1367,6 +1367,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
public var chatTs: Date {
switch self {
case let .direct(contact): return contact.chatTs ?? contact.updatedAt
case let .group(groupInfo): return groupInfo.chatTs ?? groupInfo.updatedAt
case let .local(noteFolder): return noteFolder.chatTs
case let .contactRequest(contactRequest): return contactRequest.updatedAt
case let .contactConnection(contactConnection): return contactConnection.updatedAt
case .invalidJSON: return .now
}
}
public struct SampleData {
public var direct: ChatInfo
public var group: ChatInfo
@@ -1425,6 +1436,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var mergedPreferences: ContactUserPreferences
var createdAt: Date
var updatedAt: Date
var chatTs: Date?
var contactGroupMemberId: Int64?
var contactGrpInvSent: Bool
@@ -1744,6 +1756,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
public var chatSettings: ChatSettings
var createdAt: Date
var updatedAt: Date
var chatTs: Date?
public var id: ChatId { get { "#\(groupId)" } }
public var apiId: Int64 { get { groupId } }
@@ -2049,6 +2062,7 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat {
public var unread: Bool
var createdAt: Date
public var updatedAt: Date
var chatTs: Date
public var id: ChatId { get { "*\(noteFolderId)" } }
public var apiId: Int64 { get { noteFolderId } }
@@ -2070,7 +2084,8 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat {
favorite: false,
unread: false,
createdAt: .now,
updatedAt: .now
updatedAt: .now,
chatTs: .now
)
}

View File

@@ -25,6 +25,7 @@ extern char *chat_parse_markdown(char *str);
extern char *chat_parse_server(char *str);
extern char *chat_password_hash(char *pwd, char *salt);
extern char *chat_valid_name(char *name);
extern int chat_json_length(char *str);
extern char *chat_encrypt_media(chat_ctrl ctl, char *key, char *frame, int len);
extern char *chat_decrypt_media(char *key, char *frame, int len);

View File

@@ -1,11 +1,12 @@
package chat.simplex.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.*
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.platform.ClipboardManager
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.getUserIdFromIntent
@@ -58,6 +59,17 @@ class MainActivity: FragmentActivity() {
override fun onResume() {
super.onResume()
AppLock.recheckAuthState()
withApi {
delay(1000)
if (!isAppOnForeground) return@withApi
/**
* When the app calls [ClipboardManager.shareText] and a user copies text in clipboard, Android denies
* access to clipboard because the app considered in background.
* This will ensure that the app will get the event on resume
* */
val service = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
chatModel.clipboardHasText.value = service.hasPrimaryClip()
}
}
override fun onPause() {

View File

@@ -97,13 +97,6 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
Lifecycle.Event.ON_RESUME -> {
isAppOnForeground = true
/**
* When the app calls [ClipboardManager.shareText] and a user copies text in clipboard, Android denies
* access to clipboard because the app considered in background.
* This will ensure that the app will get the event on resume
* */
val service = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
chatModel.clipboardHasText.value = service.hasPrimaryClip()
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) {
SimplexService.showBackgroundServiceNoticeIfNeeded()
}
@@ -197,10 +190,18 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE)
CoroutineScope(Dispatchers.Default).launch {
if (mode == NotificationsMode.SERVICE)
if (mode == NotificationsMode.SERVICE) {
SimplexService.start()
else
// Sometimes, when we change modes fast from one to another, system destroys the service after start.
// We can wait a little and restart the service, and it will work in 100% of cases
delay(2000)
if (!SimplexService.isServiceStarted && appPrefs.notificationsMode.get() == NotificationsMode.SERVICE) {
Log.i(TAG, "Service tried to start but destroyed by system, repeating once more")
SimplexService.start()
}
} else {
SimplexService.safeStopService()
}
}
if (mode != NotificationsMode.PERIODIC) {

View File

@@ -104,7 +104,7 @@ class SimplexService: Service() {
if (wakeLock != null || isStartingService) return
val self = this
isStartingService = true
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi {
val chatController = ChatController
waitDbMigrationEnds(chatController)
try {
@@ -262,7 +262,7 @@ class SimplexService: Service() {
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
private var isServiceStarted = false
var isServiceStarted = false
private var stopAfterStart = false
fun scheduleStart(context: Context) {

View File

@@ -12,6 +12,8 @@ import androidx.activity.compose.setContent
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalView
import chat.simplex.common.AppScreen
import chat.simplex.common.model.clear
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.*
import androidx.compose.ui.platform.LocalContext as LocalContext1
import chat.simplex.res.MR

View File

@@ -0,0 +1,106 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.CustomTimeUnit
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import com.sd.lib.compose.wheel_picker.*
@Composable
actual fun CustomTimePicker(
selection: MutableState<Int>,
timeUnitsLimits: List<TimeUnitLimits>
) {
fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List<Int> {
val unitLimits = timeUnitsLimits.firstOrNull { it.timeUnit == unit } ?: TimeUnitLimits.defaultUnitLimits(unit)
val regularUnitValues = (unitLimits.minValue..unitLimits.maxValue).toList()
return regularUnitValues + if (regularUnitValues.contains(selectedValue)) emptyList() else listOf(selectedValue)
}
val (unit, duration) = CustomTimeUnit.toTimeUnit(selection.value)
val selectedUnit: MutableState<CustomTimeUnit> = remember { mutableStateOf(unit) }
val selectedDuration = remember { mutableStateOf(duration) }
val selectedUnitValues = remember { mutableStateOf(getUnitValues(selectedUnit.value, selectedDuration.value)) }
val isTriggered = remember { mutableStateOf(false) }
LaunchedEffect(selectedUnit.value) {
// on initial composition, if passed selection doesn't fit into picker bounds, so that selectedDuration is bigger than selectedUnit maxValue
// (e.g., for selection = 121 seconds: selectedUnit would be Second, selectedDuration would be 121 > selectedUnit maxValue of 120),
// selectedDuration would've been replaced by maxValue - isTriggered check prevents this by skipping LaunchedEffect on initial composition
if (isTriggered.value) {
val maxValue = timeUnitsLimits.firstOrNull { it.timeUnit == selectedUnit.value }?.maxValue
if (maxValue != null && selectedDuration.value > maxValue) {
selectedDuration.value = maxValue
selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value)
} else {
selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value)
selection.value = selectedUnit.value.toSeconds * selectedDuration.value
}
} else {
isTriggered.value = true
}
}
LaunchedEffect(selectedDuration.value) {
selection.value = selectedUnit.value.toSeconds * selectedDuration.value
}
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
Column(Modifier.weight(1f)) {
val durationPickerState = rememberFWheelPickerState(selectedUnitValues.value.indexOf(selectedDuration.value))
FVerticalWheelPicker(
count = selectedUnitValues.value.count(),
state = durationPickerState,
unfocusedCount = 2,
focus = {
FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary)
}
) { index ->
Text(
selectedUnitValues.value[index].toString(),
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
LaunchedEffect(durationPickerState) {
snapshotFlow { durationPickerState.currentIndex }
.collect {
selectedDuration.value = selectedUnitValues.value[it]
}
}
}
Column(Modifier.weight(1f)) {
val unitPickerState = rememberFWheelPickerState(timeUnitsLimits.indexOfFirst { it.timeUnit == selectedUnit.value })
FVerticalWheelPicker(
count = timeUnitsLimits.count(),
state = unitPickerState,
unfocusedCount = 2,
focus = {
FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary)
}
) { index ->
Text(
timeUnitsLimits[index].timeUnit.text,
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
LaunchedEffect(unitPickerState) {
snapshotFlow { unitPickerState.currentIndex }
.collect {
selectedUnit.value = timeUnitsLimits[it].timeUnit
}
}
}
}
}

View File

@@ -66,6 +66,7 @@ extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name);
extern int chat_json_length(const char *str);
extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path);
@@ -163,6 +164,14 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz
return res;
}
JNIEXPORT int JNICALL
Java_chat_simplex_common_platform_CoreKt_chatJsonLength(JNIEnv *env, jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
int res = chat_json_length(_str);
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) {
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);

View File

@@ -39,6 +39,7 @@ extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name);
extern int chat_json_length(const char *str);
extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path);
@@ -173,6 +174,14 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz
return res;
}
JNIEXPORT int JNICALL
Java_chat_simplex_common_platform_CoreKt_chatJsonLength(JNIEnv *env, jclass clazz, jstring str) {
const char *_str = encode_to_utf8_chars(env, str);
int res = chat_json_length(_str);
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) {
const char *_path = encode_to_utf8_chars(env, path);

View File

@@ -108,6 +108,7 @@ fun MainScreen() {
val localUserCreated = chatModel.localUserCreated.value
var showInitializationView by remember { mutableStateOf(false) }
when {
chatModel.dbMigrationInProgress.value -> DefaultProgressView(stringResource(MR.strings.database_migration_in_progress))
chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database))
showChatDatabaseError -> {
// Prevent showing keyboard on Android when: passcode enabled and database password not saved

View File

@@ -2,6 +2,7 @@ package chat.simplex.common.model
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
@@ -48,6 +49,7 @@ object ChatModel {
val chatDbEncrypted = mutableStateOf<Boolean?>(false)
val chatDbStatus = mutableStateOf<DBMigrationResult?>(null)
val ctrlInitInProgress = mutableStateOf(false)
val dbMigrationInProgress = mutableStateOf(false)
val chats = mutableStateListOf<Chat>()
// map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
@@ -55,7 +57,7 @@ object ChatModel {
// current chat
val chatId = mutableStateOf<String?>(null)
val chatItems = mutableStateListOf<ChatItem>()
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
// rhId, chatId
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
@@ -63,8 +65,6 @@ object ChatModel {
val terminalItems = mutableStateOf<List<TerminalItem>>(listOf())
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
// Allows to temporary save servers that are being edited on multiple screens
val userSMPServersUnsaved = mutableStateOf<(List<ServerCfg>)?>(null)
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
// set when app opened from external intent
@@ -269,18 +269,15 @@ object ChatModel {
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
}
Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
withContext(Dispatchers.Main) {
// add to current chat
if (chatId.value == cInfo.id) {
Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}")
// Prevent situation when chat item already in the list received from backend
if (chatItems.none { it.id == cItem.id }) {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
if (chatItems.value.none { it.id == cItem.id }) {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
} else {
chatItems.add(cItem)
Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
}
}
}
@@ -307,14 +304,13 @@ object ChatModel {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem)))
res = true
}
Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
return withContext(Dispatchers.Main) {
// update current chat
if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
chatItems[itemIndex] = cItem
Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
items[itemIndex] = cItem
false
} else {
val status = chatItemStatuses.remove(cItem.id)
@@ -324,7 +320,6 @@ object ChatModel {
cItem
}
chatItems.add(ci)
Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
true
}
} else {
@@ -336,9 +331,10 @@ object ChatModel {
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) {
withContext(Dispatchers.Main) {
if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
val items = chatItems.value
val itemIndex = items.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
chatItems[itemIndex] = cItem
items[itemIndex] = cItem
}
} else if (status != null) {
chatItemStatuses[cItem.id] = status
@@ -362,10 +358,10 @@ object ChatModel {
}
// remove from current chat
if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) {
AudioPlayer.stop(chatItems[itemIndex])
chatItems.removeAt(itemIndex)
chatItems.removeAll {
val remove = it.id == cItem.id
if (remove) { AudioPlayer.stop(it) }
remove
}
}
}
@@ -406,7 +402,7 @@ object ChatModel {
}
fun removeLiveDummy() {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.removeLast()
}
}
@@ -438,14 +434,14 @@ object ChatModel {
var markedRead = 0
if (chatId.value == cInfo.id) {
var i = 0
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}")
while (i < chatItems.count()) {
val item = chatItems[i]
val items = chatItems.value
while (i < items.size) {
val item = items[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
val newItem = item.withStatus(CIStatus.RcvRead())
chatItems[i] = newItem
items[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
)
}
@@ -453,7 +449,6 @@ object ChatModel {
}
i += 1
}
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}")
}
return markedRead
}
@@ -644,7 +639,8 @@ object ChatModel {
}
fun addTerminalItem(item: TerminalItem) {
if (terminalItems.value.size >= 500) {
val maxItems = if (appPreferences.developerTools.get()) 500 else 200
if (terminalItems.value.size >= maxItems) {
terminalItems.value = terminalItems.value.subList(1, terminalItems.value.size)
}
terminalItems.value += item
@@ -969,6 +965,16 @@ sealed class ChatInfo: SomeChat, NamedChat {
is Group -> groupInfo.chatSettings
else -> null
}
val chatTs: Instant
get() = when(this) {
is Direct -> contact.chatTs ?: contact.updatedAt
is Group -> groupInfo.chatTs ?: groupInfo.updatedAt
is Local -> noteFolder.chatTs
is ContactRequest -> contactRequest.updatedAt
is ContactConnection -> contactConnection.updatedAt
is InvalidJSON -> updatedAt
}
}
@Serializable
@@ -1009,6 +1015,7 @@ data class Contact(
val mergedPreferences: ContactUserPreferences,
override val createdAt: Instant,
override val updatedAt: Instant,
val chatTs: Instant?,
val contactGroupMemberId: Long? = null,
val contactGrpInvSent: Boolean
): SomeChat, NamedChat {
@@ -1077,6 +1084,7 @@ data class Contact(
mergedPreferences = ContactUserPreferences.sampleData,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
contactGrpInvSent = false
)
}
@@ -1204,7 +1212,8 @@ data class GroupInfo (
val hostConnCustomUserProfileId: Long? = null,
val chatSettings: ChatSettings,
override val createdAt: Instant,
override val updatedAt: Instant
override val updatedAt: Instant,
val chatTs: Instant?
): SomeChat, NamedChat {
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
@@ -1245,7 +1254,8 @@ data class GroupInfo (
hostConnCustomUserProfileId = null,
chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
updatedAt = Clock.System.now(),
chatTs = Clock.System.now()
)
}
}
@@ -1507,7 +1517,8 @@ class NoteFolder(
val favorite: Boolean,
val unread: Boolean,
override val createdAt: Instant,
override val updatedAt: Instant
override val updatedAt: Instant,
val chatTs: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.Local
override val id get() = "*$noteFolderId"
@@ -1530,7 +1541,8 @@ class NoteFolder(
favorite = false,
unread = false,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
updatedAt = Clock.System.now(),
chatTs = Clock.System.now()
)
}
}
@@ -1990,6 +2002,46 @@ data class ChatItem (
}
}
fun MutableState<SnapshotStateList<ChatItem>>.add(index: Int, chatItem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, chatItem) }
}
fun MutableState<SnapshotStateList<ChatItem>>.add(chatItem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(chatItem) }
}
fun MutableState<SnapshotStateList<ChatItem>>.addAll(index: Int, chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); addAll(index, chatItems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.addAll(chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); addAll(chatItems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.removeAll(block: (ChatItem) -> Boolean) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeAll(block) }
}
fun MutableState<SnapshotStateList<ChatItem>>.removeAt(index: Int) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeAt(index) }
}
fun MutableState<SnapshotStateList<ChatItem>>.removeLast() {
value = SnapshotStateList<ChatItem>().apply { addAll(value); removeLast() }
}
fun MutableState<SnapshotStateList<ChatItem>>.replaceAll(chatItems: List<ChatItem>) {
value = SnapshotStateList<ChatItem>().apply { addAll(chatItems) }
}
fun MutableState<SnapshotStateList<ChatItem>>.clear() {
value = SnapshotStateList<ChatItem>()
}
fun State<SnapshotStateList<ChatItem>>.asReversed(): MutableList<ChatItem> = value.asReversed()
val State<List<ChatItem>>.size: Int get() = value.size
enum class CIMergeCategory {
MemberConnected,
RcvGroupEvent,

View File

@@ -28,6 +28,7 @@ external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String
external fun chatPasswordHash(pwd: String, salt: String): String
external fun chatValidName(name: String): String
external fun chatJsonLength(str: String): Int
external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String
external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>
external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String
@@ -42,7 +43,7 @@ val appPreferences: AppPreferences
val chatController: ChatController = ChatController
fun initChatControllerAndRunMigrations() {
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi {
if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) {
initChatController(startChat = ::showStartChatAfterRestartAlert)
} else {
@@ -58,10 +59,23 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
chatModel.ctrlInitInProgress.value = true
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
val confirm = confirmMigrations ?: if (appPreferences.developerTools.get() && appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value)
val res: DBMigrationResult = kotlin.runCatching {
var migrated: Array<Any> = chatMigrateInit(dbAbsolutePrefixPath, dbKey, MigrationConfirmation.Error.value)
var res: DBMigrationResult = runCatching {
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
val rerunMigration = res is DBMigrationResult.ErrorMigration && when (res.migrationError) {
// we don't allow to run down migrations without confirmation in UI, so currently it won't be YesUpDown
is MigrationError.Upgrade -> confirm == MigrationConfirmation.YesUp || confirm == MigrationConfirmation.YesUpDown
is MigrationError.Downgrade -> confirm == MigrationConfirmation.YesUpDown
is MigrationError.Error -> false
}
if (rerunMigration) {
chatModel.dbMigrationInProgress.value = true
migrated = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value)
res = runCatching {
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
}
val ctrl = if (res is DBMigrationResult.OK) {
migrated[1] as Long
} else null
@@ -119,6 +133,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat
}
} finally {
chatModel.ctrlInitInProgress.value = false
chatModel.dbMigrationInProgress.value = false
}
}

View File

@@ -55,7 +55,7 @@ abstract class NtfManager {
}
fun openChatAction(userId: Long?, chatId: ChatId) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications?
@@ -70,7 +70,7 @@ abstract class NtfManager {
}
fun showChatsAction(userId: Long?) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi {
awaitChatStartedIfNeeded(chatModel)
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
// TODO include remote host ID in desktop notifications?

View File

@@ -324,7 +324,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
) {
LaunchedEffect(Unit) {
LaunchedEffect(ciInfo) {
if (ciInfo.memberDeliveryStatuses != null) {
selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses)
}

View File

@@ -67,13 +67,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
launch {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.onEach { Log.d(TAG, "TODOCHAT: chatId: activeChatId ${activeChat.value?.id} == new chatId $it ${activeChat.value?.id == it} ") }
.filter { it != null && activeChat.value?.id != it }
.filterNotNull()
.collect { chatId ->
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatId!!)
Log.d(TAG, "TODOCHAT: chatId: activeChatId became ${activeChat.value?.id}")
if (activeChat.value?.id != chatId) {
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatId)
}
markUnreadChatAsRead(activeChat, chatModel)
}
}
@@ -92,12 +92,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
.distinctUntilChanged()
.onEach { Log.d(TAG, "TODOCHAT: chats: activeChatId ${activeChat.value?.id} == new chatId ${it?.id} ${activeChat.value?.id == it?.id} ") }
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it != null && it?.chatInfo != activeChat.value?.chatInfo }
.filter { it != null && it.chatInfo != activeChat.value?.chatInfo }
.collect {
activeChat.value = it
Log.d(TAG, "TODOCHAT: chats: activeChatId became ${activeChat.value?.id}")
}
}
}
@@ -148,7 +146,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
},
attachmentOption,
attachmentBottomSheetState,
chatModel.chatItems,
searchText,
useLinkPreviews = useLinkPreviews,
linkMode = chatModel.simplexLinkMode.value,
@@ -226,19 +223,17 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
loadPrevMessages = {
if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout
val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout)
val firstId = chatModel.chatItems.firstOrNull()?.id
val firstId = chatModel.chatItems.value.firstOrNull()?.id
if (c != null && firstId != null) {
withBGApi {
Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}")
apiLoadPrevMessages(c, chatModel, firstId, searchText.value)
Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}")
}
}
},
deleteMessage = { itemId, mode ->
withBGApi {
val cInfo = chat.chatInfo
val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId }
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo)
val groupInfo = toModerate?.first
val groupMember = toModerate?.second
@@ -404,12 +399,15 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel)
}
ModalManager.end.closeModals()
ModalManager.end.showModal(endButtons = {
ModalManager.end.showModalCloseable(endButtons = {
ShareButton {
clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get()))
}
}) {
}) { close ->
ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get())
KeyChangeEffect(chatModel.chatId.value) {
close()
}
}
}
}
@@ -495,7 +493,6 @@ fun ChatLayout(
composeView: (@Composable () -> Unit),
attachmentOption: MutableState<AttachmentOption?>,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
@@ -582,7 +579,7 @@ fun ChatLayout(
.padding(contentPadding)
) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
chat, unreadCount, composeState, searchValue,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
@@ -647,7 +644,7 @@ fun ChatInfoToolbar(
}
}
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) {
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.mergedPreferences.calls.enabled.forUser) {
if (activeCall == null) {
barButtons.add {
if (appPlatform.isAndroid) {
@@ -840,7 +837,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
chat: Chat,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
chatItems: List<ChatItem>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
@@ -869,7 +865,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
ScrollToBottom(chat.id, listState, chatItems)
ScrollToBottom(chat.id, listState, chatModel.chatItems)
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
// Scroll to bottom when search value changes from something to nothing and back
LaunchedEffect(searchValue.value.isEmpty()) {
@@ -886,7 +882,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } }
val reversedChatItems by remember { derivedStateOf { chatModel.chatItems.asReversed() } }
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
val scrollToItem: (Long) -> Unit = { itemId: Long ->
val index = reversedChatItems.indexOfFirst { it.id == itemId }
@@ -939,7 +935,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
val provider = {
providerForGallery(i, chatItems, cItem.id) { indexInReversed ->
providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
scope.launch {
listState.scrollToItem(
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
@@ -1062,11 +1058,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
FloatingButtons(chatModel.chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState)
}
@Composable
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) {
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State<List<ChatItem>>) {
val scope = rememberCoroutineScope()
// Helps to scroll to bottom after moving from Group to Direct chat
// and prevents scrolling to bottom on orientation change
@@ -1084,7 +1080,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
* When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
* */
LaunchedEffect(Unit) {
snapshotFlow { chatItems.lastOrNull()?.id }
snapshotFlow { chatItems.value.lastOrNull()?.id }
.distinctUntilChanged()
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
.collect {
@@ -1107,7 +1103,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
@Composable
fun BoxWithConstraintsScope.FloatingButtons(
chatItems: List<ChatItem>,
chatItems: State<List<ChatItem>>,
unreadCount: State<Int>,
minUnreadItemId: Long,
searchValue: State<String>,
@@ -1141,10 +1137,11 @@ fun BoxWithConstraintsScope.FloatingButtons(
val bottomUnreadCount by remember {
derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0
val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
if (chatItems.size <= from || from < 0) return@derivedStateOf 0
val items = chatItems.value
val from = items.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
if (items.size <= from || from < 0) return@derivedStateOf 0
chatItems.subList(from, chatItems.size).count { it.isRcvNew }
items.subList(from, items.size).count { it.isRcvNew }
}
}
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
@@ -1190,7 +1187,7 @@ fun BoxWithConstraintsScope.FloatingButtons(
painterResource(MR.images.ic_check),
onClick = {
markRead(
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
)
showDropDown.value = false
@@ -1495,7 +1492,6 @@ fun PreviewChatLayout() {
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
@@ -1568,7 +1564,6 @@ fun PreviewGroupChatLayout() {
composeView = {},
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
chatItems = chatItems,
searchValue,
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,

View File

@@ -583,6 +583,10 @@ fun ComposeView(
}
fun cancelLinkPreview() {
val pendingLink = pendingLinkUrl.value
if (pendingLink != null) {
cancelledLinks.add(pendingLink)
}
val uri = composeState.value.linkPreview?.uri
if (uri != null) {
cancelledLinks.add(uri)
@@ -661,7 +665,7 @@ fun ComposeView(
fun editPrevMessage() {
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
val lastEditable = chatModel.chatItems.findLast { it.meta.editable }
val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable }
if (lastEditable != null) {
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
}

View File

@@ -59,14 +59,6 @@ fun SendMsgView(
) {
val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) }
if (showCustomDisappearingMessageDialog.value) {
CustomDisappearingMessageDialog(
sendMessage = sendMessage,
setShowDialog = { showCustomDisappearingMessageDialog.value = it },
customDisappearingMessageTimePref = customDisappearingMessageTimePref
)
}
Box(Modifier.padding(vertical = 8.dp)) {
val cs = composeState.value
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
@@ -203,6 +195,11 @@ fun SendMsgView(
DefaultDropdownMenu(showDropdown) {
menuItems.forEach { composable -> composable() }
}
CustomDisappearingMessageDialog(
showCustomDisappearingMessageDialog,
sendMessage = sendMessage,
customDisappearingMessageTimePref = customDisappearingMessageTimePref
)
} else {
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage)
}
@@ -220,93 +217,43 @@ expect fun VoiceButtonWithoutPermissionByPlatform()
@Composable
private fun CustomDisappearingMessageDialog(
showMenu: MutableState<Boolean>,
sendMessage: (Int?) -> Unit,
setShowDialog: (Boolean) -> Unit,
customDisappearingMessageTimePref: SharedPreference<Int>?
) {
val showCustomTimePicker = remember { mutableStateOf(false) }
if (showCustomTimePicker.value) {
val selectedDisappearingMessageTime = remember {
mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300)
}
CustomTimePickerDialog(
selectedDisappearingMessageTime,
title = generalGetString(MR.strings.delete_after),
confirmButtonText = generalGetString(MR.strings.send_disappearing_message_send),
confirmButtonAction = { ttl ->
sendMessage(ttl)
customDisappearingMessageTimePref?.set?.invoke(ttl)
setShowDialog(false)
},
cancel = { setShowDialog(false) }
DefaultDropdownMenu(showMenu) {
Text(
generalGetString(MR.strings.send_disappearing_message),
Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING * 1.5f),
fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
} else {
@Composable
fun ChoiceButton(
text: String,
onClick: () -> Unit
) {
TextButton(onClick) {
Text(
text,
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
}
DefaultDialog(onDismissRequest = { setShowDialog(false) }) {
Surface(
shape = RoundedCornerShape(corner = CornerSize(25.dp)),
contentColor = LocalContentColor.current
) {
Box(
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.padding(DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(" ") // centers title
Text(
generalGetString(MR.strings.send_disappearing_message),
fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
Icon(
painterResource(MR.images.ic_close),
generalGetString(MR.strings.icon_descr_close_button),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(25.dp)
.clickable { setShowDialog(false) }
)
}
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_30_seconds)) {
sendMessage(30)
setShowDialog(false)
}
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_1_minute)) {
sendMessage(60)
setShowDialog(false)
}
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_5_minutes)) {
sendMessage(300)
setShowDialog(false)
}
ChoiceButton(generalGetString(MR.strings.send_disappearing_message_custom_time)) {
showCustomTimePicker.value = true
}
}
}
}
ItemAction(generalGetString(MR.strings.send_disappearing_message_30_seconds)) {
sendMessage(30)
showMenu.value = false
}
ItemAction(generalGetString(MR.strings.send_disappearing_message_1_minute)) {
sendMessage(60)
showMenu.value = false
}
ItemAction(generalGetString(MR.strings.send_disappearing_message_5_minutes)) {
sendMessage(300)
showMenu.value = false
}
ItemAction(generalGetString(MR.strings.send_disappearing_message_custom_time)) {
showMenu.value = false
val selectedDisappearingMessageTime = mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300)
showCustomTimePickerDialog(
selectedDisappearingMessageTime,
title = generalGetString(MR.strings.delete_after),
confirmButtonText = generalGetString(MR.strings.send_disappearing_message_send),
confirmButtonAction = { ttl ->
sendMessage(ttl)
customDisappearingMessageTimePref?.set?.invoke(ttl)
},
cancel = { showMenu.value = false }
)
}
}
}

View File

@@ -54,7 +54,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
},
inviteMembers = {
allowModifyMembers = false
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi(slow = 30_000, deadlock = 120_000) {
for (contactId in selectedContacts) {
val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value)
if (member != null) {
@@ -86,7 +86,7 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
.map { it.chatInfo }
.filterIsInstance<ChatInfo.Direct>()
.map { it.contact }
.filter { it.contactId !in memberContactIds && it.chatViewName.lowercase().contains(s) }
.filter { c -> c.ready && c.active && c.contactId !in memberContactIds && c.chatViewName.lowercase().contains(s) }
.sortedBy { it.displayName.lowercase() }
.toList()
}

View File

@@ -3,11 +3,9 @@ package chat.simplex.common.views.chat.group
import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import TextIconSpaced
import androidx.compose.desktop.ui.tooling.preview.Preview
import java.net.URI
import androidx.compose.foundation.*
@@ -74,9 +72,8 @@ fun GroupMemberInfoView(
if (chatModel.getContactChat(it) == null) {
chatModel.addChat(c)
}
chatModel.chatItems.clear()
chatModel.chatItemStatuses.clear()
chatModel.chatItems.addAll(c.chatItems)
chatModel.chatItems.replaceAll(c.chatItems)
chatModel.chatId.value = c.id
closeAll()
}

View File

@@ -3,6 +3,7 @@ package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import TextIconSpaced
import androidx.compose.foundation.layout.*
@@ -14,6 +15,7 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@@ -27,9 +29,13 @@ import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.chatJsonLength
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.res.MR
import kotlinx.coroutines.delay
private const val maxByteCount = 1200
@Composable
fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () -> Unit) {
var gInfo by remember { mutableStateOf(groupInfo) }
@@ -54,8 +60,11 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: ()
ModalView(
close = {
if (welcomeText.value == gInfo.groupProfile.description || (welcomeText.value == "" && gInfo.groupProfile.description == null)) close()
else showUnsavedChangesAlert({ save(close) }, close)
when {
welcomeTextUnchanged(welcomeText, gInfo) -> close()
!welcomeTextFitsLimit(welcomeText) -> showUnsavedChangesTooLongAlert(close)
else -> showUnsavedChangesAlert({ save(close) }, close)
}
},
) {
GroupWelcomeLayout(
@@ -67,6 +76,14 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: ()
}
}
private fun welcomeTextUnchanged(welcomeText: MutableState<String>, groupInfo: GroupInfo): Boolean {
return welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)
}
private fun welcomeTextFitsLimit(welcomeText: MutableState<String>): Boolean {
return chatJsonLength(welcomeText.value) <= maxByteCount
}
@Composable
private fun GroupWelcomeLayout(
welcomeText: MutableState<String>,
@@ -95,6 +112,13 @@ private fun GroupWelcomeLayout(
} else {
TextPreview(wt.value, linkMode)
}
SectionTextFooter(
if (!welcomeTextFitsLimit(wt)) { generalGetString(MR.strings.message_too_large) } else "",
color = if (welcomeTextFitsLimit(wt)) MaterialTheme.colors.secondary else Color.Red
)
Spacer(Modifier.size(8.dp))
ChangeModeButton(
editMode.value,
click = {
@@ -104,10 +128,18 @@ private fun GroupWelcomeLayout(
)
val clipboard = LocalClipboardManager.current
CopyTextButton { clipboard.setText(AnnotatedString(wt.value)) }
SectionDividerSpaced(maxBottomPadding = false)
Divider(
Modifier.padding(
start = DEFAULT_PADDING_HALF,
top = 8.dp,
end = DEFAULT_PADDING_HALF,
bottom = 8.dp)
)
SaveButton(
save = save,
disabled = wt.value == groupInfo.groupProfile.description || (wt.value == "" && groupInfo.groupProfile.description == null)
disabled = welcomeTextUnchanged(wt, groupInfo) || !welcomeTextFitsLimit(wt)
)
} else {
val clipboard = LocalClipboardManager.current
@@ -182,3 +214,11 @@ private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
onDismiss = revert,
)
}
private fun showUnsavedChangesTooLongAlert(revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.welcome_message_is_too_long),
confirmText = generalGetString(MR.strings.exit_without_saving),
onConfirm = revert,
)
}

View File

@@ -103,7 +103,7 @@ fun ChatItemView(
setReaction(cInfo, cItem, !r.userReacted, r.reaction)
}
}
Row(modifier.padding(2.dp)) {
Row(modifier.padding(2.dp), verticalAlignment = Alignment.CenterVertically) {
ReactionIcon(r.reaction.text, fontSize = 12.sp)
if (r.totalReacted > 1) {
Spacer(Modifier.width(4.dp))
@@ -112,7 +112,6 @@ fun ChatItemView(
fontSize = 11.5.sp,
fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal,
color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = if (appPlatform.isAndroid) Modifier else Modifier.padding(top = 4.dp)
)
}
}
@@ -178,7 +177,8 @@ fun ChatItemView(
fun MsgContentItemDropdownMenu() {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file)
when {
cItem.content.msgContent != null -> {
// cItem.id check is a special case for live message chat item which has negative ID while not sent yet
cItem.content.msgContent != null && cItem.id >= 0 -> {
DefaultDropdownMenu(showMenu) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
@@ -527,8 +527,9 @@ fun DeleteItemAction(
val range = chatViewItemsRange(currIndex, prevHidden)
if (range != null) {
val itemIds: ArrayList<Long> = arrayListOf()
val reversedChatItems = chatModel.chatItems.asReversed()
for (i in range) {
itemIds.add(chatModel.chatItems.asReversed()[i].id)
itemIds.add(reversedChatItems[i].id)
}
deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages)
} else {
@@ -651,6 +652,23 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo
}
}
@Composable
fun ItemAction(text: String, color: Color = Color.Unspecified, onClick: () -> Unit) {
val finalColor = if (color == Color.Unspecified) {
MenuTextColor
} else color
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
Text(
text,
modifier = Modifier
.fillMaxWidth()
.weight(1F)
.padding(end = 15.dp),
color = finalColor
)
}
}
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
AlertManager.shared.showAlertDialog(
title = generalGetString(cancelAction.alert.titleId),

View File

@@ -91,7 +91,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState<B
)
}
private fun markedDeletedText(meta: CIMeta): String =
fun markedDeletedText(meta: CIMeta): String =
when (meta.itemDeleted) {
is CIDeleted.Moderated ->
String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName)

View File

@@ -212,18 +212,15 @@ suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) {
}
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) {
Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId)
if (chat != null) {
openLoadedChat(chat, chatModel)
Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
}
}
fun openLoadedChat(chat: Chat, chatModel: ChatModel) {
chatModel.chatItems.clear()
chatModel.chatItemStatuses.clear()
chatModel.chatItems.addAll(chat.chatItems)
chatModel.chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
}
@@ -239,8 +236,7 @@ suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) {
val chatInfo = ch.chatInfo
val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return
if (chatModel.chatId.value != chat.id) return
chatModel.chatItems.clear()
chatModel.chatItems.addAll(0, chat.chatItems)
chatModel.chatItems.replaceAll(chat.chatItems)
}
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) {

View File

@@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.chat.item.markedDeletedText
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
@@ -170,7 +171,7 @@ fun ChatPreviewView(
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
ci.meta.itemDeleted == null -> ci.text to null
else -> generalGetString(MR.strings.marked_deleted_description) to null
else -> markedDeletedText(ci.meta) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
@@ -286,7 +287,7 @@ fun ChatPreviewView(
Box(
contentAlignment = Alignment.TopEnd
) {
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt)
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.chatTs)
Text(
ts,
color = MaterialTheme.colors.secondary,

View File

@@ -62,7 +62,7 @@ fun DatabaseEncryptionView(m: ChatModel) {
initialRandomDBPassphrase,
progressIndicator,
onConfirmEncrypt = {
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi {
encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator)
}
}

View File

@@ -368,7 +368,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
}
fun startChat(m: ChatModel, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>, progressIndicator: MutableState<Boolean>? = null) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi {
try {
progressIndicator?.value = true
if (chatDbChanged.value) {
@@ -581,7 +581,7 @@ private fun importArchive(
progressIndicator.value = true
val archivePath = saveArchiveFromURI(importedArchiveURI)
if (archivePath != null) {
withLongRunningApi(slow = 60_000, deadlock = 180_000) {
withLongRunningApi {
try {
m.controller.apiDeleteStorage()
try {

View File

@@ -22,6 +22,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
class AlertManager {
@@ -128,6 +129,8 @@ class AlertManager {
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
// Wait before focusing to prevent auto-confirming if a user used Enter key on hardware keyboard
delay(200)
focusRequester.requestFocus()
}
TextButton(onClick = {
@@ -195,6 +198,8 @@ class AlertManager {
AlertContent(text, hostDevice, extraPadding = true) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
// Wait before focusing to prevent auto-confirming if a user used Enter key on hardware keyboard
delay(200)
focusRequester.requestFocus()
}
Row(

View File

@@ -1,116 +1,21 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.clickable
import SectionItemView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import androidx.compose.ui.text.style.TextAlign
import chat.simplex.common.model.CustomTimeUnit
import chat.simplex.common.model.timeText
import chat.simplex.res.MR
import com.sd.lib.compose.wheel_picker.*
@Composable
fun CustomTimePicker(
expect fun CustomTimePicker(
selection: MutableState<Int>,
timeUnitsLimits: List<TimeUnitLimits> = TimeUnitLimits.defaultUnitsLimits
) {
fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List<Int> {
val unitLimits = timeUnitsLimits.firstOrNull { it.timeUnit == unit } ?: TimeUnitLimits.defaultUnitLimits(unit)
val regularUnitValues = (unitLimits.minValue..unitLimits.maxValue).toList()
return regularUnitValues + if (regularUnitValues.contains(selectedValue)) emptyList() else listOf(selectedValue)
}
val (unit, duration) = CustomTimeUnit.toTimeUnit(selection.value)
val selectedUnit: MutableState<CustomTimeUnit> = remember { mutableStateOf(unit) }
val selectedDuration = remember { mutableStateOf(duration) }
val selectedUnitValues = remember { mutableStateOf(getUnitValues(selectedUnit.value, selectedDuration.value)) }
val isTriggered = remember { mutableStateOf(false) }
LaunchedEffect(selectedUnit.value) {
// on initial composition, if passed selection doesn't fit into picker bounds, so that selectedDuration is bigger than selectedUnit maxValue
// (e.g., for selection = 121 seconds: selectedUnit would be Second, selectedDuration would be 121 > selectedUnit maxValue of 120),
// selectedDuration would've been replaced by maxValue - isTriggered check prevents this by skipping LaunchedEffect on initial composition
if (isTriggered.value) {
val maxValue = timeUnitsLimits.firstOrNull { it.timeUnit == selectedUnit.value }?.maxValue
if (maxValue != null && selectedDuration.value > maxValue) {
selectedDuration.value = maxValue
selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value)
} else {
selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value)
selection.value = selectedUnit.value.toSeconds * selectedDuration.value
}
} else {
isTriggered.value = true
}
}
LaunchedEffect(selectedDuration.value) {
selection.value = selectedUnit.value.toSeconds * selectedDuration.value
}
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING),
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
Column(Modifier.weight(1f)) {
val durationPickerState = rememberFWheelPickerState(selectedUnitValues.value.indexOf(selectedDuration.value))
FVerticalWheelPicker(
count = selectedUnitValues.value.count(),
state = durationPickerState,
unfocusedCount = 2,
focus = {
FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary)
}
) { index ->
Text(
selectedUnitValues.value[index].toString(),
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
LaunchedEffect(durationPickerState) {
snapshotFlow { durationPickerState.currentIndex }
.collect {
selectedDuration.value = selectedUnitValues.value[it]
}
}
}
Column(Modifier.weight(1f)) {
val unitPickerState = rememberFWheelPickerState(timeUnitsLimits.indexOfFirst { it.timeUnit == selectedUnit.value })
FVerticalWheelPicker(
count = timeUnitsLimits.count(),
state = unitPickerState,
unfocusedCount = 2,
focus = {
FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary)
}
) { index ->
Text(
timeUnitsLimits[index].timeUnit.text,
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
LaunchedEffect(unitPickerState) {
snapshotFlow { unitPickerState.currentIndex }
.collect {
selectedUnit.value = timeUnitsLimits[it].timeUnit
}
}
}
}
}
)
data class TimeUnitLimits(
val timeUnit: CustomTimeUnit,
@@ -141,8 +46,7 @@ data class TimeUnitLimits(
}
}
@Composable
fun CustomTimePickerDialog(
fun showCustomTimePickerDialog(
selection: MutableState<Int>,
timeUnitsLimits: List<TimeUnitLimits> = TimeUnitLimits.defaultUnitsLimits,
title: String,
@@ -150,53 +54,26 @@ fun CustomTimePickerDialog(
confirmButtonAction: (Int) -> Unit,
cancel: () -> Unit
) {
DefaultDialog(onDismissRequest = cancel) {
Surface(
shape = RoundedCornerShape(corner = CornerSize(25.dp)),
contentColor = LocalContentColor.current
) {
Box(
contentAlignment = Alignment.Center
AlertManager.shared.showAlertDialogButtonsColumn(
title = title,
onDismissRequest = cancel
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CustomTimePicker(
selection,
timeUnitsLimits
)
SectionItemView({
AlertManager.shared.hideAlert()
confirmButtonAction(selection.value)
}
) {
Column(
modifier = Modifier.padding(DEFAULT_PADDING),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(" ") // centers title
Text(
title,
fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
Icon(
painterResource(MR.images.ic_close),
generalGetString(MR.strings.icon_descr_close_button),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(25.dp)
.clickable { cancel() }
)
}
CustomTimePicker(
selection,
timeUnitsLimits
)
TextButton(onClick = { confirmButtonAction(selection.value) }) {
Text(
confirmButtonText,
fontSize = 18.sp,
color = MaterialTheme.colors.primary
)
}
}
Text(
confirmButtonText,
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colors.primary
)
}
}
}
@@ -220,7 +97,6 @@ fun DropdownCustomTimePickerSettingRow(
val dropdownSelection: MutableState<DropdownSelection> = remember { mutableStateOf(DropdownSelection.DropdownValue(selection.value)) }
val values: MutableState<List<DropdownSelection>> = remember { mutableStateOf(getValues(selection.value)) }
val showCustomTimePicker = remember { mutableStateOf(false) }
fun updateValue(selectedValue: Int?) {
values.value = getValues(selectedValue)
@@ -247,28 +123,22 @@ fun DropdownCustomTimePickerSettingRow(
onSelected = { sel: DropdownSelection ->
when (sel) {
is DropdownSelection.DropdownValue -> updateValue(sel.value)
DropdownSelection.Custom -> showCustomTimePicker.value = true
DropdownSelection.Custom -> {
val selectedCustomTime = mutableStateOf(selection.value ?: 86400)
showCustomTimePickerDialog(
selectedCustomTime,
timeUnitsLimits = customPickerTimeUnitsLimits,
title = customPickerTitle,
confirmButtonText = customPickerConfirmButtonText,
confirmButtonAction = ::updateValue,
cancel = {
dropdownSelection.value = DropdownSelection.DropdownValue(selection.value)
}
)
}
}
}
)
if (showCustomTimePicker.value) {
val selectedCustomTime = remember { mutableStateOf(selection.value ?: 86400) }
CustomTimePickerDialog(
selectedCustomTime,
timeUnitsLimits = customPickerTimeUnitsLimits,
title = customPickerTitle,
confirmButtonText = customPickerConfirmButtonText,
confirmButtonAction = { time ->
updateValue(time)
showCustomTimePicker.value = false
},
cancel = {
dropdownSelection.value = DropdownSelection.DropdownValue(selection.value)
showCustomTimePicker.value = false
}
)
}
}
private sealed class DropdownSelection {

View File

@@ -5,6 +5,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.DEFAULT_PADDING
@@ -20,7 +21,7 @@ fun DefaultProgressView(description: String?) {
strokeWidth = 2.5.dp
)
if (description != null) {
Text(description)
Text(description, textAlign = TextAlign.Center)
}
}
}

View File

@@ -61,10 +61,10 @@ class ModalManager(private val placement: ModalPlacement? = null) {
}
}
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
val data = ModalData()
showCustomModal { close ->
ModalView(close, showClose = showClose, content = { data.content(close) })
ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content(close) })
}
}

View File

@@ -198,16 +198,16 @@ fun <T> SectionItemWithValue(
}
@Composable
fun SectionTextFooter(text: String) {
SectionTextFooter(AnnotatedString(text))
fun SectionTextFooter(text: String, color: Color = MaterialTheme.colors.secondary) {
SectionTextFooter(AnnotatedString(text), color = color)
}
@Composable
fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start) {
fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start, color: Color = MaterialTheme.colors.secondary) {
Text(
text,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F),
color = MaterialTheme.colors.secondary,
color = color,
lineHeight = 18.sp,
fontSize = 14.sp,
textAlign = textAlign

View File

@@ -49,7 +49,7 @@ fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) {
}
private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (LAResult) -> Unit) {
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi {
try {
/** Waiting until [initChatController] finishes */
while (m.ctrlInitInProgress.value) {

View File

@@ -50,7 +50,7 @@ fun SetupDatabasePassphrase(m: ChatModel) {
confirmNewKey,
progressIndicator,
onConfirmEncrypt = {
withLongRunningApi(slow = 30_000, deadlock = 60_000) {
withLongRunningApi {
if (m.chatRunning.value == true) {
// Stop chat if it's started before doing anything
stopChatAsync(m)

View File

@@ -47,10 +47,6 @@ fun NetworkAndServersView(
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
val sessionMode = remember { mutableStateOf(netCfg.sessionMode) }
LaunchedEffect(Unit) {
chatModel.userSMPServersUnsaved.value = null
}
val proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } }
NetworkAndServersLayout(
currentRemoteHost = currentRemoteHost,

View File

@@ -28,19 +28,18 @@ import chat.simplex.res.MR
@Composable
fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) {
var presetServers by remember(rhId) { mutableStateOf(emptyList<String>()) }
var servers by remember(rhId) {
mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList())
}
var servers by remember { stateGetOrPut("servers") { emptyList<ServerCfg>() } }
var serversAlreadyLoaded by remember { stateGetOrPut("serversAlreadyLoaded") { false } }
val currServers = remember(rhId) { mutableStateOf(servers) }
val testing = rememberSaveable(rhId) { mutableStateOf(false) }
val serversUnchanged = remember { derivedStateOf { servers == currServers.value || testing.value } }
val allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } }
val saveDisabled = remember {
val serversUnchanged = remember(servers) { derivedStateOf { servers == currServers.value || testing.value } }
val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled } } }
val saveDisabled = remember(servers) {
derivedStateOf {
servers.isEmpty() ||
servers == currServers.value ||
testing.value ||
!servers.all { srv ->
servers.none { srv ->
val address = parseServerAddress(srv.server)
address != null && uniqueAddress(srv, address, servers)
} ||
@@ -49,8 +48,8 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser
}
KeyChangeEffect(rhId) {
m.userSMPServersUnsaved.value = null
servers = emptyList()
serversAlreadyLoaded = false
}
LaunchedEffect(rhId) {
@@ -59,8 +58,9 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser
if (res != null) {
currServers.value = res.protoServers
presetServers = res.presetServers
if (servers.isEmpty()) {
if (servers.isEmpty() && !serversAlreadyLoaded) {
servers = currServers.value
serversAlreadyLoaded = true
}
}
}
@@ -80,13 +80,11 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser
newServers.add(index, updated)
old = updated
servers = newServers
m.userSMPServersUnsaved.value = servers
},
onDelete = {
val newServers = ArrayList(servers)
newServers.removeAt(index)
servers = newServers
m.userSMPServersUnsaved.value = servers
close()
})
}
@@ -125,7 +123,6 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser
ScanProtocolServer(rhId) {
close()
servers = servers + it
m.userSMPServersUnsaved.value = servers
}
}
}
@@ -150,13 +147,11 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser
testServersJob.value = withLongRunningApi {
testServers(testing, servers, m) {
servers = it
m.userSMPServersUnsaved.value = servers
}
}
},
resetServers = {
servers = currServers.value ?: emptyList()
m.userSMPServersUnsaved.value = null
servers = currServers.value
},
saveSMPServers = {
saveServers(rhId, serverProtocol, currServers, servers, m)
@@ -355,7 +350,6 @@ private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: Muta
withBGApi {
if (m.controller.setUserProtoServers(rhId, protocol, servers)) {
currServers.value = servers
m.userSMPServersUnsaved.value = null
}
afterSave()
}

View File

@@ -16,6 +16,7 @@
<!-- MainActivity.kt -->
<string name="opening_database">Opening database…</string>
<string name="database_migration_in_progress">Database migration is in progress.\nIt may take a few minutes.</string>
<string name="non_content_uri_alert_title">Invalid file path</string>
<string name="non_content_uri_alert_text">You shared an invalid file path. Report the issue to the app developers.</string>
<string name="app_was_crashed">View crashed</string>
@@ -1377,9 +1378,11 @@
<!-- GroupWelcomeView.kt -->
<string name="group_welcome_title">Welcome message</string>
<string name="save_welcome_message_question">Save welcome message?</string>
<string name="welcome_message_is_too_long">Welcome message is too long</string>
<string name="save_and_update_group_profile">Save and update group profile</string>
<string name="group_welcome_preview">Preview</string>
<string name="enter_welcome_message">Enter welcome message…</string>
<string name="message_too_large">Message too large</string>
<!-- ConnectionStats -->
<string name="conn_stats_section_title_servers">SERVERS</string>

View File

@@ -14,8 +14,7 @@ import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme

View File

@@ -0,0 +1,80 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import chat.simplex.common.model.CustomTimeUnit
import chat.simplex.common.ui.theme.DEFAULT_PADDING
@Composable
actual fun CustomTimePicker(
selection: MutableState<Int>,
timeUnitsLimits: List<TimeUnitLimits>
) {
val unit = remember {
var res: CustomTimeUnit = CustomTimeUnit.Second
val found = timeUnitsLimits.asReversed().any {
if (selection.value >= it.minValue * it.timeUnit.toSeconds && selection.value <= it.maxValue * it.timeUnit.toSeconds) {
res = it.timeUnit
selection.value = (selection.value / it.timeUnit.toSeconds).coerceIn(it.minValue, it.maxValue) * it.timeUnit.toSeconds
true
} else {
false
}
}
if (!found) {
// If custom interval doesn't fit in any category, set it to 1 second interval
selection.value = 1
}
mutableStateOf(res)
}
val values = remember(unit.value) {
val limit = timeUnitsLimits.first { it.timeUnit == unit.value }
val res = ArrayList<Pair<Int, String>>()
for (i in limit.minValue..limit.maxValue) {
val seconds = i * limit.timeUnit.toSeconds
val desc = i.toString()
res.add(seconds to desc)
}
if (res.none { it.first == selection.value }) {
// Doesn't fit into min..max, put it equal to the closest value
selection.value = selection.value.coerceIn(res.first().first, res.last().first)
//selection.value = res.last { it.first <= selection.value }.first
}
res
}
val units = remember {
val res = ArrayList<Pair<CustomTimeUnit, String>>()
for (unit in timeUnitsLimits) {
res.add(unit.timeUnit to unit.timeUnit.text)
}
res
}
Row(
Modifier.padding(bottom = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
ExposedDropDownSetting(
values,
selection,
textColor = MaterialTheme.colors.onBackground,
enabled = remember { mutableStateOf(true) },
onSelected = { selection.value = it }
)
Spacer(Modifier.width(DEFAULT_PADDING))
ExposedDropDownSetting(
units,
unit,
textColor = MaterialTheme.colors.onBackground,
enabled = remember { mutableStateOf(true) },
onSelected = {
selection.value = selection.value / unit.value.toSeconds * it.toSeconds
unit.value = it
}
)
}
}

View File

@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
android.version_name=5.5
android.version_code=175
android.version_name=5.5.1
android.version_code=177
desktop.version_name=5.5
desktop.version_code=26
desktop.version_name=5.5.1
desktop.version_code=27
kotlin.version=1.8.20
gradle.plugin.version=7.4.2

View File

@@ -0,0 +1,119 @@
---
layout: layouts/article.html
title: "SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect."
date: 2024-01-24
previewBody: blog_previews/20240124.html
image: images/20240124-connect1.png
permalink: "/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"
---
# SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.
**Published:** Jan 24, 2024
[SimpleX Chat infrastructure on Linode](#simplex-chat-infrastructure-on-linode):
- Free infrastructure.
- SimpleX servers in Linode Marketplace.
- High capacity messaging servers.
What's new in v5.5:
- [private notes](#private-notes)
- [group history](#group-history)
- [simpler UX to connect to other users](#simpler-ux-to-connect-to-other-users)
- [message delivery stability and other improvements](#message-delivery-stability-and-other-improvements)
Also, we added Hungarian (only Android) and Turkish interface languages, thanks to [our users and Weblate](https://github.com/simplex-chat/simplex-chat#help-translating-simplex-chat).
SimpleX Chat Android app is now available in 20 languages!
## SimpleX Chat infrastructure on Linode
We chose Linode as our hosting provider as and they have been consistently reliable, cheaper than alternatives, with excellent support and great documentation.
When Linode was acquired by Akamai, we were a bit nervous about how it may affect service quality. So far it's been working out quite well.
As the usage of SimpleX network was growing, so did our hosting costs, and from being really small they started to become significant, particularly as we didn't yet manage to optimize the servers last year.
Linode helped - we're really excited to announce that Akamai decided to support SimpleX Chat growth by accepting it into their [Linode Rise startup program](https://www.linode.com/linode-for-startups/).
Thanks to this program:
- we received free infrastructure for the first year up to $10,000 per month, no strings attached. It already saved us some money, and gave us enough time to optimize the servers - the latest version of the servers are much less costly to operate with the current traffic, and can support a much larger traffic within this limit. In the year 2 of the program we will receive 50% discount with unlimited traffic, and in year 3 - 25% discount.
- Linode Marketplace now includes [SimpleX Chat messages and file servers](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) - you can get free $100 credits for the first 2 months and run your own servers in just a few clicks, and use them in SimpleX Chat apps. Anybody can submit their application to Linode marketplace, but dedicated support we have from Linode team via this program made it simpler.
- Akamai solution engineers are helping us to design high capacity server solution, free of charge, so that a single host can provide horizontally scalable capacity for messaging, allowing for a much larger number of concurrent users on a single server address. Initially we considered using HAProxy, and the latest proof of concept uses OpenResty - a fork of Nginx with Lua script engine - to route requests from a single host to multiple SMP relays, reducing an overhead for the clients that would be configured with a smaller number of higher capacity servers. This project is still in progress, there will be more details as we roll it out.
## What's new in v5.5
### Private notes
<img src="./images/20240124-notes1.png" width="220" class="float-to-left"> <img src="./images/20240124-notes2.png" width="220" class="float-to-left">
*"Where do I put notes for myself?"* was a very common support question. There was a workaround - you could create an empty group, just with yourself, and use it to save notes, but it was not very convenient, and you could accidentally add members there.
This version has a more convenient and private alternative - the Private notes. It looks like an ordinary conversation where you can put text messages, links with previews, and any media and files, but they are not sent anywhere - they are stored locally, only on your device, with encrypted files.
You can access the Private notes created in mobile app from desktop app too, by linking a mobile and desktop apps - the feature [added in the previous version](./20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md). It allows to conveniently share files between the devices without sending them over the Internet.
### Group history
<img src="./images/20240124-history1.png" width="220" class="float-to-left"> <img src="./images/20240124-history2.png" width="220" class="float-to-left">
In the previous version, when users joined groups, they only saw an empty conversation, and the notifications of being connected to other members. This version allows group admins sending recent group history to the new members - this option is enabled by default for new groups, and can be enabled for the existing groups in the preferences. So now new members can join the conversation as soon as they join.
This does not mean that these messages are stored on any servers - the admin member that adds a new member to the group sends these messages directly when a new member joins. Groups are still fully decentralized, do not have any identity on the network, and fully private - only their members know they exist.
That is, unless a group owner decides to make it public. Groups can be registered in [SimpleX groups directory](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) to be discovered by the new members - group directory is also improved.
### Simpler UX to connect to other users
<img src="./images/20240124-connect1.png" width="220" class="float-to-left"> <img src="./images/20240124-connect2.png" width="220" class="float-to-left">
SimpleX platform has no user accounts or identities, and while it improves metadata privacy, it also makes it harder to understand how to connect to other people, particularly for the new users who are not invited by the existing users.
This version simplifies this interface by allowing to connect via the received link just by pasting the address into the search bar, as is common in many wallet apps and some other decentralized messengers. We also improved the interface of creating invitation links.
We will continue working on improving and simplifying user interface throughout the year. Please send us any feedback and suggestions to the team's address available in the app.
### Message delivery stability and other improvements
One of the long standing issues was that message reception could get stuck in some rare occasions, and only get resumed once the app is fully restarted. As Android app includes an always-on notification service that runs in background, full restart should be done via the app settings.
This version fixed many issues with message delivery stability and also added some diagnostics to identify any other cases when message delivery may stop. These fixes should also reduce battery usage, particularly on slow internet connections.
Other improvements in this version:
- you can now reveal secret messages by tapping. To send a secret message wrap in "#" characters, e.g. "\#password\#".
- you can delete the last user profile, simplifying account deletion. If you have [hidden user profiles](./20230328-simplex-chat-v4-6-hidden-profiles.md), they won't be deleted in this case, and will be accessible again once you create a new profile.
## SimpleX platform
Some links to answer the most common questions:
[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers).
[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users).
[Technical details and limitations](https://github.com/simplex-chat/simplex-chat#privacy-technical-details-and-limitations).
[How SimpleX is different from Session, Matrix, Signal, etc.](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#frequently-asked-questions).
Please also see our [website](https://simplex.chat).
## Help us with donations
Huge thank you to everybody who donated to SimpleX Chat!
We are prioritizing users privacy and security - it would be impossible without your support.
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
Your donations help us raise more funds any amount, even the price of the cup of coffee, makes a big difference for us.
See [this section](https://github.com/simplex-chat/simplex-chat/tree/master#help-us-with-donations) for the ways to donate.
Thank you,
Evgeny
SimpleX Chat founder

View File

@@ -1,5 +1,20 @@
# Blog
Jan 24, 2024 [SimpleX Chat: free infrastructure from Linode, v5.5 released](./20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
SimpleX Chat infrastructure on Linode:
- Free infrastructure.
- SimpleX servers in Linode Marketplace.
- High capacity messaging servers.
What's new in v5.5:
- private notes.
- group history.
- simpler UX to connect to other users.
- message delivery stability and other improvements.
---
Nov 25, 2023 [SimpleX Chat v5.4 released](./20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md)
- Link mobile and desktop apps via secure quantum-resistant protocol. 🔗

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@@ -7,7 +7,7 @@ revision: 25.11.2023
| Updated 25.11.2023 | Languages: EN |
# Download SimpleX apps
The latest stable version is v5.4.3.
The latest stable version is v5.5.
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
@@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch
Using the same profile as on mobile device is not yet supported you need to create a separate profile to use desktop apps.
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-desktop-ubuntu-22_04-x86_64.deb).
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-ubuntu-22_04-x86_64.deb).
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-desktop-windows-x86_64.msi).
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-desktop-windows-x86_64.msi).
## Mobile apps
**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu).
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-armv7a.apk).
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-armv7a.apk).
## Terminal (console) app
See [Using terminal app](/docs/CLI.md).
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-chat-ubuntu-22_04-x86-64).
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-ubuntu-22_04-x86-64).
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#).
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](/docs/CLI.md#).
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.3/simplex-chat-windows-x86-64).
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.5.0/simplex-chat-windows-x86-64).

View File

@@ -0,0 +1,78 @@
# Space-efficient chat message encoding
## Problem
We use JSON format to encode messages in SimpleX Chat protocol. It has many advantages:
- human readable, unlike binary formats.
- easy to extend.
- relatively low overhead for large text messages (and the overhead is not important for small messages, as we send fixed size blocks).
- ability to use Internet RFC 8927 for message format schema.
The main overhead of this format is for images that are included as text base64 encoded data in JSON for image and video previews, link previews (limited to 14,000 bytes of base64 encoded size) and profile pictures (limited to 12,500 of base64 encoded size, to account for the connection links sent alongside the profile images).
Adding post quantum encryption requires adding additional message headers with additional keys requiring up to 2500 additional bytes of cryptographic data in each message header. This is a significant overhead for messages with images and would not be compatible with the current constraints.
## Possible solutions
- reduce image preview sizes. The downside is that it would further reduce the quality of the image previews and profile pictures, that is already suboptimal.
- switch to a binary format for messages. The downside is that, depending on the format, it may be more difficult to extend and debug, and in any case it is not human readable.
- allow larger client messages than the block size, by extending SMP agent protocol. This is possible, but 1) quite complex, 2) leaks information when an image preview is sent, as multiple messages would be required sent in rapid succession (or some additional throttling logic).
- use hybrid format combining JSON format for the text and metadata part of the message, and binary format for the image previews and profile pictures.
- use some other binary format, e.g. MessagePack or CBOR.
- compress messages, e.g. using zstd.
The latter option looks attractive, as it avoids the complexities and downsides of the other options, while allows to provide the space for additional cryptographic data in the message headers without reducing image quality.
## Hybrid format
Currently we support two formats in chat protocol messages:
- JSON, which is identified by the first byte of the message being `{` or `[` (for batch of multiple messages sent in a single block, e.g. when message history or group introductions are sent).
- deprecated binary format for small chunks of files sent via SMP protocol that is only used for small voice messages, and only in case the user disables local file encryption. This format is identified by the first byte of the message being `F` (for "file").
The proposed format would use this format, using ABNF notation:
```abnf
hybridMessage = jsonPart "," binaryPart
jsonPart = "J" partLen ":" json
binaryPart = "B" partLen ":" 1*OCTET
partLen = 1*DIGIT
```
We could use a standart multipart format, but it seems unnecessarily generic and complex, and also more wasteful.
This syntax is sufficiently generic and extensible, and can be used for messages with more than two parts if necessary.
The downside is that it is ad-hoc, and does not achieve any possible reduction for other binary fields in JSON.
## MessagePack or CBOR
Using [MessagePack](https://github.com/msgpack/msgpack/blob/master/spec.md) or CBOR instead of JSON is another possible option, as it's both compact and efficient. While it's not human readable, it will result not only in more efficient binary encoding for images, but for all fields (such as hashes and member IDs, for example), and will also result in more efficient batching of small messages.
As we need to maintain backwards compatibility, we need to recognise MessagePack format, and as it has no distinct first byte of its own, we could use some fixed letter for it, e.g. `X` (we use `x` as a namespace prefix for all protocol message types).
The downside is implementation complexity, particularly given that historically use different binary encoding in JSON (base64 for images and base64url for other binary fields), so we would have to do one of the following:
- maintain two different encodings for all types that are sent between the clients, some of these encodings are manual.
- implement alternative AST for extended JSON format with binary support.
- in either case, types representing binary data would have to support decoding from both JSON strings and from binary data.
## Message compression
This might be the simplest option, as it does not require any changes to protocol encoding other than adding compression and have a different encoding for batching that can be the same as for SMP protocol (we could reuse the same function). The syntax for chat packet would be:
```abnf
chatPacket = %s'X' count 1*(message)
count = OCTET ; up to 255 messages in the batch
message = length compressedMessage
length = 2*2 OCTET ; length of compressed message, up to 65535 bytes (we have less than 15000 bytes limit).
```
After the character 'X' the encoding is the same as for SMP batches, so the same function can be used.
## Additional considerations
We do not just send images as base64 binary, we use web format for images, with the different prefixes for jpg and png images. In the client UIs though we simply discard these prefixes, and they are not processed. So we could use pure binary format for image data without any prefix.
We also use base64 format with prefix to send images in the API. We could continue using it, or we could remove prefix from the API and use plain base64.
base64 format with prefix also saved to the database, so we would need to parse and strip the prefix. Alternatively, we could do some data migration (it is likely to be slow) or export images to storage, at least for profile images - that would reduce overhead. This is not directly related to the problem we are solving here, and can be considered separately.

View File

@@ -1,5 +1,5 @@
name: simplex-chat
version: 5.5.0.4
version: 5.5.1.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme

View File

@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 5.5.0.4
version: 5.5.1.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,6 @@ import Database.SQLite.Simple.QQ (sql)
m20220302_profile_images :: Query
m20220302_profile_images =
[sql|
ALTER TABLE contact_profiles ADD COLUMN image TEXT;
ALTER TABLE group_profiles ADD COLUMN image TEXT;
ALTER TABLE contact_profiles ADD COLUMN image TEXT; -- currently saved as BLOB
ALTER TABLE group_profiles ADD COLUMN image TEXT; -- currently saved as BLOB
|]

View File

@@ -20,6 +20,7 @@ import qualified Data.ByteArray as BA
import qualified Data.ByteString.Base64.URL as U
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Functor (($>))
import Data.List (find)
import qualified Data.List.NonEmpty as L
@@ -94,6 +95,8 @@ foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CStrin
foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString
foreign export ccall "chat_json_length" cChatJsonLength :: CString -> IO CInt
foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CString
foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
@@ -176,6 +179,10 @@ cChatPasswordHash cPwd cSalt = do
cChatValidName :: CString -> IO CString
cChatValidName cName = newCString . mkValidName =<< peekCString cName
-- | returns length of JSON encoded string
cChatJsonLength :: CString -> IO CInt
cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s
mobileChatOpts :: String -> ChatOpts
mobileChatOpts dbFilePrefix =
ChatOpts
@@ -264,9 +271,18 @@ chatSendRemoteCmd :: ChatController -> Maybe RemoteHostId -> B.ByteString -> IO
chatSendRemoteCmd cc rh s = J.encode . APIResponse Nothing rh <$> runReaderT (execChatCommand rh s) cc
chatRecvMsg :: ChatController -> IO JSONByteString
chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ)
chatRecvMsg ChatController {outputQ} = json <$> readChatResponse
where
json (corr, remoteHostId, resp) = J.encode APIResponse {corr, remoteHostId, resp}
readChatResponse = do
out@(_, _, cr) <- atomically $ readTBQueue outputQ
if filterEvent cr then pure out else readChatResponse
filterEvent = \case
CRGroupSubscribed {} -> False
CRGroupEmpty {} -> False
CRMemberSubSummary {} -> False
CRPendingSubSummary {} -> False
_ -> True
chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString
chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc)

View File

@@ -22,6 +22,7 @@
module Simplex.Chat.Types where
import Control.Applicative (optional, (<|>))
import Crypto.Number.Serialize (os2ip)
import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=))
import qualified Data.Aeson as J
@@ -29,10 +30,12 @@ import qualified Data.Aeson.Encoding as JE
import qualified Data.Aeson.TH as JQ
import qualified Data.Aeson.Types as JT
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString, pack, unpack)
import qualified Data.ByteString.Char8 as B
import Data.Int (Int64)
import Data.Maybe (isJust)
import Data.String (IsString (..))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
@@ -49,7 +52,7 @@ import Simplex.FileTransfer.Description (FileDigest)
import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId)
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Parsers (base64P, defaultJSON, dropPrefix, enumJSON, fromTextField_, parseString, sumTypeJSON, taggedObjectJSON)
import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI)
import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>))
import Simplex.Messaging.Version
@@ -523,19 +526,39 @@ data GroupProfile = GroupProfile
}
deriving (Eq, Show)
newtype ImageData = ImageData Text
newtype ImageData = ImageData ByteString
deriving (Eq, Show)
-- The encoding does not add prefix, as the old clients do not validate that it is present.
-- The decoding strips an optional prefix, as the old clients would have it added in the UI.
instance StrEncoding ImageData where
strEncode (ImageData d) = B64.encode d
strP = ImageData <$> (optional ("data:image/jpg;base64," <|> "data:image/png;base64,") *> base64P)
instance IsString ImageData where
fromString = parseString strDecode
instance FromJSON ImageData where
parseJSON = fmap ImageData . J.parseJSON
parseJSON = strParseJSON "ImageData"
instance ToJSON ImageData where
toJSON (ImageData t) = J.toJSON t
toEncoding (ImageData t) = J.toEncoding t
toJSON = strToJSON
toEncoding = strToJEncoding
-- The encoder saves image data is binary blob.
-- We can do it because SQLite supports different value types in the same column.
instance ToField ImageData where toField (ImageData t) = toField t
instance FromField ImageData where fromField = fmap ImageData . fromField
-- The decoder supports both the old Text format, with and without prefix, and the new binary format too.
-- We need to support Text format without prefix as the old clients can receive Text without prefix
-- from the new clients in JSON and store it in the database as Text.
instance FromField ImageData where
fromField f@(Field r _) = case r of
SQLBlob b -> Ok $ ImageData b
SQLText t -> case strDecode $ encodeUtf8 t of
Right d -> Ok d
Left e -> returnError ConversionFailed f ("couldn't parse field ImageData: " ++ e)
_ -> returnError ConversionFailed f "expecting SQLBlob or SQLText column type"
data CReqClientData = CRDataGroup {groupLinkId :: GroupLinkId}

View File

@@ -1294,7 +1294,7 @@ viewUserProfileUpdated Profile {displayName = n, fullName, image, contactLink, p
viewUserProfileImage :: Profile -> [StyledString]
viewUserProfileImage Profile {image} = case image of
Just (ImageData img) -> ["Profile image:", plain img]
Just img -> ["Profile image:", plain $ strEncode img]
_ -> ["No profile image"]
viewUserContactPrefsUpdated :: User -> Contact -> Contact -> [StyledString]

View File

@@ -1,5 +1,6 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PostfixOperators #-}
{-# LANGUAGE ScopedTypeVariables #-}
module ChatTests.Groups where
@@ -8,7 +9,7 @@ import ChatTests.Utils
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Monad (void, when)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as B
import Data.List (isInfixOf)
import qualified Data.Text as T
import Simplex.Chat.Controller (ChatConfig (..), XFTPFileConfig (..))
@@ -16,6 +17,7 @@ import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
import Simplex.Chat.Types (GroupMemberRole (..))
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Version
import System.Directory (copyFile)
import System.FilePath ((</>))
@@ -3046,6 +3048,7 @@ testGroupLinkNoContactHostProfileReceived =
testChat2 aliceProfile bobProfile $
\alice bob -> do
let profileImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
profileImageData = either error id $ strDecode $ B.pack profileImage
alice ##> ("/set profile image " <> profileImage)
alice <## "profile image updated"
@@ -3067,7 +3070,7 @@ testGroupLinkNoContactHostProfileReceived =
threadDelay 100000
aliceImage <- getProfilePictureByName bob "alice"
aliceImage `shouldBe` Just profileImage
aliceImage `shouldBe` Just profileImageData
testGroupLinkNoContactExistingContactMerged :: HasCallStack => FilePath -> IO ()
testGroupLinkNoContactExistingContactMerged =

View File

@@ -116,11 +116,11 @@ testUpdateProfileImage =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
alice ##> "/set profile image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
alice ##> "/set profile image iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
alice <## "profile image updated"
alice ##> "/show profile image"
alice <## "Profile image:"
alice <## "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
alice <## "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
alice ##> "/delete profile image"
alice <## "profile image removed"
alice ##> "/show profile image"

View File

@@ -49,7 +49,7 @@ aliceProfile :: Profile
aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing, contactLink = Nothing, preferences = defaultPrefs}
bobProfile :: Profile
bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ImageData "data:image/png;base64,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"), contactLink = Nothing, preferences = defaultPrefs}
bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just "data:image/png;base64,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", contactLink = Nothing, preferences = defaultPrefs}
cathProfile :: Profile
cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Nothing, contactLink = Nothing, preferences = defaultPrefs}
@@ -483,7 +483,7 @@ createCCNoteFolder cc =
withCCUser cc $ \user ->
runExceptT (createNoteFolder db user) >>= either (fail . show) pure
getProfilePictureByName :: TestCC -> String -> IO (Maybe String)
getProfilePictureByName :: TestCC -> String -> IO (Maybe ImageData)
getProfilePictureByName cc displayName =
withTransaction (chatStore $ chatController cc) $ \db ->
maybeFirstRow fromOnly $

View File

@@ -68,6 +68,8 @@ mobileTests = do
it "no exception on missing file" testMissingFileEncryptionCApi
describe "validate name" $ do
it "should convert invalid name to a valid name" testValidNameCApi
describe "JSON length" $ do
it "should compute length of JSON encoded string" testChatJsonLengthCApi
noActiveUser :: LB.ByteString
noActiveUser =
@@ -222,8 +224,6 @@ testChatApi tmp = do
chatSendCmd cc "/_start" `shouldReturn` chatStarted
chatRecvMsg cc `shouldReturn` networkStatuses
chatRecvMsg cc `shouldReturn` userContactSubSummary
chatRecvMsg cc `shouldReturn` memberSubSummary
chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary
chatRecvMsgWait cc 10000 `shouldReturn` ""
chatParseMarkdown "hello" `shouldBe` "{}"
chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown
@@ -356,6 +356,13 @@ testValidNameCApi _ = do
cName2 <- cChatValidName =<< newCString " @'Джон' Доу 👍 "
peekCString cName2 `shouldReturn` goodName
testChatJsonLengthCApi :: FilePath -> IO ()
testChatJsonLengthCApi _ = do
cInt1 <- cChatJsonLength =<< newCString "Hello!"
cInt1 `shouldBe` 6
cInt2 <- cChatJsonLength =<< newCString "こんにちは!"
cInt2 `shouldBe` 18
jDecode :: FromJSON a => String -> IO (Maybe a)
jDecode = pure . J.decode . LB.pack

View File

@@ -100,7 +100,7 @@ testGroupPreferences :: Maybe GroupPreferences
testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn}, files = Nothing, fullDelete = Nothing, history = Nothing}
testProfile :: Profile
testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), contactLink = Nothing, preferences = testChatPreferences}
testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=", contactLink = Nothing, preferences = testChatPreferences}
testGroupProfile :: GroupProfile
testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, image = Nothing, groupPreferences = testGroupPreferences}
@@ -118,13 +118,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
#==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing Nothing (Just True)))
it "x.msg.new simple link" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing))
it "x.msg.new simple image" $
==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = "iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing))
it "x.msg.new simple link (no image prefix)" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = "iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing))
it "x.msg.new simple image (with impage prefix)" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCImage "" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing))
==# XMsgNew (MCSimple (extMsgContent (MCImage "" "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing))
it "x.msg.new simple image (no impage prefix)" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"image\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCImage "" "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing))
it "x.msg.new simple image with text" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"here's an image\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCImage "here's an image" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing))
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"here's an image\",\"type\":\"image\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCImage "here's an image" "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing))
it "x.msg.new chat message" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)))
@@ -208,23 +214,26 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.file.cancel" $
"{\"v\":\"1\",\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}"
#==# XFileCancel (SharedMsgId "\1\2\3\4")
it "x.info" $
it "x.info (with image prefix)" $
"{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
==# XInfo testProfile
it "x.info (no image prefix)" $
"{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XInfo testProfile
it "x.info with empty full name" $
"{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing, contactLink = Nothing, preferences = testChatPreferences}
it "x.contact with xContactId" $
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XContact testProfile (Just $ XContactId "\1\2\3\4")
it "x.contact without XContactId" $
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XContact testProfile Nothing
it "x.contact with content null" $
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
==# XContact testProfile Nothing
it "x.contact with content (ignored)" $
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
==# XContact testProfile Nothing
it "x.grp.inv" $
"{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
@@ -235,20 +244,23 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.grp.acpt without incognito profile" $
"{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
#==# XGrpAcpt (MemberId "\1\2\3\4")
it "x.grp.mem.new" $
it "x.grp.mem.new (with image prefix)" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile}
it "x.grp.mem.new (no image prefix)" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile}
it "x.grp.mem.new with member chat version range" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile}
it "x.grp.mem.intro" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing
it "x.grp.mem.intro with member chat version range" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing
it "x.grp.mem.intro with member restrictions" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} (Just MemberRestrictions {restriction = MRSBlocked})
it "x.grp.mem.inv" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
@@ -257,13 +269,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
it "x.grp.mem.fwd" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-7\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
it "x.grp.mem.info" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
"{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile
it "x.grp.mem.con" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}"

View File

@@ -250,5 +250,7 @@
"stable-versions-built-by-f-droid-org": "Stable versions built by F-Droid.org",
"releases-to-this-repo-are-done-1-2-days-later": "The releases to this repo are done 1-2 days later",
"f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat and F-Droid.org repositories sign builds with the different keys. To switch, please <a href='/docs/guide/chat-profiles.html#move-your-chat-profiles-to-another-device'>export</a> the chat database and re-install the app.",
"jobs": "Join team"
"jobs": "Join team",
"please-enable-javascript": "Please enable JavaScript to see the QR code.",
"please-use-link-in-mobile-app": "Please use the link in the mobile app"
}

View File

@@ -0,0 +1,20 @@
<p><strong>SimpleX Chat infrastructure on Linode:</strong></p>
<ul class="mb-[12px]">
<li>free infrastructure.</li>
<li>SimpleX servers in Linode Marketplace.</li>
<li>high capacity messaging servers.</li>
</ul>
<p><strong>v5.5 is released:</strong></p>
<ul class="mb-[12px]">
<li>private notes.</li>
<li>group history</li>
<li>simpler UX to connect to other people</li>
<li>message delivery, battery usage and other improvements</li>
</ul>
<p>Also, the app interface is now available in Hungarian and Turkish - thanks to our users.</p>
<p>SimpleX Chat Android and desktop apps are now available in 20 languages!</p>

View File

@@ -30,8 +30,12 @@
<div class="absolute mt-[-100px]">
<img class="" src="/img/new/contact_page_mobile.png" alt="">
</div>
<noscript class="z-10 flex flex-col items-center pt-[40px] ml-[-15px]">
<p class="text-2xl font-medium text-center max-w-[234px] mb-32">{{ "please-enable-javascript" | i18n({}, lang ) | safe }}</p>
</noscript>
<div class="z-10 flex flex-col items-center pt-[40px] ml-[-15px]">
<div class="z-10 flex flex-col items-center pt-[40px] ml-[-15px] d-none-if-js-disabled">
<p class="text-base font-medium text-center max-w-[234px]">{{ "scan-qr-code-from-mobile-app" | i18n({}, lang ) | safe }}</p>
<canvas class="conn_req_uri_qrcode"></canvas>
</div>
@@ -61,7 +65,11 @@
</div>
<div class="flex flex-col justify-center items-center w-full max-w-[468px] h-[131px] rounded-[30px] border-[1px] border-[#A8B0B4] dark:border-white border-opacity-60 mb-6 relative">
<p class="text-xl font-medium text-grey-black dark:text-white mb-4">{{ "connect-in-app" | i18n({}, lang ) | safe }}</p>
<p class="text-xl font-medium text-grey-black dark:text-white mb-4 d-none-if-js-disabled">{{ "connect-in-app" | i18n({}, lang ) | safe }}</p>
<noscript>
<p class="text-xl font-medium text-grey-black dark:text-white mb-4">{{ "please-use-link-in-mobile-app" | i18n({}, lang ) | safe }}</p>
</noscript>
<a id="mobile_conn_req_uri" class="bg-[#0053D0] text-white py-3 px-8 rounded-[34px] h-[44px] text-[16px] leading-[19px] tracking-[0.02em]">{{ "open-simplex-app" | i18n({}, lang ) | safe }}</a>
<div class="absolute bg-[#0197FF] h-[44px] w-[44px] rounded-full flex items-center justify-center top-0 left-0 translate-x-[-30%] translate-y-[-30%]">
@@ -69,7 +77,7 @@
</div>
</div>
<div class="flex flex-col justify-center items-center w-full max-w-[468px] h-[131px] rounded-[30px] border-[1px] border-[#A8B0B4] dark:border-white border-opacity-60 relative">
<div class="flex flex-col justify-center items-center w-full max-w-[468px] h-[131px] rounded-[30px] border-[1px] border-[#A8B0B4] dark:border-white border-opacity-60 relative d-none-if-js-disabled">
<p class="text-xl font-medium text-grey-black dark:text-white max-w-[230px] text-center">{{ "tap-the-connect-button-in-the-app" | i18n({}, lang ) | safe }}</p>
<div class="absolute bg-[#0197FF] h-[44px] w-[44px] rounded-full flex items-center justify-center top-0 left-0 translate-x-[-30%] translate-y-[-30%]">
@@ -81,7 +89,7 @@
</section>
<section class="hidden md:block bg-secondary-bg-light dark:bg-secondary-bg-dark py-[20px]">
<section class="hidden md:block bg-secondary-bg-light dark:bg-secondary-bg-dark py-[20px] d-none-if-js-disabled">
<div class="container px-5">
<div class="text-grey-black dark:text-white">
@@ -164,3 +172,7 @@
{# join simplex #}
{% include "sections/join_simplex.html" %}
<script>
document.querySelectorAll('.d-none-if-js-disabled').forEach(el => el.classList.remove('d-none-if-js-disabled'));
</script>

View File

@@ -957,3 +957,7 @@ p a{
top: calc(66px + 2rem);
}
}
.d-none-if-js-disabled{
display: none !important;
}