diff --git a/README.md b/README.md index 91b015f7f..f6630ebbf 100644 --- a/README.md +++ b/README.md @@ -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.). diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 24a77cb3d..d1a16f73a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index dfa4a97fc..cb0b61f53 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -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)" diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index af53e7e47..35caf655e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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()) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index b59792609..604e0a276 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -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) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 3879e78d3..dbea6a17e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -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: { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index e5ff644a9..d6dbf06ef 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -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) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 186a709ce..8bfc8fec0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -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" diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index ab069f24c..c0bb29892 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -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]? } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ff61a51d3..198a777f8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -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 ) } diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index c49d10451..153365424 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -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); diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 8d64ae3c8..7a1299c61 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -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() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index f9c2eac13..e9f28a8ea 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -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) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index dd760e0b1..903f09608 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -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) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index d360c44b4..1e8fe94bf 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -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 diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.android.kt new file mode 100644 index 000000000..18f3455e3 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.android.kt @@ -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, + timeUnitsLimits: List +) { + fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List { + 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 = 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 + } + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 5936bd5ff..d0581b433 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -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); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index f15689285..90504e25c 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -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); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index bfff3bf9f..57959af4c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index d44c80e92..c19abdccc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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(false) val chatDbStatus = mutableStateOf(null) val ctrlInitInProgress = mutableStateOf(false) + val dbMigrationInProgress = mutableStateOf(false) val chats = mutableStateListOf() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() @@ -55,7 +57,7 @@ object ChatModel { // current chat val chatId = mutableStateOf(null) - val chatItems = mutableStateListOf() + val chatItems = mutableStateOf(SnapshotStateList()) // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) val chatItemStatuses = mutableMapOf() @@ -63,8 +65,6 @@ object ChatModel { val terminalItems = mutableStateOf>(listOf()) val userAddress = mutableStateOf(null) - // Allows to temporary save servers that are being edited on multiple screens - val userSMPServersUnsaved = mutableStateOf<(List)?>(null) val chatItemTTL = mutableStateOf(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>.add(index: Int, chatItem: ChatItem) { + value = SnapshotStateList().apply { addAll(value); add(index, chatItem) } +} + +fun MutableState>.add(chatItem: ChatItem) { + value = SnapshotStateList().apply { addAll(value); add(chatItem) } +} + +fun MutableState>.addAll(index: Int, chatItems: List) { + value = SnapshotStateList().apply { addAll(value); addAll(index, chatItems) } +} + +fun MutableState>.addAll(chatItems: List) { + value = SnapshotStateList().apply { addAll(value); addAll(chatItems) } +} + +fun MutableState>.removeAll(block: (ChatItem) -> Boolean) { + value = SnapshotStateList().apply { addAll(value); removeAll(block) } +} + +fun MutableState>.removeAt(index: Int) { + value = SnapshotStateList().apply { addAll(value); removeAt(index) } +} + +fun MutableState>.removeLast() { + value = SnapshotStateList().apply { addAll(value); removeLast() } +} + +fun MutableState>.replaceAll(chatItems: List) { + value = SnapshotStateList().apply { addAll(chatItems) } +} + +fun MutableState>.clear() { + value = SnapshotStateList() +} + +fun State>.asReversed(): MutableList = value.asReversed() + +val State>.size: Int get() = value.size + enum class CIMergeCategory { MemberConnected, RcvGroupEvent, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index ec81e5441..63fcb90bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -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 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 = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value) - val res: DBMigrationResult = kotlin.runCatching { + var migrated: Array = chatMigrateInit(dbAbsolutePrefixPath, dbKey, MigrationConfirmation.Error.value) + var res: DBMigrationResult = runCatching { json.decodeFromString(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(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 } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index a75ee7590..57c1e578a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -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? diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index a46821452..4347623bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -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) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 2e99d791b..9a92997f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -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, attachmentBottomSheetState: ModalBottomSheetState, - chatItems: List, searchValue: State, 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, composeState: MutableState, - chatItems: List, searchValue: State, 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) { +private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State>) { 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, + chatItems: State>, unreadCount: State, minUnreadItemId: Long, searchValue: State, @@ -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(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), - chatItems = chatItems, searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, @@ -1568,7 +1564,6 @@ fun PreviewGroupChatLayout() { composeView = {}, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), - chatItems = chatItems, searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index e5982d01d..534185429 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -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) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index f1079d2f5..456e2a538 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -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, sendMessage: (Int?) -> Unit, - setShowDialog: (Boolean) -> Unit, customDisappearingMessageTimePref: SharedPreference? ) { - 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 } + ) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index e4f31748c..6add33d83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -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 { .map { it.chatInfo } .filterIsInstance() .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() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 54614d022..6759d5474 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -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() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 9124eed4c..6c2c37503 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -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, groupInfo: GroupInfo): Boolean { + return welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null) +} + +private fun welcomeTextFitsLimit(welcomeText: MutableState): Boolean { + return chatJsonLength(welcomeText.value) <= maxByteCount +} + @Composable private fun GroupWelcomeLayout( welcomeText: MutableState, @@ -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, + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 549f5f2f5..568f00302 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -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 = 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), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index f7783d682..0e2e8867c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -91,7 +91,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 15b1db6c4..2324d62ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 08e95f391..1bb5a7899 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -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, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 3cfd9e94b..7bd9fbc66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -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) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 2d644b297..8680c98d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -368,7 +368,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String { } fun startChat(m: ChatModel, chatLastStart: MutableState, chatDbChanged: MutableState, progressIndicator: MutableState? = 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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 082d73320..a4cea68ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -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( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt index f13edd618..3c44cbb4d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt @@ -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, timeUnitsLimits: List = TimeUnitLimits.defaultUnitsLimits -) { - fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List { - 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 = 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, timeUnitsLimits: List = 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 = remember { mutableStateOf(DropdownSelection.DropdownValue(selection.value)) } val values: MutableState> = 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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt index ec2500ab2..104a01150 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt @@ -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) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index f41d21764..e2dd315fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -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) }) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index ba0edb98d..1c3540d7f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -198,16 +198,16 @@ fun 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 1048b03bc..5a37c860a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 854770489..9ae34eb18 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index 7c921d7e8..66b4a0e83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -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, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt index e1668ab9a..ad1648f1e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt @@ -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()) } - var servers by remember(rhId) { - mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList()) - } + var servers by remember { stateGetOrPut("servers") { emptyList() } } + 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() } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index d36eca273..a511e2e13 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -16,6 +16,7 @@ Opening database… + Database migration is in progress.\nIt may take a few minutes. Invalid file path You shared an invalid file path. Report the issue to the app developers. View crashed @@ -1377,9 +1378,11 @@ Welcome message Save welcome message? + Welcome message is too long Save and update group profile Preview Enter welcome message… + Message too large SERVERS diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 6e7945d8c..41e87b4a1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -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 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt new file mode 100644 index 000000000..03c8e51c5 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt @@ -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, + timeUnitsLimits: List +) { + 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>() + 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>() + 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 + } + ) + } +} diff --git a/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md b/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md new file mode 100644 index 000000000..43c502d8c --- /dev/null +++ b/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md @@ -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 + + + +*"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 + + + +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 + + + +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 diff --git a/blog/README.md b/blog/README.md index 815009139..3afa61eef 100644 --- a/blog/README.md +++ b/blog/README.md @@ -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. 🔗 diff --git a/blog/images/20240124-connect1.png b/blog/images/20240124-connect1.png new file mode 100644 index 000000000..9b5a3cd1f Binary files /dev/null and b/blog/images/20240124-connect1.png differ diff --git a/blog/images/20240124-connect2.png b/blog/images/20240124-connect2.png new file mode 100644 index 000000000..fda81efb2 Binary files /dev/null and b/blog/images/20240124-connect2.png differ diff --git a/blog/images/20240124-history1.png b/blog/images/20240124-history1.png new file mode 100644 index 000000000..fbba77b5f Binary files /dev/null and b/blog/images/20240124-history1.png differ diff --git a/blog/images/20240124-history2.png b/blog/images/20240124-history2.png new file mode 100644 index 000000000..a54992bb4 Binary files /dev/null and b/blog/images/20240124-history2.png differ diff --git a/blog/images/20240124-notes1.png b/blog/images/20240124-notes1.png new file mode 100644 index 000000000..4c253b470 Binary files /dev/null and b/blog/images/20240124-notes1.png differ diff --git a/blog/images/20240124-notes2.png b/blog/images/20240124-notes2.png new file mode 100644 index 000000000..82c6b18eb Binary files /dev/null and b/blog/images/20240124-notes2.png differ diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index a646645d7..042973176 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -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). diff --git a/package.yaml b/package.yaml index 9eba9918e..f170e3d7e 100644 --- a/package.yaml +++ b/package.yaml @@ -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 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b1fd924ae..36dc080b2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 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 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 564db9b42..dcd392629 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1028,6 +1028,7 @@ processChatCommand' vr = \case when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections user members + updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members @@ -1686,17 +1687,9 @@ processChatCommand' vr = \case createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted - updateCIGroupInvitationStatus user + updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` \_ -> pure () pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct - where - updateCIGroupInvitationStatus user = do - AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db vr user groupId - case (cInfo, content) of - (DirectChat ct, CIRcvGroupInvitation ciGroupInv memRole) -> do - let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = CIGISAccepted} memRole - updateDirectChatItemView user ct itemId aciContent False Nothing - _ -> pure () -- prohibited APIMemberRole groupId memberId memRole -> withUser $ \user -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId if memberId == groupMemberId' membership @@ -2512,6 +2505,14 @@ processChatCommand' vr = \case cReqHashes :: (ConnReqUriHash, ConnReqUriHash) cReqHashes = bimap hash hash cReqSchemas hash = ConnReqUriHash . C.sha256Hash . strEncode + updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do + AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db vr user groupId + case (cInfo, content) of + (DirectChat ct, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) + | status == CIGISPending -> do + let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = newStatus} memRole + updateDirectChatItemView user ct itemId aciContent False Nothing + _ -> pure () -- prohibited toggleNtf :: ChatMonad m => User -> GroupMember -> Bool -> m () toggleNtf user m ntfOn = diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 7c74a7325..105dedb32 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -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) diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 0ed1b30f5..f41d0172d 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -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 diff --git a/website/langs/en.json b/website/langs/en.json index 1bb64c7ef..10db2dd4e 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -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 export 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" } diff --git a/website/src/_includes/blog_previews/20240124.html b/website/src/_includes/blog_previews/20240124.html new file mode 100644 index 000000000..799e75426 --- /dev/null +++ b/website/src/_includes/blog_previews/20240124.html @@ -0,0 +1,20 @@ +

SimpleX Chat infrastructure on Linode:

+ +
    +
  • free infrastructure.
  • +
  • SimpleX servers in Linode Marketplace.
  • +
  • high capacity messaging servers.
  • +
+ +

v5.5 is released:

+ +
    +
  • private notes.
  • +
  • group history
  • +
  • simpler UX to connect to other people
  • +
  • message delivery, battery usage and other improvements
  • +
+ +

Also, the app interface is now available in Hungarian and Turkish - thanks to our users.

+ +

SimpleX Chat Android and desktop apps are now available in 20 languages!

\ No newline at end of file diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index 6beb148f8..b5f7442a7 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -30,8 +30,12 @@
+ + -
+

{{ "scan-qr-code-from-mobile-app" | i18n({}, lang ) | safe }}

@@ -61,7 +65,11 @@
-

{{ "connect-in-app" | i18n({}, lang ) | safe }}

+

{{ "connect-in-app" | i18n({}, lang ) | safe }}

+ + {{ "open-simplex-app" | i18n({}, lang ) | safe }}
@@ -69,7 +77,7 @@
-
+

{{ "tap-the-connect-button-in-the-app" | i18n({}, lang ) | safe }}

@@ -81,7 +89,7 @@ -