From 6ff5e7cf234353826dc368d5e9effd7c24bfe76e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 22 Dec 2024 10:23:45 +0000 Subject: [PATCH 01/95] readme: update --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ad70c350e4..936667da8c 100644 --- a/README.md +++ b/README.md @@ -163,13 +163,14 @@ Your donations help us raise more funds - any amount, even the price of the cup It is possible to donate via: - [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission). -- Bitcoin: bc1qd74rc032ek2knhhr3yjq2ajzc5enz3h4qwnxad -- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt +- BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u +- XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg -- Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 -- USDT: - - Ethereum: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 -- Solana: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu +- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 +- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 +- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg +- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf +- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu - please ask if you want to donate any other coins. Thank you, From bcdf08488ec2e1837db94696a06b7c85aafd74d0 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 22 Dec 2024 23:18:45 +0700 Subject: [PATCH 02/95] ios: show alert when import database is failed or succeeded (#5400) * ios: show alert when import database is failed or succeeded * don't hide error alert until pressing Ok * always skip starting chat in case of import error * changes * defer --- .../Shared/Views/Database/DatabaseView.swift | 19 ++++++++++++------- .../Views/Migration/MigrateToDevice.swift | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 4a367f7722..4c05434eb6 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -262,8 +262,7 @@ struct DatabaseView: View { message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."), primaryButton: .destructive(Text("Import")) { stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) { - _ = await DatabaseView.importArchive(fileURL, $progressIndicator, $alert) - return true + await DatabaseView.importArchive(fileURL, $progressIndicator, $alert, false) } }, secondaryButton: .cancel() @@ -467,9 +466,13 @@ struct DatabaseView: View { static func importArchive( _ archivePath: URL, _ progressIndicator: Binding, - _ alert: Binding + _ alert: Binding, + _ migration: Bool ) async -> Bool { if archivePath.startAccessingSecurityScopedResource() { + defer { + archivePath.stopAccessingSecurityScopedResource() + } await MainActor.run { progressIndicator.wrappedValue = true } @@ -483,17 +486,17 @@ struct DatabaseView: View { _ = kcDatabasePassword.remove() if archiveErrors.isEmpty { await operationEnded(.archiveImported, progressIndicator, alert) + return true } else { await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors), progressIndicator, alert) + return migration } - return true } catch let error { await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert) } } catch let error { await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert) } - archivePath.stopAccessingSecurityScopedResource() } else { showAlert("Error accessing database file") } @@ -542,6 +545,8 @@ struct DatabaseView: View { } else if case .chatDeleted = dbAlert { let (title, message) = chatDeletedAlertText() showAlert(title, message: message, actions: { [okAlertActionWaiting] }) + } else if case let .error(title, error) = dbAlert { + showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] }) } else { alert.wrappedValue = dbAlert cont.resume() @@ -587,13 +592,13 @@ struct DatabaseView: View { } } -private func archiveImportedAlertText() -> (String, String) { +func archiveImportedAlertText() -> (String, String) { ( NSLocalizedString("Chat database imported", comment: ""), NSLocalizedString("Restart the app to use imported chat database", comment: "") ) } -private func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) { +func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) { ( NSLocalizedString("Chat database imported", comment: ""), NSLocalizedString("Restart the app to use imported chat database", comment: "") + "\n" + NSLocalizedString("Some non-fatal errors occurred during import:", comment: "") + archiveErrorsText(errs) diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 763cd473fe..2d83cdc7c8 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -96,6 +96,7 @@ struct MigrateToDevice: View { @Binding var migrationState: MigrationToState? @State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var alert: MigrateToDeviceViewAlert? + @State private var databaseAlert: DatabaseAlert? = nil private let tempDatabaseUrl = urlForTemporaryDatabase() @State private var chatReceiver: MigrationChatReceiver? = nil // Prevent from hiding the view until migration is finished or app deleted @@ -178,6 +179,20 @@ struct MigrateToDevice: View { return Alert(title: Text(title), message: Text(error)) } } + .alert(item: $databaseAlert) { item in + switch item { + case .archiveImported: + let (title, message) = archiveImportedAlertText() + return Alert(title: Text(title), message: Text(message)) + case let .archiveImportedWithErrors(errs): + let (title, message) = archiveImportedWithErrorsAlertText(errs: errs) + return Alert(title: Text(title), message: Text(message)) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + default: // not expected this branch to be called because this alert is used only for importArchive purpose + return Alert(title: Text("Error")) + } + } .interactiveDismissDisabled(backDisabled) } @@ -243,7 +258,7 @@ struct MigrateToDevice: View { ) { result in if case let .success(files) = result, let fileURL = files.first { Task { - let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, Binding.constant(nil)) + let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, $databaseAlert, true) if success { DatabaseView.startChat( Binding.constant(false), From 3fead10ea266b4af416b452621fb9071be041153 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 22 Dec 2024 23:19:05 +0700 Subject: [PATCH 03/95] android, desktop: show alert when import database is failed or succeeded (#5402) --- .../chat/simplex/common/views/database/DatabaseView.kt | 7 ++++--- .../chat/simplex/common/views/migration/MigrateToDevice.kt | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) 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 bf59524a06..28772f01d3 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 @@ -60,8 +60,7 @@ fun DatabaseView() { if (to != null) { importArchiveAlert { stopChatRunBlockStartChat(stopped, chatLastStart, progressIndicator) { - importArchive(to, appFilesCountAndSize, progressIndicator) - true + importArchive(to, appFilesCountAndSize, progressIndicator, false) } } } @@ -645,6 +644,7 @@ suspend fun importArchive( importedArchiveURI: URI, appFilesCountAndSize: MutableState>, progressIndicator: MutableState, + migration: Boolean ): Boolean { val m = chatModel progressIndicator.value = true @@ -666,12 +666,13 @@ suspend fun importArchive( if (chatModel.localUserCreated.value == false) { chatModel.chatRunning.value = false } + return true } else { operationEnded(m, progressIndicator) { showArchiveImportedWithErrorsAlert(archiveErrors) } + return migration } - return true } catch (e: Error) { operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_importing_database), e.toString()) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 788c07a9d2..6baa4952b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -239,7 +239,7 @@ private fun ArchiveImportView(progressIndicator: MutableState, close: ( val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) { withLongRunningApi { - val success = importArchive(to, mutableStateOf(0 to 0), progressIndicator) + val success = importArchive(to, mutableStateOf(0 to 0), progressIndicator, true) if (success) { startChat( chatModel, From 9c87b8782c604a100250aa229e22fd34a5e36ebe Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 22 Dec 2024 23:22:36 +0700 Subject: [PATCH 04/95] android, desktop: update message successfully if it's the same (#5404) --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index e95fdb446f..08051927fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -968,6 +968,7 @@ object ChatController { val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) when { r is CR.ChatItemUpdated -> return r.chatItem + r is CR.ChatItemNotChanged -> return r.chatItem r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.LargeMsg -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.maximum_message_size_title), From 8c90a96d789c51d4d801aeffd1b4912d5061b646 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 22 Dec 2024 23:18:45 +0700 Subject: [PATCH 05/95] ios: show alert when import database is failed or succeeded (#5400) * ios: show alert when import database is failed or succeeded * don't hide error alert until pressing Ok * always skip starting chat in case of import error * changes * defer --- .../Shared/Views/Database/DatabaseView.swift | 19 ++++++++++++------- .../Views/Migration/MigrateToDevice.swift | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 4a367f7722..4c05434eb6 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -262,8 +262,7 @@ struct DatabaseView: View { message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."), primaryButton: .destructive(Text("Import")) { stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) { - _ = await DatabaseView.importArchive(fileURL, $progressIndicator, $alert) - return true + await DatabaseView.importArchive(fileURL, $progressIndicator, $alert, false) } }, secondaryButton: .cancel() @@ -467,9 +466,13 @@ struct DatabaseView: View { static func importArchive( _ archivePath: URL, _ progressIndicator: Binding, - _ alert: Binding + _ alert: Binding, + _ migration: Bool ) async -> Bool { if archivePath.startAccessingSecurityScopedResource() { + defer { + archivePath.stopAccessingSecurityScopedResource() + } await MainActor.run { progressIndicator.wrappedValue = true } @@ -483,17 +486,17 @@ struct DatabaseView: View { _ = kcDatabasePassword.remove() if archiveErrors.isEmpty { await operationEnded(.archiveImported, progressIndicator, alert) + return true } else { await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors), progressIndicator, alert) + return migration } - return true } catch let error { await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert) } } catch let error { await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert) } - archivePath.stopAccessingSecurityScopedResource() } else { showAlert("Error accessing database file") } @@ -542,6 +545,8 @@ struct DatabaseView: View { } else if case .chatDeleted = dbAlert { let (title, message) = chatDeletedAlertText() showAlert(title, message: message, actions: { [okAlertActionWaiting] }) + } else if case let .error(title, error) = dbAlert { + showAlert("\(title)", message: error, actions: { [okAlertActionWaiting] }) } else { alert.wrappedValue = dbAlert cont.resume() @@ -587,13 +592,13 @@ struct DatabaseView: View { } } -private func archiveImportedAlertText() -> (String, String) { +func archiveImportedAlertText() -> (String, String) { ( NSLocalizedString("Chat database imported", comment: ""), NSLocalizedString("Restart the app to use imported chat database", comment: "") ) } -private func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) { +func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) { ( NSLocalizedString("Chat database imported", comment: ""), NSLocalizedString("Restart the app to use imported chat database", comment: "") + "\n" + NSLocalizedString("Some non-fatal errors occurred during import:", comment: "") + archiveErrorsText(errs) diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 763cd473fe..2d83cdc7c8 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -96,6 +96,7 @@ struct MigrateToDevice: View { @Binding var migrationState: MigrationToState? @State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var alert: MigrateToDeviceViewAlert? + @State private var databaseAlert: DatabaseAlert? = nil private let tempDatabaseUrl = urlForTemporaryDatabase() @State private var chatReceiver: MigrationChatReceiver? = nil // Prevent from hiding the view until migration is finished or app deleted @@ -178,6 +179,20 @@ struct MigrateToDevice: View { return Alert(title: Text(title), message: Text(error)) } } + .alert(item: $databaseAlert) { item in + switch item { + case .archiveImported: + let (title, message) = archiveImportedAlertText() + return Alert(title: Text(title), message: Text(message)) + case let .archiveImportedWithErrors(errs): + let (title, message) = archiveImportedWithErrorsAlertText(errs: errs) + return Alert(title: Text(title), message: Text(message)) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + default: // not expected this branch to be called because this alert is used only for importArchive purpose + return Alert(title: Text("Error")) + } + } .interactiveDismissDisabled(backDisabled) } @@ -243,7 +258,7 @@ struct MigrateToDevice: View { ) { result in if case let .success(files) = result, let fileURL = files.first { Task { - let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, Binding.constant(nil)) + let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, $databaseAlert, true) if success { DatabaseView.startChat( Binding.constant(false), From 9bfc861aeacda1b61d2614a39d679028b280ff0c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 23 Dec 2024 22:50:01 +0700 Subject: [PATCH 06/95] android: cancel worker task if the service was disabled (#5410) --- .../main/java/chat/simplex/app/MessagesFetcherWorker.kt | 6 ++++-- .../android/src/main/java/chat/simplex/app/SimplexApp.kt | 8 +++++++- .../src/main/java/chat/simplex/app/SimplexService.kt | 2 ++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt index b18204d905..9264ca6abf 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt @@ -32,8 +32,10 @@ object MessagesFetcherWorker { SimplexApp.context.getWorkManagerInstance().enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest) } - fun cancelAll() { - Log.d(TAG, "Worker: canceled all tasks") + fun cancelAll(withLog: Boolean = true) { + if (withLog) { + Log.d(TAG, "Worker: canceled all tasks") + } SimplexApp.context.getWorkManagerInstance().cancelUniqueWork(UNIQUE_WORK_TAG) } } 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 07685a4526..ee259a98d0 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 @@ -33,6 +33,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* +import kotlinx.coroutines.flow.map import java.io.* import java.util.* import java.util.concurrent.TimeUnit @@ -151,6 +152,7 @@ class SimplexApp: Application(), LifecycleEventObserver { * */ fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch { if (!allowToStartServiceAfterAppExit()) { + getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) return@launch } val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get() @@ -172,6 +174,7 @@ class SimplexApp: Application(), LifecycleEventObserver { fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch { if (!allowToStartPeriodically()) { + MessagesFetcherWorker.cancelAll(withLog = false) return@launch } MessagesFetcherWorker.scheduleWork() @@ -227,7 +230,9 @@ class SimplexApp: Application(), LifecycleEventObserver { SimplexService.safeStopService() } } - + if (mode != NotificationsMode.SERVICE) { + getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) + } if (mode != NotificationsMode.PERIODIC) { MessagesFetcherWorker.cancelAll() } @@ -244,6 +249,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } override fun androidChatStopped() { + getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) SimplexService.safeStopService() MessagesFetcherWorker.cancelAll() } 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 3b9f2ade26..cb50336fce 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 @@ -139,6 +139,7 @@ class SimplexService: Service() { if (chatDbStatus != DBMigrationResult.OK) { Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus") showPassphraseNotification(chatDbStatus) + androidAppContext.getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) safeStopService() return@withLongRunningApi } @@ -681,6 +682,7 @@ class SimplexService: Service() { } ChatController.appPrefs.notificationsMode.set(NotificationsMode.OFF) StartReceiver.toggleReceiver(false) + androidAppContext.getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) MessagesFetcherWorker.cancelAll() safeStopService() } From 39ab56f4943108ef4c4d6204447114eaa58700ed Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 23 Dec 2024 23:30:51 +0700 Subject: [PATCH 07/95] android: starting service/worker after migrating database (#5411) --- .../chat/simplex/common/views/migration/MigrateToDevice.kt | 1 + .../common/views/usersettings/NotificationsSettingsView.kt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 6baa4952b4..1a28bbf589 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -691,6 +691,7 @@ private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit) if (user != null) { startChat(user) } + platform.androidChatStartedAfterBeingOff() hideView(close) AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_chat_migrated), generalGetString(MR.strings.migrate_to_device_finalize_migration)) } catch (e: Exception) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 66b518e9aa..5af5d5fb90 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -78,7 +78,7 @@ fun NotificationsSettingsLayout( ) } if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { - SectionTextFooter(stringResource(MR.strings.xiaomi_ignore_battery_optimization)) + SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) } } SectionBottomSpacer() @@ -95,7 +95,7 @@ fun NotificationsModeView( AppBarTitle(stringResource(MR.strings.settings_notifications_mode_title).lowercase().capitalize(Locale.current)) SectionViewSelectable(null, notificationsMode, modes, onNotificationsModeSelected) if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { - SectionTextFooter(stringResource(MR.strings.xiaomi_ignore_battery_optimization)) + SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) } } } From ba601552d27b82f83edbae83ec82c0f2ef44c58a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 23 Dec 2024 16:31:47 +0000 Subject: [PATCH 08/95] ios: add chat to created list (#5407) * ios: add chat to created list * do not include muted chats in unread tags --- apps/ios/Shared/Model/ChatModel.swift | 16 ++-- apps/ios/Shared/Views/Chat/ChatView.swift | 2 + .../Views/ChatList/ChatListNavLink.swift | 87 +++++++++++-------- .../Shared/Views/ChatList/ChatListView.swift | 2 +- 4 files changed, 60 insertions(+), 47 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 2784551361..31bacd1ba7 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -118,7 +118,7 @@ class ChatTagsModel: ObservableObject { newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1 } } - if chat.isUnread, let tags = chat.chatInfo.chatTags { + if chat.unreadTag, let tags = chat.chatInfo.chatTags { for tag in tags { newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1 } @@ -162,14 +162,14 @@ class ChatTagsModel: ObservableObject { } func markChatTagRead(_ chat: Chat) -> Void { - if chat.isUnread, let tags = chat.chatInfo.chatTags { + if chat.unreadTag, let tags = chat.chatInfo.chatTags { markChatTagRead_(chat, tags) } } func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void { guard let tags = chat.chatInfo.chatTags else { return } - let nowUnread = chat.isUnread + let nowUnread = chat.unreadTag if nowUnread && !wasUnread { for tag in tags { unreadTags[tag] = (unreadTags[tag] ?? 0) + 1 @@ -694,7 +694,7 @@ final class ChatModel: ObservableObject { // update preview let markedCount = chat.chatStats.unreadCount - unreadBelow if markedCount > 0 { - let wasUnread = chat.isUnread + let wasUnread = chat.unreadTag chat.chatStats.unreadCount -= markedCount ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) @@ -709,7 +709,7 @@ final class ChatModel: ObservableObject { func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { _updateChat(cInfo.id) { chat in - let wasUnread = chat.isUnread + let wasUnread = chat.unreadTag chat.chatStats.unreadChat = unreadChat ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) } @@ -847,7 +847,7 @@ final class ChatModel: ObservableObject { } func changeUnreadCounter(_ chatIndex: Int, by count: Int) { - let wasUnread = chats[chatIndex].isUnread + let wasUnread = chats[chatIndex].unreadTag chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread) changeUnreadCounter(user: currentUser!, by: count) @@ -1055,8 +1055,8 @@ final class Chat: ObservableObject, Identifiable, ChatLike { } } - var isUnread: Bool { - chatStats.unreadCount > 0 || chatStats.unreadChat + var unreadTag: Bool { + chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) } var id: ChatId { get { chatInfo.id } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index ac4066d23e..32b4fab291 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2009,6 +2009,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { await MainActor.run { let wasFavorite = chat.chatInfo.chatSettings?.favorite ?? false ChatTagsModel.shared.updateChatFavorite(favorite: chatSettings.favorite, wasFavorite: wasFavorite) + let wasUnread = chat.unreadTag switch chat.chatInfo { case var .direct(contact): contact.chatSettings = chatSettings @@ -2018,6 +2019,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { ChatModel.shared.updateGroup(groupInfo) default: () } + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) } } catch let error { logger.error("apiSetChatSettings error \(responseError(error))") diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 6bf86840a8..117a8fa795 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -568,7 +568,6 @@ struct TagEditorNavParams { struct ChatListTag: View { var chat: Chat? = nil - var showEditButton: Bool = false @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @EnvironmentObject var chatTagsModel: ChatTagsModel @@ -603,7 +602,7 @@ struct ChatListTag: View { .contentShape(Rectangle()) .onTapGesture { if let c = chat { - setTag(tagId: selected ? nil : tagId, chat: c) + setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() } } else { tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) } @@ -665,7 +664,7 @@ struct ChatListTag: View { Label("Create list", systemImage: "plus") } } header: { - if showEditButton { + if chat == nil { editTagsButton() .textCase(nil) .frame(maxWidth: .infinity, alignment: .trailing) @@ -714,36 +713,6 @@ struct ChatListTag: View { } } - private func setTag(tagId: Int64?, chat: Chat) { - Task { - do { - let tagIds: [Int64] = if let t = tagId { [t] } else {[]} - let (userTags, chatTags) = try await apiSetChatTags( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - tagIds: tagIds - ) - - await MainActor.run { - chatTagsModel.userTags = userTags - if var contact = chat.chatInfo.contact { - contact.chatTags = chatTags - m.updateContact(contact) - } else if var group = chat.chatInfo.groupInfo { - group.chatTags = chatTags - m.updateGroup(group) - } - dismiss() - } - } catch let error { - showAlert( - NSLocalizedString("Error saving chat list", comment: "alert title"), - message: responseError(error) - ) - } - } - } - private func deleteTag(_ tagId: Int64) { Task { try await apiDeleteChatTag(tagId: tagId) @@ -767,6 +736,37 @@ struct ChatListTag: View { } } +private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) { + Task { + do { + let tagIds: [Int64] = if let t = tagId { [t] } else {[]} + let (userTags, chatTags) = try await apiSetChatTags( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + tagIds: tagIds + ) + + await MainActor.run { + let m = ChatModel.shared + ChatTagsModel.shared.userTags = userTags + if var contact = chat.chatInfo.contact { + contact.chatTags = chatTags + m.updateContact(contact) + } else if var group = chat.chatInfo.groupInfo { + group.chatTags = chatTags + m.updateGroup(group) + } + closeSheet() + } + } catch let error { + showAlert( + NSLocalizedString("Error saving chat list", comment: "alert title"), + message: responseError(error) + ) + } + } +} + struct EmojiPickerView: UIViewControllerRepresentable { @Binding var selectedEmoji: String? @Binding var showingPicker: Bool @@ -817,11 +817,11 @@ struct EmojiPickerView: UIViewControllerRepresentable { } struct ChatListTagEditor: View { - var chat: Chat? = nil - var tagId: Int64? = nil @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatTagsModel: ChatTagsModel @EnvironmentObject var theme: AppTheme + var chat: Chat? = nil + var tagId: Int64? = nil var emoji: String? var name: String = "" @State private var newEmoji: String? @@ -860,7 +860,13 @@ struct ChatListTagEditor: View { createChatTag() } } label: { - Text(NSLocalizedString(tagId == nil ? "Create list" : "Save list", comment: "list editor button")) + Text( + chat != nil + ? "Add to list" + : tagId == nil + ? "Create list" + : "Save list" + ) } .disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName) } footer: { @@ -893,13 +899,18 @@ struct ChatListTagEditor: View { private func createChatTag() { Task { do { + let text = trimmedName let userTags = try await apiCreateChatTag( - tag: ChatTagData(emoji: newEmoji , text: trimmedName) + tag: ChatTagData(emoji: newEmoji , text: text) ) await MainActor.run { saving = false chatTagsModel.userTags = userTags - dismiss() + } + if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) { + setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() } + } else { + await MainActor.run { dismiss() } } } catch let error { await MainActor.run { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 9cb87a4b22..edf9a3e5d2 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -717,7 +717,7 @@ struct ChatTagsView: View { content: { AnyView( NavigationView { - ChatListTag(chat: nil, showEditButton: true) + ChatListTag(chat: nil) .modifier(ThemedBackground(grouped: true)) } ) From 9e2e4722a32b22040f3a249a20a254c6394c70c5 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 24 Dec 2024 20:23:22 +0700 Subject: [PATCH 09/95] android: start/stop service in migration from device process (#5412) * android: start/stop service in migration from device process * cleanup when finished uploading --- .../common/views/migration/MigrateFromDevice.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index d3f3facbd9..8588e0e981 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -174,7 +174,7 @@ private fun SectionByState( is MigrationFromState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath) is MigrationFromState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value) is MigrationFromState.LinkCreation -> LinkCreationView() - is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl) + is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl, chatReceiver.value) is MigrationFromState.Finished -> migrationState.FinishedView(s.chatDeletion) } } @@ -335,7 +335,7 @@ private fun LinkCreationView() { } @Composable -private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) { +private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl, chatReceiver: MigrationFromChatReceiver?) { SectionView { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_close), @@ -356,7 +356,7 @@ private fun MutableState.LinkShownView(fileId: Long, link: S confirmText = generalGetString(MR.strings.continue_to_next_step), destructive = true, onConfirm = { - finishMigration(fileId, ctrl) + finishMigration(fileId, ctrl, chatReceiver) } ) } @@ -450,6 +450,7 @@ private fun MutableState.stopChat() { try { controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationFromState.PassphraseNotSet else MigrationFromState.PassphraseConfirmation + platform.androidChatStopped() } catch (e: Exception) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.migrate_from_device_error_saving_settings), @@ -617,9 +618,11 @@ private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) { } } -private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl) { +private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl, chatReceiver: MigrationFromChatReceiver?) { withBGApi { cancelUploadedArchive(fileId, ctrl) + chatReceiver?.stopAndCleanUp() + getMigrationTempFilesDirectory().deleteRecursively() state = MigrationFromState.Finished(false) } } @@ -655,6 +658,7 @@ private suspend fun startChatAndDismiss(dismiss: Boolean = true) { } else if (user != null) { startChat(user) } + platform.androidChatStartedAfterBeingOff() } catch (e: Exception) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.error_starting_chat), From e4044f62117da02511c6a8df91a795620cd66e27 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 24 Dec 2024 14:13:47 +0000 Subject: [PATCH 10/95] core: fix operator conditions query (#5420) * logs * logs2 * logs3 * logs4 * logs5 * fix * update schema * migration * fix migration --- simplex-chat.cabal | 1 + .../M20241222_operator_conditions.hs | 18 ++++++ src/Simplex/Chat/Migrations/chat_schema.sql | 2 + src/Simplex/Chat/Operators.hs | 2 +- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/Store/Profiles.hs | 57 ++++++++++++------- src/Simplex/Chat/View.hs | 2 +- 7 files changed, 62 insertions(+), 24 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 29e748c4e8..a345fe5716 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -155,6 +155,7 @@ library Simplex.Chat.Migrations.M20241125_indexes Simplex.Chat.Migrations.M20241128_business_chats Simplex.Chat.Migrations.M20241205_business_chat_members + Simplex.Chat.Migrations.M20241222_operator_conditions Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs b/src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs new file mode 100644 index 0000000000..c0c4304313 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20241222_operator_conditions where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241222_operator_conditions :: Query +m20241222_operator_conditions = + [sql| +ALTER TABLE operator_usage_conditions ADD COLUMN auto_accepted INTEGER DEFAULT 0; +|] + +down_m20241222_operator_conditions :: Query +down_m20241222_operator_conditions = + [sql| +ALTER TABLE operator_usage_conditions DROP COLUMN auto_accepted; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 94ccc65b7f..0a6a581cbe 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -622,6 +622,8 @@ CREATE TABLE operator_usage_conditions( conditions_commit TEXT NOT NULL, accepted_at TEXT, created_at TEXT NOT NULL DEFAULT(datetime('now')) + , + auto_accepted INTEGER DEFAULT 0 ); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index e14e95211a..9eda85aaf3 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -167,7 +167,7 @@ conditionsRequiredOrDeadline createdAt notifiedAtOrNow = conditionsDeadline = addUTCTime (31 * nominalDay) data ConditionsAcceptance - = CAAccepted {acceptedAt :: Maybe UTCTime} + = CAAccepted {acceptedAt :: Maybe UTCTime, autoAccepted :: Bool} | CARequired {deadline :: Maybe UTCTime} deriving (Show) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 65fe8223fe..7d4d96dff2 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -119,6 +119,7 @@ import Simplex.Chat.Migrations.M20241027_server_operators import Simplex.Chat.Migrations.M20241125_indexes import Simplex.Chat.Migrations.M20241128_business_chats import Simplex.Chat.Migrations.M20241205_business_chat_members +import Simplex.Chat.Migrations.M20241222_operator_conditions import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -237,7 +238,8 @@ schemaMigrations = ("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators), ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes), ("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats), - ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members) + ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members), + ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e88cf39feb..013075841e 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -627,13 +627,13 @@ getUpdateServerOperators db presetOps newUser = do DBNewEntity -> do op' <- insertOperator op case (operatorTag op', acceptForSimplex_) of - (Just OTSimplex, Just cond) -> autoAcceptConditions op' cond + (Just OTSimplex, Just cond) -> autoAcceptConditions op' cond now _ -> pure op' DBEntityId _ -> do updateOperator op getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case - CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds - CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds + CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds now + CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds now ca -> pure op {conditionsAcceptance = ca} where insertConditions UsageConditions {conditionsId, conditionsCommit, notifiedAt, createdAt} = @@ -667,9 +667,9 @@ getUpdateServerOperators db presetOps newUser = do (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles) opId <- insertedRowId db pure op {operatorId = DBEntityId opId} - autoAcceptConditions op UsageConditions {conditionsCommit} = - acceptConditions_ db op conditionsCommit Nothing - $> op {conditionsAcceptance = CAAccepted Nothing} + autoAcceptConditions op UsageConditions {conditionsCommit} now = + acceptConditions_ db op conditionsCommit now True + $> op {conditionsAcceptance = CAAccepted (Just now) True} serverOperatorQuery :: Query serverOperatorQuery = @@ -708,7 +708,7 @@ getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {condition DB.query db [sql| - SELECT conditions_commit, accepted_at + SELECT conditions_commit, accepted_at, auto_accepted FROM operator_usage_conditions WHERE server_operator_id = ? ORDER BY operator_usage_conditions_id DESC @@ -716,10 +716,10 @@ getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {condition |] (Only operatorId) pure $ case operatorAcceptedConds_ of - Just (operatorCommit, acceptedAt_) + Just (operatorCommit, acceptedAt_, autoAccept) | operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled? | currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) - | otherwise -> CAAccepted acceptedAt_ + | otherwise -> CAAccepted acceptedAt_ autoAccept _ -> CARequired Nothing -- no conditions were accepted for this operator getCurrentUsageConditions :: DB.Connection -> ExceptT StoreError IO UsageConditions @@ -763,24 +763,39 @@ acceptConditions :: DB.Connection -> Int64 -> NonEmpty Int64 -> UTCTime -> Excep acceptConditions db condId opIds acceptedAt = do UsageConditions {conditionsCommit} <- getUsageConditionsById_ db condId operators <- mapM getServerOperator_ opIds - let ts = Just acceptedAt - liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit ts + liftIO $ forM_ operators $ \op -> acceptConditions_ db op conditionsCommit acceptedAt False where getServerOperator_ opId = ExceptT $ firstRow toServerOperator (SEOperatorNotFound opId) $ DB.query db (serverOperatorQuery <> " WHERE server_operator_id = ?") (Only opId) -acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> Maybe UTCTime -> IO () -acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt = - DB.execute - db - [sql| - INSERT INTO operator_usage_conditions - (server_operator_id, server_operator_tag, conditions_commit, accepted_at) - VALUES (?,?,?,?) - |] - (operatorId, operatorTag, conditionsCommit, acceptedAt) +acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> UTCTime -> Bool -> IO () +acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt autoAccepted = do + acceptedAt_ :: Maybe (Maybe UTCTime) <- maybeFirstRow fromOnly $ DB.query db "SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit == ?" (operatorId, conditionsCommit) + case acceptedAt_ of + Just Nothing -> + DB.execute + db + (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO UPDATE SET accepted_at = ?, auto_accepted = ?") + (operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted, acceptedAt, autoAccepted) + Just (Just _) -> + DB.execute + db + (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO NOTHING") + (operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted) + Nothing -> + DB.execute + db + q + (operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted) + where + q = + [sql| + INSERT INTO operator_usage_conditions + (server_operator_id, server_operator_tag, conditions_commit, accepted_at, auto_accepted) + VALUES (?,?,?,?,?) + |] getUsageConditionsById_ :: DB.Connection -> Int64 -> ExceptT StoreError IO UsageConditions getUsageConditionsById_ db conditionsId = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8b6a545637..49fd73ecc4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1311,7 +1311,7 @@ viewOpIdTag ServerOperator {operatorId, operatorTag} = case operatorId of viewOpConditions :: ConditionsAcceptance -> Text viewOpConditions = \case - CAAccepted ts -> viewCond "accepted" ts + CAAccepted ts _ -> viewCond "accepted" ts CARequired ts -> viewCond "required" ts where viewCond w ts = w <> maybe "" (parens . tshow) ts From 62188967536812b43e128f5bec8239f6f211d5f2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 24 Dec 2024 17:57:41 +0000 Subject: [PATCH 11/95] core: correct order or migrations (#5422) --- simplex-chat.cabal | 2 +- .../{M20241206_chat_tags.hs => M20241223_chat_tags.hs} | 10 +++++----- src/Simplex/Chat/Store/Migrations.hs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) rename src/Simplex/Chat/Migrations/{M20241206_chat_tags.hs => M20241223_chat_tags.hs} (89%) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f9f25374e2..4e917e885b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -159,8 +159,8 @@ library Simplex.Chat.Migrations.M20241125_indexes Simplex.Chat.Migrations.M20241128_business_chats Simplex.Chat.Migrations.M20241205_business_chat_members - Simplex.Chat.Migrations.M20241206_chat_tags Simplex.Chat.Migrations.M20241222_operator_conditions + Simplex.Chat.Migrations.M20241223_chat_tags Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat/Migrations/M20241206_chat_tags.hs b/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs similarity index 89% rename from src/Simplex/Chat/Migrations/M20241206_chat_tags.hs rename to src/Simplex/Chat/Migrations/M20241223_chat_tags.hs index 2476512814..a83be7549d 100644 --- a/src/Simplex/Chat/Migrations/M20241206_chat_tags.hs +++ b/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs @@ -1,12 +1,12 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241206_chat_tags where +module Simplex.Chat.Migrations.M20241223_chat_tags where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20241206_chat_tags :: Query -m20241206_chat_tags = +m20241223_chat_tags :: Query +m20241223_chat_tags = [sql| CREATE TABLE chat_tags ( chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -31,8 +31,8 @@ CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chat CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(group_id, chat_tag_id); |] -down_m20241206_chat_tags :: Query -down_m20241206_chat_tags = +down_m20241223_chat_tags :: Query +down_m20241223_chat_tags = [sql| DROP INDEX idx_chat_tags_user_id; DROP INDEX idx_chat_tags_user_id_chat_tag_text; diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index b685a5ad9f..33f5c329dc 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -119,8 +119,8 @@ import Simplex.Chat.Migrations.M20241027_server_operators import Simplex.Chat.Migrations.M20241125_indexes import Simplex.Chat.Migrations.M20241128_business_chats import Simplex.Chat.Migrations.M20241205_business_chat_members -import Simplex.Chat.Migrations.M20241206_chat_tags import Simplex.Chat.Migrations.M20241222_operator_conditions +import Simplex.Chat.Migrations.M20241223_chat_tags import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -240,8 +240,8 @@ schemaMigrations = ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes), ("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats), ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members), - ("20241206_chat_tags", m20241206_chat_tags, Just down_m20241206_chat_tags), - ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions) + ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), + ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags) ] -- | The list of migrations in ascending order by date From d80d2fa1567b477e0a437b5a5a1116bfa2dd85da Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Dec 2024 02:33:47 +0700 Subject: [PATCH 12/95] android: open file in default app (#5413) * android: open file in default app * icon * changes * changes * fix * allow files without extension --- .../android/src/main/AndroidManifest.xml | 8 +++ .../simplex/common/platform/Share.android.kt | 47 +++++++++++++-- .../views/chat/item/CIFileView.android.kt | 57 +++++++++++++++++++ .../common/views/chat/item/CIFileView.kt | 28 ++++++++- .../common/views/chat/item/ChatItemView.kt | 26 +++++++++ .../commonMain/resources/MR/base/strings.xml | 1 + .../views/chat/item/CIFileView.desktop.kt | 18 ++++++ 7 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 67bc0d70c8..bb6a6f8f8a 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -27,6 +27,14 @@ + + + + + + + + = if (Build.VERSION.SDK_INT >= 33) { +// pm.queryIntentActivities(openIntent, PackageManager.ResolveInfoFlags.of((PackageManager.MATCH_DEFAULT_ONLY).toLong())) +// } else { +// pm.queryIntentActivities(openIntent, PackageManager.MATCH_DEFAULT_ONLY) +// }.sortedBy { it.priority } +// val first = resInfoList.firstOrNull { it.isDefault } ?: resInfoList.firstOrNull() ?: return null + val act = pm.resolveActivity(openIntent, PackageManager.MATCH_DEFAULT_ONLY) ?: return null +// Log.d(TAG, "Default launch action ${act} ${act.loadLabel(pm)} ${act.activityInfo?.name}") + val label = act.loadLabel(pm).toString() + val icon = act.loadIcon(pm).toBitmap().asImageBitmap() + val chooser = act.activityInfo?.name?.endsWith("ResolverActivity") == true + return OpenDefaultApp(label, icon, chooser) } actual fun shareFile(text: String, fileSource: CryptoFile) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt new file mode 100644 index 0000000000..b24150ed24 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt @@ -0,0 +1,57 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import chat.simplex.common.model.CryptoFile +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.DefaultDropdownMenu +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.net.URI + +@Composable +actual fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) { + val defaultApp = remember(encryptedUri.toString()) { if (ext != null) queryDefaultAppForExtension(ext, encryptedUri) else null } + DefaultDropdownMenu(showMenu) { + if (defaultApp != null) { + if (!defaultApp.isSystemChooser) { + ItemAction( + stringResource(MR.strings.open_with_app).format(defaultApp.name), + defaultApp.icon, + textColor = MaterialTheme.colors.primary, + onClick = { + openOrShareFile("", fileSource, justOpen = true, useChooser = false) + showMenu.value = false + } + ) + } else { + ItemAction( + stringResource(MR.strings.open_with_app).format("…"), + painterResource(MR.images.ic_open_in_new), + color = MaterialTheme.colors.primary, + onClick = { + openOrShareFile("", fileSource, justOpen = true, useChooser = false) + showMenu.value = false + } + ) + } + } + ItemAction( + stringResource(MR.strings.save_verb), + painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download), + color = MaterialTheme.colors.primary, + onClick = { + saveFile() + showMenu.value = false + } + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 59643afdf4..2c16de40e9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -1,12 +1,12 @@ package chat.simplex.common.views.chat.item -import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable 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.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -184,14 +184,26 @@ fun CIFileView( } } + val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) } + val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() } + val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null + if (loadedFilePath != null && file?.fileSource != null) { + val encrypted = file.fileSource.cryptoArgs != null + SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) + } Row( Modifier .combinedClickable( - onClick = { fileAction() }, + onClick = { + if (appPlatform.isAndroid && loadedFilePath != null) { + showOpenSaveMenu.value = true + } else { + fileAction() + } + }, onLongClick = { showMenu.value = true } ) .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), - //Modifier.clickable(enabled = file?.fileSource != null) { if (file?.fileSource != null && getLoadedFilePath(file) != null) openFile(file.fileSource) }.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.sp.toDp()) ) { @@ -223,6 +235,16 @@ fun CIFileView( fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +@Composable +expect fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) + @Composable fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = rememberFileChooserLauncher(false, ciFile) { to: URI? -> 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 22842eb350..647c74da06 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 @@ -867,6 +867,32 @@ fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, on } } +@Composable +fun ItemAction(text: String, icon: ImageBitmap, textColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified, onClick: () -> Unit) { + val finalColor = if (textColor == Color.Unspecified) { + MenuTextColor + } else textColor + DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text, + modifier = Modifier + .fillMaxWidth() + .weight(1F) + .padding(end = 15.dp), + color = finalColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (iconColor == Color.Unspecified) { + Image(icon, text, Modifier.size(22.dp)) + } else { + Icon(icon, text, Modifier.size(22.dp), tint = iconColor) + } + } + } +} + @Composable fun ItemAction( text: String, 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 34788b5bde..b052727ad2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -482,6 +482,7 @@ Please, wait while the file is being loaded from the linked mobile File error Temporary file error + Open with %s Voice message diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt new file mode 100644 index 0000000000..eceb7de9be --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.desktop.kt @@ -0,0 +1,18 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import chat.simplex.common.model.CryptoFile +import java.net.URI + +@Composable +actual fun SaveOrOpenFileMenu( + showMenu: MutableState, + encrypted: Boolean, + ext: String?, + encryptedUri: URI, + fileSource: CryptoFile, + saveFile: () -> Unit +) { + +} From 5fef959e860fd9cc1db39693288ba0fc5859a0bd Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 25 Dec 2024 02:40:06 +0700 Subject: [PATCH 13/95] android, desktop: copy contact/group/member name into clipboard on their pages (#5423) * android, desktop: copy contact/group/member name into clipboard on their pages * name format --- .../chat/simplex/common/views/chat/ChatInfoView.kt | 11 +++++++++-- .../common/views/chat/group/GroupChatInfoView.kt | 14 ++++++++++++-- .../common/views/chat/group/GroupMemberInfoView.kt | 12 ++++++++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index ed661245a3..9b580edb62 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -697,13 +697,19 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) } ) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(contact.profile.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( text, inlineContent = inlineContent, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), textAlign = TextAlign.Center, maxLines = 3, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) { Text( @@ -711,7 +717,8 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 5ee6e40e6e..c92ac2ddc3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -9,6 +9,7 @@ import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material.* @@ -17,6 +18,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight @@ -446,12 +449,18 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) { horizontalAlignment = Alignment.CenterHorizontally ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(cInfo.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { Text( @@ -459,7 +468,8 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) { color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 8, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } 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 c9ac464438..760f340851 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 @@ -8,6 +8,7 @@ import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -528,13 +529,19 @@ fun GroupMemberInfoHeader(member: GroupMember) { Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary) } ) + val clipboard = LocalClipboardManager.current + val copyNameToClipboard = { + clipboard.setText(AnnotatedString(member.displayName)) + showToast(generalGetString(MR.strings.copied)) + } Text( text, inlineContent = inlineContent, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), textAlign = TextAlign.Center, maxLines = 3, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) if (member.fullName != "" && member.fullName != member.displayName) { Text( @@ -542,7 +549,8 @@ fun GroupMemberInfoHeader(member: GroupMember) { color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) } } From f7509953501c83144de33354198acb120af9468e Mon Sep 17 00:00:00 2001 From: BronxWick Date: Tue, 24 Dec 2024 19:43:38 +0000 Subject: [PATCH 14/95] blog: small typo (#5418) --- ...41210-simplex-network-v6-2-servers-by-flux-business-chats.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md b/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md index 502a42c559..339fab4d16 100644 --- a/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md +++ b/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md @@ -72,7 +72,7 @@ This is a small but important change - you can now see who reacted to your messa ### Improving notifications in iOS app -iOS notifications in a decentralized network is a complex problems. We [support iOS notifications](./20220404-simplex-chat-instant-notifications.md#ios-notifications-require-a-server) from early versions of the app, focussing on preserving privacy as much as possible. But the reliability of notifications was not good enough. +iOS notifications in a decentralized network is a complex problem. We [support iOS notifications](./20220404-simplex-chat-instant-notifications.md#ios-notifications-require-a-server) from early versions of the app, focussing on preserving privacy as much as possible. But the reliability of notifications was not good enough. We solved several problems of notification delivery in this release: - messaging servers no longer lose notifications while notification servers are restarted. From 790b0f315e98be22e03924ff830a2e0533a0f1a6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 24 Dec 2024 22:01:55 +0000 Subject: [PATCH 15/95] core: 6.2.2.0 (simplexmq: 6.2.2.0) --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat.hs | 2 +- tests/ChatClient.hs | 2 ++ 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index ae24afd374..327342f8a7 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 79e9447b73cc315ce35042b0a5f210c07ea39b07 + tag: 426bf68763c4461e218f6775e4cec8143393640f source-repository-package type: git diff --git a/package.yaml b/package.yaml index b476741597..668e2f26a0 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 6.2.0.7 +version: 6.2.2.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d0411c584d..353310605f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."79e9447b73cc315ce35042b0a5f210c07ea39b07" = "16z7z5a3f7gw0h188manykp008d1bqpydlrj7h497mgyjmp4cy9m"; + "https://github.com/simplex-chat/simplexmq.git"."426bf68763c4461e218f6775e4cec8143393640f" = "1h2hxn1qv33frpdaspbqz7ivysrnk5lcrgxsv88mk6mbm6bf7cwy"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index a345fe5716..37153da27e 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: 6.2.0.7 +version: 6.2.2.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 d5ad68079f..8cc3267f3a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -92,7 +92,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Util (encryptFile, liftIOEither, shuffle) import qualified Simplex.Chat.Util as U -import Simplex.FileTransfer.Client.Main (maxFileSize, maxFileSizeHard) +import Simplex.FileTransfer.Description (maxFileSize, maxFileSizeHard) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 8b7e8fcd32..b724c78e04 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -463,6 +463,8 @@ smpServerCfg = logStatsStartTime = 0, serverStatsLogFile = "tests/smp-server-stats.daily.log", serverStatsBackupFile = Nothing, + prometheusInterval = Nothing, + prometheusMetricsFile = "tests/smp-server-metrics.txt", pendingENDInterval = 500000, ntfDeliveryInterval = 200000, smpServerVRange = supportedServerSMPRelayVRange, From 7d0768457ebe50c14021e332b4c4e5f2d5eb1587 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 24 Dec 2024 23:56:40 +0000 Subject: [PATCH 16/95] 6.2.2: ios 256, android 263, desktop 84 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 759b16b196..910f5d2360 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -516,9 +516,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -671,9 +671,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -754,8 +754,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */, ); path = Libraries; sourceTree = ""; @@ -1931,7 +1931,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1956,7 +1956,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1980,7 +1980,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2005,7 +2005,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2021,11 +2021,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2041,11 +2041,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2066,7 +2066,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2081,7 +2081,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2103,7 +2103,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2118,7 +2118,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2140,7 +2140,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2166,7 +2166,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2191,7 +2191,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2217,7 +2217,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2242,7 +2242,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2257,7 +2257,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 255; + CURRENT_PROJECT_VERSION = 256; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.1; + MARKETING_VERSION = 6.2.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index cde173f24c..00c7578a8e 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.2.1 -android.version_code=261 +android.version_name=6.2.2 +android.version_code=263 -desktop.version_name=6.2.1 -desktop.version_code=83 +desktop.version_name=6.2.2 +desktop.version_code=84 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 1fd45f3478d79d342419daf311169576b9eb38c0 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 25 Dec 2024 13:11:15 +0400 Subject: [PATCH 17/95] flatpak: update metainfo (#5425) * flatpak: update metainfo * corrections --------- Co-authored-by: Evgeny --- .../flatpak/chat.simplex.simplex.metainfo.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 9b915532f7..182976d030 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,25 @@ + + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-2:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html From 32773c1d6e78c8ca1cbf25914d14de7adfe4a472 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 25 Dec 2024 09:22:34 +0000 Subject: [PATCH 18/95] core: update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 327342f8a7..ec29f320d7 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 426bf68763c4461e218f6775e4cec8143393640f + tag: bf289023273f2b94f8649b4c641e1cc9996b8a4b source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 353310605f..d1d3071b63 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."426bf68763c4461e218f6775e4cec8143393640f" = "1h2hxn1qv33frpdaspbqz7ivysrnk5lcrgxsv88mk6mbm6bf7cwy"; + "https://github.com/simplex-chat/simplexmq.git"."bf289023273f2b94f8649b4c641e1cc9996b8a4b" = "1qcyh8n3mws2vbnjw44ih2ji6s9p1dy5rmhs49zf3ia7llnsqzdl"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From 84a45cedbea55827c0493913b7d8d92ac0cc3e69 Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 25 Dec 2024 11:35:48 +0000 Subject: [PATCH 19/95] android, desktop: chat tags (#5396) * types and api * remaining api * icons for tags (named label due to name conflict) * icon fix * wup * desktop handlers to open list * updates * filtering * progress * wip dump * icons * preset updates * unread * + button in tags view * drag n drop helpers * chats reorder * tag chat after list creation (when chat provided) * updates on unread tags * initial emoji picker * fixes and tweaks * reoder color * clickable shapes * paddings * reachable form * one hand for tags * ui tweaks * input for emojis desktop * wrap chat tags in desktop * handling longer texts * fixed a couple of issues in updates of unread tags * reset search text on active filter change * fix multi row alignment * fix modal paddings * fix single emoji picker for skin colors * dependency corrected * icon, refactor, back action to exit edit mode * different icon params to make it larger * refactor * refactor * rename * rename * refactor * refactor * padding * unread counter size --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin --- apps/multiplatform/common/build.gradle.kts | 3 + .../views/chatlist/TagListView.android.kt | 81 +++ .../chat/simplex/common/model/ChatModel.kt | 158 +++++- .../chat/simplex/common/model/SimpleXAPI.kt | 61 +++ .../simplex/common/views/chat/ChatView.kt | 2 + .../views/chatlist/ChatListNavLinkView.kt | 52 +- .../common/views/chatlist/ChatListView.kt | 398 +++++++++++++- .../common/views/chatlist/ShareListView.kt | 2 +- .../common/views/chatlist/TagListView.kt | 509 ++++++++++++++++++ .../common/views/helpers/DragAndDrop.kt | 177 ++++++ .../commonMain/resources/MR/base/strings.xml | 24 + .../resources/MR/images/ic_drag_handle.svg | 1 + .../resources/MR/images/ic_group_filled.svg | 1 + .../resources/MR/images/ic_label.svg | 1 + .../resources/MR/images/ic_label_filled.svg | 1 + .../resources/MR/images/ic_person_filled.svg | 1 + .../resources/MR/images/ic_work_filled.svg | 1 + .../chatlist/ChatListNavLinkView.desktop.kt | 3 +- .../views/chatlist/TagListView.desktop.kt | 57 ++ 19 files changed, 1501 insertions(+), 32 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index ad67b7cf1e..5b7f89f2df 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -87,6 +87,9 @@ kotlin { implementation("io.coil-kt:coil-compose:2.6.0") implementation("io.coil-kt:coil-gif:2.6.0") + // Emojis + implementation("androidx.emoji2:emoji2-emojipicker:1.4.0") + implementation("com.jakewharton:process-phoenix:3.0.0") val cameraXVersion = "1.3.4" diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt new file mode 100644 index 0000000000..ab6d375d75 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt @@ -0,0 +1,81 @@ +package chat.simplex.common.views.chatlist + +import SectionItemView +import android.view.ViewGroup +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.emoji2.emojipicker.EmojiPickerView +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { + Box(Modifier + .clip(shape = CircleShape) + .clickable { + ModalManager.start.showModalCloseable { close -> + EmojiPicker(close = { + close() + emoji.value = it + }) + } + } + .padding(4.dp) + ) { + val emojiValue = emoji.value + if (emojiValue != null) { + Text(emojiValue) + } else { + Icon( + painter = painterResource(MR.images.ic_add_reaction), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + } + } + Spacer(Modifier.width(8.dp)) + TagListNameTextField(name, showError = showError) + } +} + +@Composable +private fun EmojiPicker(close: (String?) -> Unit) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val topPaddingToContent = topPaddingToContent(false) + + Column ( + modifier = Modifier.fillMaxSize().navigationBarsPadding().padding( + start = DEFAULT_PADDING_HALF, + end = DEFAULT_PADDING_HALF, + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, + bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp + ), + ) { + AndroidView( + factory = { context -> + EmojiPickerView(context).apply { + emojiGridColumns = 10 + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setOnEmojiPickedListener { pickedEmoji -> + close(pickedEmoji.emoji) + } + } + } + ) + } +} 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 db90c2e573..e2fe96e178 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 @@ -13,6 +13,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState import chat.simplex.common.views.migration.MigrationToState @@ -81,6 +82,12 @@ object ChatModel { val groupMembers = mutableStateListOf() val groupMembersIndexes = mutableStateMapOf() + // Chat Tags + val userTags = mutableStateOf(emptyList()) + val activeChatTagFilter = mutableStateOf(null) + val presetTags = mutableStateMapOf() + val unreadTags = mutableStateMapOf() + // false: default placement, true: floating window. // Used for deciding to add terminal items on main thread or not. Floating means appPrefs.terminalAlwaysVisible var terminalsVisible = setOf() @@ -196,6 +203,116 @@ object ChatModel { } } + fun updateChatTags(rhId: Long?) { + val newPresetTags = mutableMapOf() + val newUnreadTags = mutableMapOf() + + for (chat in chats.value.filter { it.remoteHostId == rhId }) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chat.chatInfo)) { + newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1 + } + } + if (chat.unreadTag) { + val chatTags: List = when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> cInfo.contact.chatTags + is ChatInfo.Group -> cInfo.groupInfo.chatTags + else -> emptyList() + } + chatTags.forEach { tag -> + newUnreadTags[tag] = (newUnreadTags[tag] ?: 0) + 1 + } + } + } + + if (activeChatTagFilter.value is ActiveFilter.PresetTag && + (newPresetTags[(activeChatTagFilter.value as ActiveFilter.PresetTag).tag] ?: 0) == 0) { + activeChatTagFilter.value = null + } + + presetTags.clear() + presetTags.putAll(newPresetTags) + unreadTags.clear() + unreadTags.putAll(newUnreadTags) + } + + fun updateChatFavorite(favorite: Boolean, wasFavorite: Boolean) { + val count = presetTags[PresetTagKind.FAVORITES] + + if (favorite && !wasFavorite) { + presetTags[PresetTagKind.FAVORITES] = (count ?: 0) + 1 + } else if (!favorite && wasFavorite && count != null) { + presetTags[PresetTagKind.FAVORITES] = maxOf(0, count - 1) + if (activeChatTagFilter.value == ActiveFilter.PresetTag(PresetTagKind.FAVORITES) && (presetTags[PresetTagKind.FAVORITES] ?: 0) == 0) { + activeChatTagFilter.value = null + } + } + } + + fun addPresetChatTags(chatInfo: ChatInfo) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chatInfo)) { + presetTags[tag] = (presetTags[tag] ?: 0) + 1 + } + } + } + + fun removePresetChatTags(chatInfo: ChatInfo) { + for (tag in PresetTagKind.entries) { + if (presetTagMatchesChat(tag, chatInfo)) { + val count = presetTags[tag] + if (count != null) { + presetTags[tag] = maxOf(0, count - 1) + } + } + } + } + + fun markChatTagRead(chat: Chat) { + if (chat.unreadTag) { + chat.chatInfo.chatTags?.let { tags -> + markChatTagRead_(chat, tags) + } + } + } + + fun updateChatTagRead(chat: Chat, wasUnread: Boolean) { + val tags = chat.chatInfo.chatTags ?: return + val nowUnread = chat.unreadTag + + if (nowUnread && !wasUnread) { + tags.forEach { tag -> + unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 + } + } else if (!nowUnread && wasUnread) { + markChatTagRead_(chat, tags) + } + } + + fun moveChatTagUnread(chat: Chat, oldTags: List?, newTags: List) { + if (chat.unreadTag) { + oldTags?.forEach { t -> + val oldCount = unreadTags[t] + if (oldCount != null) { + unreadTags[t] = maxOf(0, oldCount - 1) + } + } + + newTags.forEach { t -> + unreadTags[t] = (unreadTags[t] ?: 0) + 1 + } + } + } + + private fun markChatTagRead_(chat: Chat, tags: List) { + for (tag in tags) { + val count = unreadTags[tag] + if (count != null) { + unreadTags[tag] = maxOf(0, count - 1) + } + } + } + // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null // TODO pass rhId? @@ -280,6 +397,7 @@ object ChatModel { updateChatInfo(rhId, cInfo) } else if (addMissing) { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) + addPresetChatTags(cInfo) } } @@ -329,6 +447,7 @@ object ChatModel { } else -> cItem } + val wasUnread = chat.unreadTag chats[i] = chat.copy( chatItems = arrayListOf(newPreviewItem), chatStats = @@ -339,6 +458,8 @@ object ChatModel { else chat.chatStats ) + updateChatTagRead(chats[i], wasUnread) + if (appPlatform.isDesktop && cItem.chatDir.sent) { reorderChat(chats[i], 0) } else { @@ -455,6 +576,7 @@ object ChatModel { if (i >= 0) { decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) + markChatTagRead(chats[i]) } // clear current chat if (chatId.value == cInfo.id) { @@ -522,11 +644,13 @@ object ChatModel { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { + val wasUnread = chat.unreadTag val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0 decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount) ) + updateChatTagRead(chats[chatIdx], wasUnread) } } } @@ -537,16 +661,29 @@ object ChatModel { val chat = chats[chatIndex] val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) + val wasUnread = chat.unreadTag decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, ) ) + updateChatTagRead(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { - chats.removeAll { it.id == id && it.remoteHostId == rhId } + var removed: ChatInfo? = null + chats.removeAll { + val found = it.id == id && it.remoteHostId == rhId + if (found) { + removed = it.chatInfo + } + found + } + + removed?.let { + removePresetChatTags(it) + } } suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { @@ -977,6 +1114,8 @@ data class Chat( else -> false } + val unreadTag: Boolean get() = chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat) + val id: String get() = chatInfo.id fun groupFeatureEnabled(feature: GroupFeature): Boolean = @@ -1189,6 +1328,12 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> false } + val chatTags: List? + get() = when (this) { + is Direct -> contact.chatTags + is Group -> groupInfo.chatTags + else -> null + } } @Serializable @@ -1232,6 +1377,7 @@ data class Contact( val chatTs: Instant?, val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, + val chatTags: List, override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, ): SomeChat, NamedChat { @@ -1315,6 +1461,7 @@ data class Contact( contactGrpInvSent = false, chatDeleted = false, uiThemes = null, + chatTags = emptyList() ) } } @@ -1476,6 +1623,7 @@ data class GroupInfo ( override val updatedAt: Instant, val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, + val chatTags: List ): SomeChat, NamedChat { override val chatType get() = ChatType.Group override val id get() = "#$groupId" @@ -1520,6 +1668,7 @@ data class GroupInfo ( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), uiThemes = null, + chatTags = emptyList() ) } } @@ -3850,6 +3999,13 @@ sealed class ChatItemTTL: Comparable { } } +@Serializable +data class ChatTag( + val chatTagId: Long, + val chatTagText: String, + val chatTagEmoji: String? +) + @Serializable class ChatItemInfo( val itemVersions: List, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 95d5a01699..22dda005af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -624,6 +624,8 @@ object ChatController { val chats = apiGetChats(rhId) updateChats(chats) } + chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() + chatModel.updateChatTags(rhId) } private fun startReceiver() { @@ -879,6 +881,16 @@ object ChatController { return emptyList() } + private suspend fun apiGetChatTags(rh: Long?): List?{ + val userId = currentUserId("apiGetChatTags") + val r = sendCmd(rh, CC.ApiGetChatTags(userId)) + + if (r is CR.ChatTags) return r.userTags + Log.e(TAG, "apiGetChatTags bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_loading_chat_tags), "${r.responseType}: ${r.details}") + return null + } + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair? { val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo @@ -891,6 +903,28 @@ object ChatController { return null } + suspend fun apiCreateChatTag(rh: Long?, tag: ChatTagData): List? { + val r = sendCmd(rh, CC.ApiCreateChatTag(tag)) + if (r is CR.ChatTags) return r.userTags + Log.e(TAG, "apiCreateChatTag bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_creating_chat_tags), "${r.responseType}: ${r.details}") + return null + } + + suspend fun apiSetChatTags(rh: Long?, type: ChatType, id: Long, tagIds: List): Pair, List>? { + val r = sendCmd(rh, CC.ApiSetChatTags(type, id, tagIds)) + if (r is CR.TagsUpdated) return r.userTags to r.chatTags + Log.e(TAG, "apiSetChatTags bad response: ${r.responseType} ${r.details}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_updating_chat_tags), "${r.responseType}: ${r.details}") + return null + } + + suspend fun apiDeleteChatTag(rh: Long?, tagId: Long) = sendCommandOkResp(rh, CC.ApiDeleteChatTag(tagId)) + + suspend fun apiUpdateChatTag(rh: Long?, tagId: Long, tag: ChatTagData) = sendCommandOkResp(rh, CC.ApiUpdateChatTag(tagId, tag)) + + suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) @@ -3152,10 +3186,16 @@ sealed class CC { class TestStorageEncryption(val key: String): CC() class ApiSaveSettings(val settings: AppSettings): CC() class ApiGetSettings(val settings: AppSettings): CC() + class ApiGetChatTags(val userId: Long): CC() class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiCreateChatTag(val tag: ChatTagData): CC() + class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() + class ApiDeleteChatTag(val tagId: Long): CC() + class ApiUpdateChatTag(val tagId: Long, val tagData: ChatTagData): CC() + class ApiReorderChatTags(val tagIds: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() @@ -3307,6 +3347,7 @@ sealed class CC { is TestStorageEncryption -> "/db test key $key" is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}" is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}" + is ApiGetChatTags -> "/_get tags $userId" is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" @@ -3315,6 +3356,11 @@ sealed class CC { val ttlStr = if (ttl != null) "$ttl" else "default" "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs" } + is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" + is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}" + is ApiDeleteChatTag -> "/_delete tag $tagId" + is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}" + is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}" is ApiCreateChatItems -> { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" @@ -3471,10 +3517,16 @@ sealed class CC { is TestStorageEncryption -> "testStorageEncryption" is ApiSaveSettings -> "apiSaveSettings" is ApiGetSettings -> "apiGetSettings" + is ApiGetChatTags -> "apiGetChatTags" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiGetChatItemInfo -> "apiGetChatItemInfo" is ApiSendMessages -> "apiSendMessages" + is ApiCreateChatTag -> "apiCreateChatTag" + is ApiSetChatTags -> "apiSetChatTags" + is ApiDeleteChatTag -> "apiDeleteChatTag" + is ApiUpdateChatTag -> "apiUpdateChatTag" + is ApiReorderChatTags -> "apiReorderChatTags" is ApiCreateChatItems -> "apiCreateChatItems" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" @@ -3657,6 +3709,9 @@ sealed class ChatPagination { @Serializable class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent) +@Serializable +class ChatTagData(val emoji: String?, val text: String) + @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) @@ -5390,6 +5445,7 @@ sealed class CR { @Serializable @SerialName("chatStopped") class ChatStopped: CR() @Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List): CR() @Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR() + @Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() @@ -5416,6 +5472,7 @@ sealed class CR { @Serializable @SerialName("contactCode") class ContactCode(val user: UserRef, val contact: Contact, val connectionCode: String): CR() @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() + @Serializable @SerialName("tagsUpdated") class TagsUpdated(val user: UserRef, val userTags: List, val chatTags: List): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR() @@ -5574,6 +5631,7 @@ sealed class CR { is ChatStopped -> "chatStopped" is ApiChats -> "apiChats" is ApiChat -> "apiChat" + is ChatTags -> "chatTags" is ApiChatItemInfo -> "chatItemInfo" is ServerTestResult -> "serverTestResult" is ServerOperatorConditions -> "serverOperatorConditions" @@ -5600,6 +5658,7 @@ sealed class CR { is ContactCode -> "contactCode" is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" + is TagsUpdated -> "tagsUpdated" is Invitation -> "invitation" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" is ConnectionUserChanged -> "ConnectionUserChanged" @@ -5748,6 +5807,7 @@ sealed class CR { is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") + is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" @@ -5774,6 +5834,7 @@ sealed class CR { is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") + is TagsUpdated -> withUser(user, "userTags: ${json.encodeToString(userTags)}\nchatTags: ${json.encodeToString(chatTags)}") is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" ) 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 3c0f1f7769..c58561718e 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 @@ -31,6 +31,7 @@ import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.activeCall import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.markChatTagRead import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -2106,6 +2107,7 @@ private fun markUnreadChatAsRead(chatId: String) { if (success) { withChats { replaceChat(chatRh, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + markChatTagRead(chat) } } } 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 226030fcd4..2f0311b087 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 @@ -4,28 +4,34 @@ import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.MaterialTheme.colors import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.* +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.* import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.markChatTagRead +import chat.simplex.common.model.ChatModel.updateChatTagRead import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* -import chat.simplex.common.views.chat.group.deleteGroupDialog -import chat.simplex.common.views.chat.group.leaveGroupDialog +import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.contacts.onRequestAccepted import chat.simplex.common.views.helpers.* @@ -33,7 +39,6 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.datetime.Clock -import kotlin.math.min @Composable fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { @@ -252,6 +257,7 @@ fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMen } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) } DeleteContactAction(chat, chatModel, showMenu) @@ -291,6 +297,7 @@ fun GroupMenuItems( } ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) + TagListAction(chat, showMenu) ClearChatAction(chat, showMenu) if (groupInfo.membership.memberCurrent) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) @@ -337,6 +344,28 @@ fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableStat ) } +@Composable +fun TagListAction( + chat: Chat, + showMenu: MutableState +) { + val userTags = remember { chatModel.userTags } + ItemAction( + stringResource(MR.strings.list_menu), + painterResource(MR.images.ic_label), + onClick = { + ModalManager.start.showModalCloseable { close -> + if (userTags.value.isEmpty()) { + TagListEditor(rhId = chat.remoteHostId, chat = chat, close = close) + } else { + TagListView(rhId = chat.remoteHostId, chat = chat, close = close) + } + } + showMenu.value = false + } + ) +} + @Composable fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState) { ItemAction( @@ -557,6 +586,7 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + markChatTagRead(chat) } } } @@ -568,6 +598,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (chat.chatStats.unreadChat) return withApi { + val wasUnread = chat.unreadTag val success = chatModel.controller.apiChatUnread( chat.remoteHostId, chat.chatInfo.chatType, @@ -577,6 +608,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + updateChatTagRead(chat, wasUnread) } } } @@ -826,12 +858,20 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch else -> false } if (res && newChatInfo != null) { + val chat = chatModel.getChat(chatInfo.id) + val wasUnread = chat?.unreadTag ?: false + val wasFavorite = chatInfo.chatSettings?.favorite ?: false + chatModel.updateChatFavorite(favorite = chatSettings.favorite, wasFavorite) withChats { updateChatInfo(remoteHostId, newChatInfo) } if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chatInfo.id) } + val updatedChat = chatModel.getChat(chatInfo.id) + if (updatedChat != null) { + chatModel.updateChatTagRead(updatedChat, wasUnread) + } val current = currentState?.value if (current != null) { currentState.value = !current diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index ff776bc8ca..2502c5c31b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -16,11 +16,13 @@ import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.platform.* -import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.AppLock import chat.simplex.common.model.* @@ -32,21 +34,30 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.CIFileViewScope +import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.topPaddingToContent -import chat.simplex.common.views.mkValidName import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* -import chat.simplex.common.views.showInvalidNameAlert import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds +enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS } + +sealed class ActiveFilter { + data class PresetTag(val tag: PresetTagKind) : ActiveFilter() + data class UserTag(val tag: ChatTag) : ActiveFilter() + data object Unread: ActiveFilter() +} + private fun showNewChatSheet(oneHandUI: State) { ModalManager.start.closeModals() ModalManager.end.closeModals() @@ -557,17 +568,24 @@ private fun BoxScope.unreadBadge(text: String? = "") { @Composable private fun ToggleFilterEnabledButton() { - val pref = remember { ChatController.appPrefs.showUnreadAndFavorites } - IconButton(onClick = { pref.set(!pref.get()) }) { + val showUnread = remember { chatModel.activeChatTagFilter }.value == ActiveFilter.Unread + + IconButton(onClick = { + if (showUnread) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.Unread + } + }) { val sp16 = with(LocalDensity.current) { 16.sp.toDp() } Icon( painterResource(MR.images.ic_filter_list), null, - tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary, + tint = if (showUnread) MaterialTheme.colors.background else MaterialTheme.colors.secondary, modifier = Modifier .padding(3.dp) - .background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) - .border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) + .background(color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) + .border(width = 1.dp, color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) .padding(3.dp) .size(sp16) ) @@ -731,6 +749,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state } val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val activeFilter = remember { chatModel.activeChatTagFilter } LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { val currentIndex = listState.firstVisibleItemIndex @@ -753,14 +772,13 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } - val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value val allChats = remember { chatModel.chats } // In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side // which is related to [derivedStateOf]. Using safe alternative instead // val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } } val searchShowingSimplexLink = remember { mutableStateOf(false) } val searchChatFilteredBySimplexLink = remember { mutableStateOf(null) } - val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList()) + val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value) val topPaddingToContent = topPaddingToContent(false) val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent LazyColumnWithScrollBar( @@ -791,11 +809,15 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ) { if (oneHandUI.value) { Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) { + Divider() + TagsView() ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) } } else { ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) + TagsView() + Divider() } } } @@ -815,8 +837,8 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) { - Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) { - Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary) + Box(Modifier.fillMaxSize().imePadding().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) { + NoChatsView(searchText = searchText) } } if (oneHandUI.value) { @@ -839,6 +861,41 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } } + + LaunchedEffect(activeFilter.value) { + searchText.value = TextFieldValue("") + } +} + +@Composable +private fun NoChatsView(searchText: MutableState) { + val activeFilter = remember { chatModel.activeChatTagFilter }.value + + if (searchText.value.text.isBlank()) { + when (activeFilter) { + is ActiveFilter.PresetTag -> Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) // this should not happen + is ActiveFilter.UserTag -> Text(String.format(generalGetString(MR.strings.no_chats_in_list), activeFilter.tag.chatTagText), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + is ActiveFilter.Unread -> { + Row( + Modifier.clip(shape = RoundedCornerShape(percent = 50)).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_filter_list), + null, + tint = MaterialTheme.colors.secondary + ) + Text(generalGetString(MR.strings.no_unread_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + null -> { + Text(generalGetString(MR.strings.no_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + } else { + Text(generalGetString(MR.strings.no_chats_found), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } } @Composable @@ -860,31 +917,297 @@ private fun ChatListFeatureCards() { } } +@Composable +private fun TagsView() { + val userTags = remember { chatModel.userTags } + val presetTags = remember { chatModel.presetTags } + val activeFilter = remember { chatModel.activeChatTagFilter } + val unreadTags = remember { chatModel.unreadTags } + val rhId = chatModel.remoteHostId() + + fun showTagList() { + ModalManager.start.showCustomModal { close -> + val editMode = remember { stateGetOrPut("editMode") { false } } + ModalView(close, showClose = true, endButtons = { + TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = RoundedCornerShape(percent = 50))) { + Text(stringResource(if (editMode.value) MR.strings.cancel_verb else MR.strings.edit_verb)) + } + }) { + TagListView(rhId = rhId, close = close, editMode = editMode) + } + } + } + val rowSizeModifier = Modifier.sizeIn(minHeight = 35.dp * fontSizeSqrtMultiplier) + + TagsRow { + if (presetTags.size > 1) { + if (presetTags.size + userTags.value.size <= 3) { + PresetTagKind.entries.filter { t -> (presetTags[t] ?: 0) > 0 }.forEach { tag -> + ExpandedTagFilterView(tag) + } + } else { + Column(rowSizeModifier, verticalArrangement = Arrangement.Center) { + CollapsedTagsFilterView() + } + } + } + + userTags.value.forEach { tag -> + val current = when (val af = activeFilter.value) { + is ActiveFilter.UserTag -> af.tag == tag + else -> false + } + val interactionSource = remember { MutableInteractionSource() } + + Column(rowSizeModifier, verticalArrangement = Arrangement.Center) { + Row( + Modifier + .clip(shape = RoundedCornerShape(percent = 50)) + .combinedClickable( + onClick = { + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) + } + }, + onLongClick = { showTagList() }, + interactionSource = interactionSource, + indication = LocalIndication.current + ) + .onRightClick { showTagList() } + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + Text( + tag.chatTagEmoji + ) + } else { + Icon( + painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), + null, + Modifier.size(20.dp), + tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground + ) + } + Spacer(Modifier.width(4.dp)) + Box { + val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" + val invisibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { + append(badgeText) + } + } + Text( + text = invisibleText, + fontWeight = FontWeight.SemiBold, + color = Color.Transparent, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Visible text with styles + val visibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.sp, color = MaterialTheme.colors.primary)) { + append(badgeText) + } + } + Text( + text = visibleText, + fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal, + color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + val plusClickModifier = Modifier + .clickable { + ModalManager.start.showModalCloseable { close -> + TagListEditor(rhId = rhId, close = close) + } + } + + Column(rowSizeModifier, verticalArrangement = Arrangement.Center) { + if (userTags.value.isEmpty()) { + Row(Modifier.clip(shape = RoundedCornerShape(percent = 50)).then(plusClickModifier).padding(vertical = 4.dp), horizontalArrangement = Arrangement.Center) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(2.dp)) + Text(stringResource(MR.strings.chat_list_add_list), color = MaterialTheme.colors.secondary) + } + } else { + Icon( + painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.clip(shape = CircleShape).then(plusClickModifier).padding(4.dp), tint = MaterialTheme.colors.secondary + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TagsRow(content: @Composable() (() -> Unit)) { + if (appPlatform.isAndroid) { + Row( + modifier = Modifier + .padding(horizontal = 14.dp) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + content() + } + } else { + FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() } + } +} + +@Composable +private fun ExpandedTagFilterView(tag: PresetTagKind) { + val activeFilter = remember { chatModel.activeChatTagFilter } + val active = when (val af = activeFilter.value) { + is ActiveFilter.PresetTag -> af.tag == tag + else -> false + } + val (icon, text) = presetTagLabel(tag, active) + val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + + Row( + modifier = Modifier + .clip(shape = RoundedCornerShape(percent = 50)) + .clickable { + if (activeFilter.value == ActiveFilter.PresetTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(tag) + } + } + .padding(4.dp) + , + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painterResource(icon), + stringResource(text), + tint = color + ) + Spacer(Modifier.width(4.dp)) + Box { + Text( + stringResource(text), + color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + ) + Text( + stringResource(text), + color = Color.Transparent, + fontWeight = FontWeight.SemiBold + ) + } + } +} + + +@Composable +private fun CollapsedTagsFilterView() { + val activeFilter = remember { chatModel.activeChatTagFilter } + val presetTags = remember { chatModel.presetTags } + val showMenu = remember { mutableStateOf(false) } + + val selectedPresetTag = when (val af = activeFilter.value) { + is ActiveFilter.PresetTag -> af.tag + else -> null + } + + Column(Modifier + .clip(shape = CircleShape) + .clickable { showMenu.value = true } + .padding(4.dp) + ) { + if (selectedPresetTag != null) { + val (icon, text) = presetTagLabel(selectedPresetTag, true) + + Icon( + painterResource(icon), + stringResource(text), + tint = MaterialTheme.colors.secondary + ) + } else { + Icon( + painterResource(MR.images.ic_menu), + stringResource(MR.strings.chat_list_all), + tint = MaterialTheme.colors.secondary + ) + } + + DefaultDropdownMenu(showMenu = showMenu) { + if (selectedPresetTag != null) { + ItemAction( + stringResource(MR.strings.chat_list_all), + painterResource(MR.images.ic_menu), + onClick = { + chatModel.activeChatTagFilter.value = null + showMenu.value = false + } + ) + } + PresetTagKind.entries.forEach { tag -> + if ((presetTags[tag] ?: 0) > 0) { + ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu) + } + } + } + } +} + +@Composable +fun ItemPresetFilterAction( + presetTag: PresetTagKind, + active: Boolean, + showMenu: MutableState +) { + val (icon, text) = presetTagLabel(presetTag, active) + ItemAction( + stringResource(text), + painterResource(icon), + onClick = { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + showMenu.value = false + } + ) +} + fun filteredChats( - showUnreadAndFavorites: Boolean, searchShowingSimplexLink: State, searchChatFilteredBySimplexLink: State, searchText: String, - chats: List + chats: List, + activeFilter: ActiveFilter? = null, ): List { val linkChatId = searchChatFilteredBySimplexLink.value return if (linkChatId != null) { chats.filter { it.id == linkChatId } } else { val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() - if (s.isEmpty() && !showUnreadAndFavorites) - chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD } + if (s.isEmpty()) + chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD && filtered(chat, activeFilter) } else { chats.filter { chat -> when (val cInfo = chat.chatInfo) { is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && ( if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat) + chat.id == chatModel.chatId.value || filtered(chat, activeFilter) } else { cInfo.anyNameContains(s) }) is ChatInfo.Group -> if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited + chat.id == chatModel.chatId.value || filtered(chat, activeFilter) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited } else { cInfo.anyNameContains(s) } @@ -898,10 +1221,41 @@ fun filteredChats( } } -private fun filtered(chat: Chat): Boolean = - (chat.chatInfo.chatSettings?.favorite ?: false) || - chat.chatStats.unreadChat || - (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0) +private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = + when (activeFilter) { + is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo) + is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false + is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + else -> true + } + +fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = + when (tag) { + PresetTagKind.FAVORITES -> chatInfo.chatSettings?.favorite == true + PresetTagKind.CONTACTS -> when (chatInfo) { + is ChatInfo.Direct -> !(chatInfo.contact.activeConn == null && chatInfo.contact.profile.contactLink != null && chatInfo.contact.active) && !chatInfo.contact.chatDeleted + is ChatInfo.ContactRequest -> true + is ChatInfo.ContactConnection -> true + is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Customer + else -> false + } + PresetTagKind.GROUPS -> when (chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null + else -> false + } + PresetTagKind.BUSINESS -> when (chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business + else -> false + } + } + +private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair = + when (tag) { + PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites + PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts + PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups + PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses + } fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) { scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index e048c39fe7..aa9847c98a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -191,7 +191,7 @@ private fun ShareList( val chats by remember(search) { derivedStateOf { val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready }.sortedByDescending { it.chatInfo is ChatInfo.Local } - filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted) + filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted) } } val topPaddingToContent = topPaddingToContent(false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt new file mode 100644 index 0000000000..7b204bc07b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -0,0 +1,509 @@ +package chat.simplex.common.views.chatlist + +import SectionCustomFooter +import SectionDivider +import SectionItemView +import TextIconSpaced +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.* +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.apiDeleteChatTag +import chat.simplex.common.model.ChatController.apiSetChatTags +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: MutableState = remember { mutableStateOf(false) }) { + if (remember { editMode }.value) { + BackHandler { + editMode.value = false + } + } + val userTags = remember { chatModel.userTags } + val oneHandUI = remember { appPrefs.oneHandUI.state } + val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState() + val saving = remember { mutableStateOf(false) } + val chatTagIds = derivedStateOf { chat?.chatInfo?.chatTags ?: emptyList() } + + fun reorderTags(tagIds: List) { + saving.value = true + withBGApi { + try { + chatModel.controller.apiReorderChatTags(rhId, tagIds) + } catch (e: Exception) { + Log.d(TAG, "ChatListTag reorderTags error: ${e.message}") + } finally { + saving.value = false + } + } + } + + val dragDropState = + rememberDragDropState(listState) { fromIndex, toIndex -> + userTags.value = userTags.value.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } + reorderTags(userTags.value.map { it.chatTagId }) + } + val topPaddingToContent = topPaddingToContent(false) + + LazyColumnWithScrollBar( + modifier = if (editMode.value) Modifier.dragContainer(dragDropState) else Modifier, + contentPadding = PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, + bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp + ), + state = listState, + verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top, + ) { + @Composable fun CreateList() { + SectionItemView({ + ModalManager.start.showModalCloseable { close -> + TagListEditor(rhId = rhId, close = close, chat = chat) + } + }) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.create_list), tint = MaterialTheme.colors.primary) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text(stringResource(MR.strings.create_list), color = MaterialTheme.colors.primary) + } + } + + if (oneHandUI.value && !editMode.value) { + item { + CreateList() + } + } + itemsIndexed(userTags.value, key = { _, item -> item.chatTagId }) { index, tag -> + DraggableItem(dragDropState, index) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + + Card( + elevation = elevation, + backgroundColor = if (isDragging) colors.surface else Color.Unspecified + ) { + Column { + val showMenu = remember { mutableStateOf(false) } + val selected = chatTagIds.value.contains(tag.chatTagId) + + Row( + Modifier + .fillMaxWidth() + .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) + .combinedClickable( + enabled = !saving.value, + onClick = { + if (chat == null) { + ModalManager.start.showModalCloseable { close -> + TagListEditor( + rhId = rhId, + tagId = tag.chatTagId, + close = close, + emoji = tag.chatTagEmoji, + name = tag.chatTagText, + ) + } + } else { + saving.value = true + setTag(rhId = rhId, tagId = if (selected) null else tag.chatTagId, chat = chat, close = { + saving.value = false + close() + }) + } + }, + onLongClick = if (editMode.value) null else { + { showMenu.value = true } + }, + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current + ) + .onRightClick { showMenu.value = true } + .padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)), + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + Text( + tag.chatTagEmoji + ) + } else { + Icon(painterResource(MR.images.ic_label), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + tag.chatTagText, + color = MenuTextColor, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal + ) + if (selected) { + Spacer(Modifier.weight(1f)) + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } else if (editMode.value) { + Spacer(Modifier.weight(1f)) + Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) + } + DefaultDropdownMenu(showMenu, dropdownMenuItems = { + EditTagAction(rhId, tag, showMenu) + DeleteTagAction(rhId, tag, showMenu, saving) + }) + } + SectionDivider() + } + } + } + } + if (!oneHandUI.value && !editMode.value) { + item { + CreateList() + } + } + } +} + +@Composable +fun ModalData.TagListEditor( + rhId: Long?, + chat: Chat? = null, + tagId: Long? = null, + emoji: String? = null, + name: String = "", + close: () -> Unit +) { + val userTags = remember { chatModel.userTags } + val oneHandUI = remember { appPrefs.oneHandUI.state } + val keyboardState by getKeyboardState() + val newEmoji = remember { stateGetOrPutNullable("chatTagEmoji") { emoji } } + val newName = remember { stateGetOrPut("chatTagName") { name } } + val saving = remember { mutableStateOf(null) } + val trimmedName = remember { derivedStateOf { newName.value.trim() } } + val isDuplicateEmojiOrName = remember { + derivedStateOf { + userTags.value.any { tag -> + tag.chatTagId != tagId && + ((newEmoji.value != null && tag.chatTagEmoji == newEmoji.value) || tag.chatTagText == trimmedName.value) + } + } + } + + fun createTag() { + saving.value = true + withBGApi { + try { + val updatedTags = chatModel.controller.apiCreateChatTag(rhId, ChatTagData(newEmoji.value, trimmedName.value)) + if (updatedTags != null) { + saving.value = false + userTags.value = updatedTags + close() + } else { + saving.value = null + return@withBGApi + } + + if (chat != null) { + val createdTag = updatedTags.firstOrNull() { it.chatTagText == trimmedName.value && it.chatTagEmoji == newEmoji.value } + + if (createdTag != null) { + setTag(rhId, createdTag.chatTagId, chat, close = { + saving.value = false + close() + }) + } + } + } catch (e: Exception) { + Log.d(TAG, "createChatTag tag error: ${e.message}") + saving.value = null + } + } + } + + fun updateTag() { + saving.value = true + withBGApi { + try { + if (chatModel.controller.apiUpdateChatTag(rhId, tagId!!, ChatTagData(newEmoji.value, trimmedName.value))) { + userTags.value = userTags.value.map { tag -> + if (tag.chatTagId == tagId) { + tag.copy(chatTagEmoji = newEmoji.value, chatTagText = trimmedName.value) + } else { + tag + } + } + } else { + saving.value = null + return@withBGApi + } + saving.value = false + close() + } catch (e: Exception) { + Log.d(TAG, "ChatListTagEditor updateChatTag tag error: ${e.message}") + saving.value = null + } + } + } + + val showError = derivedStateOf { isDuplicateEmojiOrName.value && saving.value != false } + + ColumnWithScrollBar(Modifier.consumeWindowInsets(PaddingValues(bottom = if (oneHandUI.value) WindowInsets.ime.asPaddingValues().calculateBottomPadding().coerceIn(0.dp, WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) else 0.dp))) { + if (oneHandUI.value) { + Spacer(Modifier.weight(1f)) + } + ChatTagInput(newName, showError, newEmoji) + val disabled = saving.value == true || + (trimmedName.value == name && newEmoji.value == emoji) || + trimmedName.value.isEmpty() || + isDuplicateEmojiOrName.value + + SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) { + Text( + generalGetString(if (chat != null) MR.strings.add_to_list else if (tagId == null) MR.strings.create_list else MR.strings.save_list), + color = if (disabled) colors.secondary else colors.primary + ) + } + val showErrorMessage = isDuplicateEmojiOrName.value && saving.value != false + SectionCustomFooter { + Row( + Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = stringResource(MR.strings.error), + tint = if (showErrorMessage) Color.Red else Color.Transparent, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + generalGetString(MR.strings.duplicated_list_error), + color = if (showErrorMessage) colors.secondary else Color.Transparent, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } + } + } +} + +@Composable +private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState, saving: MutableState) { + ItemAction( + stringResource(MR.strings.delete_chat_list_menu_action), + painterResource(MR.images.ic_delete), + onClick = { + deleteTagDialog(rhId, tag, saving) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.edit_chat_list_menu_action), + painterResource(MR.images.ic_edit), + onClick = { + showMenu.value = false + ModalManager.start.showModalCloseable { close -> + TagListEditor( + rhId = rhId, + tagId = tag.chatTagId, + close = close, + emoji = tag.chatTagEmoji, + name = tag.chatTagText + ) + } + }, + color = MenuTextColor + ) +} + +@Composable +expect fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) + +@Composable +fun TagListNameTextField(name: MutableState, showError: State) { + var focused by rememberSaveable { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + val strokeColor by remember { + derivedStateOf { + if (showError.value) { + Color.Red + } else { + if (focused) { + CurrentColors.value.colors.secondary.copy(alpha = 0.6f) + } else { + CurrentColors.value.colors.secondary.copy(alpha = 0.3f) + } + } + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + BasicTextField( + value = name.value, + onValueChange = { name.value = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 50.dp) + .onFocusChanged { focused = it.isFocused } + .focusRequester(focusRequester), + textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colors.secondary), + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = name.value, + innerTextField = innerTextField, + placeholder = { + Text(generalGetString(MR.strings.list_name_field_placeholder), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp)) + }, + contentPadding = PaddingValues(), + label = null, + visualTransformation = VisualTransformation.None, + leadingIcon = null, + singleLine = true, + enabled = true, + isError = false, + interactionSource = remember { MutableInteractionSource() }, + colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) + ) + } + ) + Divider(color = strokeColor, thickness = if (focused) 2.dp else 1.dp) + } +} + +private fun setTag(rhId: Long?, tagId: Long?, chat: Chat, close: () -> Unit) { + withBGApi { + val tagIds: List = if (tagId == null) { + emptyList() + } else { + listOf(tagId) + } + + try { + val result = apiSetChatTags(rh = rhId, type = chat.chatInfo.chatType, id = chat.chatInfo.apiId, tagIds = tagIds) + + if (result != null) { + val oldTags = chat.chatInfo.chatTags + chatModel.userTags.value = result.first + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> { + val contact = cInfo.contact.copy(chatTags = result.second) + withChats { + updateContact(rhId, contact) + } + } + + is ChatInfo.Group -> { + val group = cInfo.groupInfo.copy(chatTags = result.second) + withChats { + updateGroup(rhId, group) + } + } + + else -> {} + } + chatModel.moveChatTagUnread(chat, oldTags, result.second) + close() + } + } catch (e: Exception) { + Log.d(TAG, "setChatTag error: ${e.message}") + } + } +} + +private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState) { + withBGApi { + saving.value = true + + try { + val tagId = tag.chatTagId + if (apiDeleteChatTag(rhId, tagId)) { + chatModel.userTags.value = chatModel.userTags.value.filter { it.chatTagId != tagId } + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } + chatModel.chats.value.forEach { c -> + when (val cInfo = c.chatInfo) { + is ChatInfo.Direct -> { + val contact = cInfo.contact.copy(chatTags = cInfo.contact.chatTags.filter { it != tagId }) + withChats { + updateContact(rhId, contact) + } + } + is ChatInfo.Group -> { + val group = cInfo.groupInfo.copy(chatTags = cInfo.groupInfo.chatTags.filter { it != tagId }) + withChats { + updateGroup(rhId, group) + } + } + else -> {} + } + } + } + + } catch (e: Exception) { + Log.d(TAG, "deleteTag error: ${e.message}") + } finally { + saving.value = false + } + } +} + +private fun deleteTagDialog(rhId: Long?, tag: ChatTag, saving: MutableState) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_chat_list_question), + text = String.format(generalGetString(MR.strings.delete_chat_list_warning), tag.chatTagText), + buttons = { + SectionItemView({ + AlertManager.shared.hideAlert() + deleteTag(rhId, tag, saving) + }) { + Text( + generalGetString(MR.strings.confirm_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = colors.error + ) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text( + stringResource(MR.strings.cancel_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = colors.primary + ) + } + } + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt new file mode 100644 index 0000000000..cded400892 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DragAndDrop.kt @@ -0,0 +1,177 @@ +package chat.simplex.common.views.helpers + +/* + * This was adapted from google example of drag and drop for Jetpack Compose + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt + */ + +import androidx.compose.animation.core.* +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.lazy.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +@Composable +fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState { + val scope = rememberCoroutineScope() + val state = + remember(lazyListState) { + DragDropState(state = lazyListState, onMove = onMove, scope = scope) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +class DragDropState +internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = + draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + val touchY = offset.y.toInt() + val item = state.layoutInfo.visibleItemsInfo.minByOrNull { + val itemCenter = (it.offset - state.layoutInfo.viewportStartOffset) + it.size / 2 + kotlin.math.abs(touchY - itemCenter) // Find the item closest to the touch position, needs to take viewportStartOffset into account + } + + if (item != null) { + draggingItemIndex = item.index + draggingItemInitialOffset = item.offset + } + } + + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f) + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = + state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + if (targetItem != null) { + if ( + draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + state.requestScrollToItem( + state.firstVisibleItemIndex, + state.firstVisibleItemScrollOffset + ) + } + onMove.invoke(draggingItem.index, targetItem.index) + draggingItemIndex = targetItem.index + } else { + val overscroll = + when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() } + ) + } +} + +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = + if (dragging) { + Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier.zIndex(1f).graphicsLayer { + translationY = dragDropState.previousItemOffset.value + } + } else { + Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) + } + Column(modifier = modifier.then(draggingModifier)) { content(dragging) } +} 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 a45d1a5458..ffbe473df8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -188,6 +188,9 @@ Error updating user privacy Slow function Execution of function takes too long time: %1$d seconds: %2$s + Error updating chat list + Error creating chat list + Error loading chat lists Instant notifications @@ -361,6 +364,7 @@ Revoke Forward Download + List Message forwarded No direct connection yet, message is forwarded by admin. @@ -390,6 +394,10 @@ You have no chats Loading chats… No filtered chats + No chats in list %s. + No unread chats + No chats + No chats found Tap to Connect Connect with %1$s? Search or paste SimpleX link @@ -409,6 +417,12 @@ %1$d file(s) were deleted. Download %1$s messages not forwarded + Favorites + Contacts + Groups + Businesses + All + Add list Share message… @@ -627,6 +641,16 @@ Favorite Unfavorite + + Create list + Add to list + Save list + List name... + List name and emoji should be different for all lists. + Delete + Delete list? + All chats will be removed from the list %s, and the list deleted + Edit You invited a contact diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg new file mode 100644 index 0000000000..99d3e66fe8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg new file mode 100644 index 0000000000..f30bc0db2c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg new file mode 100644 index 0000000000..02c84c9d05 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg new file mode 100644 index 0000000000..3b58600ae2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg new file mode 100644 index 0000000000..0ed867b156 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg new file mode 100644 index 0000000000..fb5c122eec --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 9789fa3d1a..a1f70213d0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -4,8 +4,7 @@ import SectionDivider import androidx.compose.foundation.* import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme +import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt new file mode 100644 index 0000000000..1c388e3d07 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt @@ -0,0 +1,57 @@ +package chat.simplex.common.views.chatlist + +import SectionItemView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.chat.item.isShortEmoji +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SingleEmojiInput(emoji) + TagListNameTextField(name, showError = showError) + } +} + +@Composable +private fun SingleEmojiInput( + emoji: MutableState +) { + TextField( + value = emoji.value?.let { TextFieldValue(it) } ?: TextFieldValue(""), + onValueChange = { newValue -> + if (newValue.text == emoji.value) return@TextField + val newValueClamped = newValue.text.replace(emoji.value ?: "", "") + emoji.value = if (isShortEmoji(newValueClamped)) newValueClamped else null + }, + singleLine = true, + maxLines = 1, + modifier = Modifier + .size(60.dp) + .padding(4.dp), + placeholder = { + Icon( + painter = painterResource(MR.images.ic_add_reaction), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + }, + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f), + unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f), + cursorColor = MaterialTheme.colors.secondary, + ), + ) +} From a0cc177eb51b8811a3d8b70b21076ab0de6437cd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 25 Dec 2024 12:15:10 +0000 Subject: [PATCH 20/95] ios: update library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d342b277ce..ff29561aca 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -517,9 +517,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -673,9 +673,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -756,8 +756,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */, ); path = Libraries; sourceTree = ""; From 400967b03baa1484d0b5c529944a352fc7d6ff84 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 25 Dec 2024 16:54:21 +0000 Subject: [PATCH 21/95] ui: fix saving operators (#5428) --- .../Views/UserSettings/NetworkAndServers/OperatorView.swift | 2 +- apps/ios/SimpleXChat/APITypes.swift | 6 +++--- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index cea9dd0635..24da6a94a8 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -53,7 +53,7 @@ struct OperatorView: View { ServersErrorView(errStr: errStr) } else { switch (userServers[operatorIndex].operator_.conditionsAcceptance) { - case let .accepted(acceptedAt): + case let .accepted(acceptedAt, _): if let acceptedAt = acceptedAt { Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 884993f542..ae7e67b32f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1290,7 +1290,7 @@ public struct ServerOperatorConditions: Decodable { } public enum ConditionsAcceptance: Equatable, Codable, Hashable { - case accepted(acceptedAt: Date?) + case accepted(acceptedAt: Date?, autoAccepted: Bool) // If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator. // No deadline indicates it's required to accept conditions for the operator to start using it. case required(deadline: Date?) @@ -1364,7 +1364,7 @@ public struct ServerOperator: Identifiable, Equatable, Codable { tradeName: "SimpleX Chat", legalName: "SimpleX Chat Ltd", serverDomains: ["simplex.im"], - conditionsAcceptance: .accepted(acceptedAt: nil), + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: true, smpRoles: ServerRoles(storage: true, proxy: true), xftpRoles: ServerRoles(storage: true, proxy: true) @@ -1397,7 +1397,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable { tradeName: "", legalName: "", serverDomains: [], - conditionsAcceptance: .accepted(acceptedAt: nil), + conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: false, smpRoles: ServerRoles(storage: true, proxy: true), xftpRoles: ServerRoles(storage: true, proxy: true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 08051927fd..79fd85401e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3757,7 +3757,7 @@ data class ServerOperatorConditionsDetail( @Serializable() sealed class ConditionsAcceptance { - @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance() + @Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?, val autoAccepted: Boolean) : ConditionsAcceptance() @Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance() val conditionsAccepted: Boolean @@ -3801,7 +3801,7 @@ data class ServerOperator( tradeName = "SimpleX Chat", legalName = "SimpleX Chat Ltd", serverDomains = listOf("simplex.im"), - conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null), + conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false), enabled = true, smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) @@ -3883,7 +3883,7 @@ data class UserOperatorServers( tradeName = "", legalName = null, serverDomains = emptyList(), - conditionsAcceptance = ConditionsAcceptance.Accepted(null), + conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false), enabled = false, smpRoles = ServerRoles(storage = true, proxy = true), xftpRoles = ServerRoles(storage = true, proxy = true) From 086e375bac1054d98a32b323e2bba8b96602cb6f Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 25 Dec 2024 22:09:18 +0000 Subject: [PATCH 22/95] ui: chat tag fixes (#5427) * ui: chat tag fixes * fix switching tags * change * android: fix switching profile * change * sp * change --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 6 +- .../Views/ChatList/ChatListNavLink.swift | 7 +- .../chat/simplex/common/model/SimpleXAPI.kt | 1 + .../common/views/chatlist/ChatListView.kt | 179 +++++++++--------- .../common/views/chatlist/TagListView.kt | 93 ++++----- .../views/chatlist/TagListView.desktop.kt | 55 ++++-- 6 files changed, 178 insertions(+), 163 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 31bacd1ba7..dad84571ea 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -163,7 +163,7 @@ class ChatTagsModel: ObservableObject { func markChatTagRead(_ chat: Chat) -> Void { if chat.unreadTag, let tags = chat.chatInfo.chatTags { - markChatTagRead_(chat, tags) + decTagsReadCount(tags) } } @@ -175,11 +175,11 @@ class ChatTagsModel: ObservableObject { unreadTags[tag] = (unreadTags[tag] ?? 0) + 1 } } else if !nowUnread && wasUnread { - markChatTagRead_(chat, tags) + decTagsReadCount(tags) } } - private func markChatTagRead_(_ chat: Chat, _ tags: [Int64]) -> Void { + func decTagsReadCount(_ tags: [Int64]) -> Void { for tag in tags { if let count = unreadTags[tag] { unreadTags[tag] = max(0, count - 1) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 117a8fa795..36a98e3f2f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -748,7 +748,11 @@ private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> V await MainActor.run { let m = ChatModel.shared - ChatTagsModel.shared.userTags = userTags + let tm = ChatTagsModel.shared + tm.userTags = userTags + if chat.unreadTag, let tags = chat.chatInfo.chatTags { + tm.decTagsReadCount(tags) + } if var contact = chat.chatInfo.contact { contact.chatTags = chatTags m.updateContact(contact) @@ -756,6 +760,7 @@ private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> V group.chatTags = chatTags m.updateGroup(group) } + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false) closeSheet() } } catch let error { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 22dda005af..a2deccd39a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -625,6 +625,7 @@ object ChatController { updateChats(chats) } chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() + chatModel.activeChatTagFilter.value = null chatModel.updateChatTags(rhId) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 2502c5c31b..d3f6a7b3af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -33,8 +33,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call -import chat.simplex.common.views.chat.item.CIFileViewScope -import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* @@ -877,7 +876,7 @@ private fun NoChatsView(searchText: MutableState) { is ActiveFilter.UserTag -> Text(String.format(generalGetString(MR.strings.no_chats_in_list), activeFilter.tag.chatTagText), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) is ActiveFilter.Unread -> { Row( - Modifier.clip(shape = RoundedCornerShape(percent = 50)).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF), + Modifier.clip(shape = CircleShape).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -917,6 +916,8 @@ private fun ChatListFeatureCards() { } } +private val TAG_MIN_HEIGHT = 35.dp + @Composable private fun TagsView() { val userTags = remember { chatModel.userTags } @@ -929,7 +930,7 @@ private fun TagsView() { ModalManager.start.showCustomModal { close -> val editMode = remember { stateGetOrPut("editMode") { false } } ModalView(close, showClose = true, endButtons = { - TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = RoundedCornerShape(percent = 50))) { + TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = CircleShape)) { Text(stringResource(if (editMode.value) MR.strings.cancel_verb else MR.strings.edit_verb)) } }) { @@ -937,7 +938,7 @@ private fun TagsView() { } } } - val rowSizeModifier = Modifier.sizeIn(minHeight = 35.dp * fontSizeSqrtMultiplier) + val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) TagsRow { if (presetTags.size > 1) { @@ -946,9 +947,7 @@ private fun TagsView() { ExpandedTagFilterView(tag) } } else { - Column(rowSizeModifier, verticalArrangement = Arrangement.Center) { - CollapsedTagsFilterView() - } + CollapsedTagsFilterView() } } @@ -958,71 +957,68 @@ private fun TagsView() { else -> false } val interactionSource = remember { MutableInteractionSource() } - - Column(rowSizeModifier, verticalArrangement = Arrangement.Center) { - Row( - Modifier - .clip(shape = RoundedCornerShape(percent = 50)) - .combinedClickable( - onClick = { - if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { - chatModel.activeChatTagFilter.value = null - } else { - chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) - } - }, - onLongClick = { showTagList() }, - interactionSource = interactionSource, - indication = LocalIndication.current - ) - .onRightClick { showTagList() } - .padding(4.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - if (tag.chatTagEmoji != null) { - Text( - tag.chatTagEmoji - ) - } else { - Icon( - painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), - null, - Modifier.size(20.dp), - tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground - ) - } - Spacer(Modifier.width(4.dp)) - Box { - val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" - val invisibleText = buildAnnotatedString { - append(tag.chatTagText) - withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { - append(badgeText) + Row( + rowSizeModifier + .clip(shape = CircleShape) + .combinedClickable( + onClick = { + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) } + }, + onLongClick = { showTagList() }, + interactionSource = interactionSource, + indication = LocalIndication.current + ) + .onRightClick { showTagList() } + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) + } else { + Icon( + painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), + null, + Modifier.size(18.sp.toDp()), + tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground + ) + } + Spacer(Modifier.width(4.dp)) + Box { + val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" + val invisibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { + append(badgeText) } - Text( - text = invisibleText, - fontWeight = FontWeight.SemiBold, - color = Color.Transparent, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - // Visible text with styles - val visibleText = buildAnnotatedString { - append(tag.chatTagText) - withStyle(SpanStyle(fontSize = 12.sp, color = MaterialTheme.colors.primary)) { - append(badgeText) - } - } - Text( - text = visibleText, - fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal, - color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) } + Text( + text = invisibleText, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + color = Color.Transparent, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Visible text with styles + val visibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) { + append(badgeText) + } + } + Text( + text = visibleText, + fontWeight = if (current) FontWeight.Medium else FontWeight.Normal, + fontSize = 15.sp, + color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } } @@ -1033,16 +1029,16 @@ private fun TagsView() { } } - Column(rowSizeModifier, verticalArrangement = Arrangement.Center) { - if (userTags.value.isEmpty()) { - Row(Modifier.clip(shape = RoundedCornerShape(percent = 50)).then(plusClickModifier).padding(vertical = 4.dp), horizontalArrangement = Arrangement.Center) { - Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), tint = MaterialTheme.colors.secondary) - Spacer(Modifier.width(2.dp)) - Text(stringResource(MR.strings.chat_list_add_list), color = MaterialTheme.colors.secondary) - } - } else { + if (userTags.value.isEmpty()) { + Row(rowSizeModifier.clip(shape = CircleShape).then(plusClickModifier).padding(start = 2.dp, top = 4.dp, end = 6.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(2.dp)) + Text(stringResource(MR.strings.chat_list_add_list), color = MaterialTheme.colors.secondary, fontSize = 15.sp) + } + } else { + Box(rowSizeModifier, contentAlignment = Alignment.Center) { Icon( - painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.clip(shape = CircleShape).then(plusClickModifier).padding(4.dp), tint = MaterialTheme.colors.secondary + painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.clip(shape = CircleShape).then(plusClickModifier).padding(2.dp), tint = MaterialTheme.colors.secondary ) } } @@ -1074,12 +1070,13 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { is ActiveFilter.PresetTag -> af.tag == tag else -> false } + val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) val (icon, text) = presetTagLabel(tag, active) val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Row( - modifier = Modifier - .clip(shape = RoundedCornerShape(percent = 50)) + modifier = rowSizeModifier + .clip(shape = CircleShape) .clickable { if (activeFilter.value == ActiveFilter.PresetTag(tag)) { chatModel.activeChatTagFilter.value = null @@ -1087,7 +1084,7 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(tag) } } - .padding(4.dp) + .padding(horizontal = 5.dp, vertical = 4.dp) , verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center @@ -1095,6 +1092,7 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { Icon( painterResource(icon), stringResource(text), + Modifier.size(18.sp.toDp()), tint = color ) Spacer(Modifier.width(4.dp)) @@ -1102,12 +1100,14 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { Text( stringResource(text), color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal, + fontWeight = if (active) FontWeight.Medium else FontWeight.Normal, + fontSize = 15.sp ) Text( stringResource(text), color = Color.Transparent, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.Medium, + fontSize = 15.sp ) } } @@ -1125,17 +1125,20 @@ private fun CollapsedTagsFilterView() { else -> null } - Column(Modifier + val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) + Box(rowSizeModifier + .padding(vertical = 4.dp) .clip(shape = CircleShape) - .clickable { showMenu.value = true } - .padding(4.dp) + .size(30.sp.toDp()) + .clickable { showMenu.value = true }, + contentAlignment = Alignment.Center ) { if (selectedPresetTag != null) { val (icon, text) = presetTagLabel(selectedPresetTag, true) - Icon( painterResource(icon), stringResource(text), + Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.secondary ) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 7b204bc07b..2cd0c953c7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.* import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material.TextFieldDefaults.indicatorLine import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -35,6 +36,7 @@ import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.ReactionIcon import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -148,11 +150,9 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu verticalAlignment = Alignment.CenterVertically ) { if (tag.chatTagEmoji != null) { - Text( - tag.chatTagEmoji - ) + ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) } else { - Icon(painterResource(MR.images.ic_label), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Icon(painterResource(MR.images.ic_label), null, Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.onBackground) } Spacer(Modifier.padding(horizontal = 4.dp)) Text( @@ -196,7 +196,6 @@ fun ModalData.TagListEditor( ) { val userTags = remember { chatModel.userTags } val oneHandUI = remember { appPrefs.oneHandUI.state } - val keyboardState by getKeyboardState() val newEmoji = remember { stateGetOrPutNullable("chatTagEmoji") { emoji } } val newName = remember { stateGetOrPut("chatTagName") { name } } val saving = remember { mutableStateOf(null) } @@ -351,53 +350,45 @@ expect fun ChatTagInput(name: MutableState, showError: State, e fun TagListNameTextField(name: MutableState, showError: State) { var focused by rememberSaveable { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } - val strokeColor by remember { - derivedStateOf { - if (showError.value) { - Color.Red - } else { - if (focused) { - CurrentColors.value.colors.secondary.copy(alpha = 0.6f) - } else { - CurrentColors.value.colors.secondary.copy(alpha = 0.3f) - } - } + val interactionSource = remember { MutableInteractionSource() } + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f), + unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f), + cursorColor = MaterialTheme.colors.secondary, + ) + BasicTextField( + value = name.value, + onValueChange = { name.value = it }, + interactionSource = interactionSource, + modifier = Modifier + .fillMaxWidth() + .indicatorLine(true, showError.value, interactionSource, colors) + .heightIn(min = TextFieldDefaults.MinHeight) + .onFocusChanged { focused = it.isFocused } + .focusRequester(focusRequester), + textStyle = TextStyle(fontSize = 18.sp, color = MaterialTheme.colors.onBackground), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colors.secondary), + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = name.value, + innerTextField = innerTextField, + placeholder = { + Text(generalGetString(MR.strings.list_name_field_placeholder), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp)) + }, + contentPadding = PaddingValues(), + label = null, + visualTransformation = VisualTransformation.None, + leadingIcon = null, + singleLine = true, + enabled = true, + isError = false, + interactionSource = remember { MutableInteractionSource() }, + colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) + ) } - } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - BasicTextField( - value = name.value, - onValueChange = { name.value = it }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 50.dp) - .onFocusChanged { focused = it.isFocused } - .focusRequester(focusRequester), - textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground), - singleLine = true, - cursorBrush = SolidColor(MaterialTheme.colors.secondary), - decorationBox = @Composable { innerTextField -> - TextFieldDefaults.TextFieldDecorationBox( - value = name.value, - innerTextField = innerTextField, - placeholder = { - Text(generalGetString(MR.strings.list_name_field_placeholder), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp)) - }, - contentPadding = PaddingValues(), - label = null, - visualTransformation = VisualTransformation.None, - leadingIcon = null, - singleLine = true, - enabled = true, - isError = false, - interactionSource = remember { MutableInteractionSource() }, - colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) - ) - } - ) - Divider(color = strokeColor, thickness = if (focused) 2.dp else 1.dp) - } + ) } private fun setTag(rhId: Long?, tagId: Long?, chat: Chat, close: () -> Unit) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt index 1c388e3d07..75a76014a9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt @@ -5,13 +5,19 @@ import androidx.compose.foundation.layout.* 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 androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import chat.simplex.common.ui.theme.CurrentColors -import chat.simplex.common.ui.theme.DEFAULT_PADDING +import androidx.compose.ui.unit.sp +import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex +import chat.simplex.common.views.chat.item.isHeartEmoji import chat.simplex.common.views.chat.item.isShortEmoji +import chat.simplex.common.views.helpers.toDp import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @@ -27,31 +33,40 @@ actual fun ChatTagInput(name: MutableState, showError: State, e private fun SingleEmojiInput( emoji: MutableState ) { + val state = remember { mutableStateOf(TextFieldValue(emoji.value ?: "")) } + val colors = TextFieldDefaults.textFieldColors( + textColor = if (isHeartEmoji(emoji.value ?: "")) Color(0xffD63C31) else MaterialTheme.colors.onPrimary, + backgroundColor = Color.Unspecified, + focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f), + unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f), + cursorColor = MaterialTheme.colors.secondary, + ) TextField( - value = emoji.value?.let { TextFieldValue(it) } ?: TextFieldValue(""), + value = state.value, onValueChange = { newValue -> - if (newValue.text == emoji.value) return@TextField + if (newValue.text == emoji.value) { + state.value = newValue + return@TextField + } val newValueClamped = newValue.text.replace(emoji.value ?: "", "") - emoji.value = if (isShortEmoji(newValueClamped)) newValueClamped else null + val isEmoji = isShortEmoji(newValueClamped) + emoji.value = if (isEmoji) newValueClamped else null + state.value = if (isEmoji) newValue else TextFieldValue() }, singleLine = true, - maxLines = 1, modifier = Modifier - .size(60.dp) - .padding(4.dp), + .padding(4.dp) + .size(width = TextFieldDefaults.MinHeight.value.sp.toDp(), height = TextFieldDefaults.MinHeight), + textStyle = LocalTextStyle.current.copy(fontFamily = EmojiFont, textAlign = TextAlign.Center), placeholder = { - Icon( - painter = painterResource(MR.images.ic_add_reaction), - contentDescription = null, - tint = MaterialTheme.colors.secondary - ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(MR.images.ic_add_reaction), + contentDescription = null, + tint = MaterialTheme.colors.secondary + ) + } }, - shape = RoundedCornerShape(8.dp), - colors = TextFieldDefaults.textFieldColors( - backgroundColor = Color.Unspecified, - focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f), - unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f), - cursorColor = MaterialTheme.colors.secondary, - ), + colors = colors, ) } From 00bc59b3a044f7964f379a596e3e43395b23813b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 25 Dec 2024 22:34:55 +0000 Subject: [PATCH 23/95] android: fix for disabled notifications (#5431) * android: fix for disabled notifications * change * prevent showing alert multiple times * changes --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../java/chat/simplex/app/SimplexService.kt | 100 ++++++++++-------- .../SetNotificationsMode.android.kt | 24 +++-- .../chat/simplex/common/model/SimpleXAPI.kt | 2 + .../common/views/chatlist/ChatListView.kt | 6 ++ 4 files changed, 82 insertions(+), 50 deletions(-) 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 cb50336fce..6ca8dd43a0 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 @@ -470,53 +470,65 @@ class SimplexService: Service() { ) } - private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert { - val ignoreOptimization = { - AlertManager.shared.hideAlert() - askAboutIgnoringBatteryOptimization() + private var showingIgnoreNotification = false + private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) { + // that's workaround for situation when the app receives onPause/onResume events multiple times + // (for example, after showing system alert for enabling notifications) which triggers showing that alert multiple times + if (showingIgnoreNotification) { + return } - val disableNotifications = { - AlertManager.shared.hideAlert() - disableNotifications(mode, showOffAlert) - } - AlertDialog( - onDismissRequest = disableNotifications, - title = { - Row { - Icon( - painterResource(MR.images.ic_bolt), - contentDescription = - if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications), - ) - Text( - if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications), - fontWeight = FontWeight.Bold - ) - } - }, - text = { - Column { - Text( - if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc), - Modifier.padding(bottom = 8.dp) - ) - Text(annotatedStringResource(MR.strings.turn_off_battery_optimization)) - - if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) { - Text(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization), - Modifier.padding(top = 8.dp) + showingIgnoreNotification = true + AlertManager.shared.showAlert { + val ignoreOptimization = { + AlertManager.shared.hideAlert() + showingIgnoreNotification = false + askAboutIgnoringBatteryOptimization() + } + val disableNotifications = { + AlertManager.shared.hideAlert() + showingIgnoreNotification = false + disableNotifications(mode, showOffAlert) + } + AlertDialog( + onDismissRequest = disableNotifications, + title = { + Row { + Icon( + painterResource(MR.images.ic_bolt), + contentDescription = + if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications), + ) + Text( + if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications), + fontWeight = FontWeight.Bold ) } - } - }, - dismissButton = { - TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) } - }, - confirmButton = { - TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) } - }, - shape = RoundedCornerShape(corner = CornerSize(25.dp)) - ) + }, + text = { + Column { + Text( + if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc), + Modifier.padding(bottom = 8.dp) + ) + Text(annotatedStringResource(MR.strings.turn_off_battery_optimization)) + + if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) { + Text( + annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization), + Modifier.padding(top = 8.dp) + ) + } + } + }, + dismissButton = { + TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) } + }, + confirmButton = { + TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } } private fun showBGServiceNoticeSystemRestricted(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt index a39e71947d..0378fcbd7a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt @@ -4,19 +4,31 @@ import android.Manifest import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.platform.ntfManager -import com.google.accompanist.permissions.PermissionStatus -import com.google.accompanist.permissions.rememberPermissionState +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import com.google.accompanist.permissions.* @Composable actual fun SetNotificationsModeAdditions() { if (Build.VERSION.SDK_INT >= 33) { val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) LaunchedEffect(notificationsPermissionState.status == PermissionStatus.Granted) { - if (notificationsPermissionState.status == PermissionStatus.Granted) { - ntfManager.androidCreateNtfChannelsMaybeShowAlert() + val canAsk = appPrefs.canAskToEnableNotifications.get() + if (notificationsPermissionState.status is PermissionStatus.Denied) { + if (notificationsPermissionState.status.shouldShowRationale || !canAsk) { + if (canAsk) { + appPrefs.canAskToEnableNotifications.set(false) + } + Log.w(TAG, "Notifications are disabled and nobody will ask to enable them") + } else { + notificationsPermissionState.launchPermissionRequest() + } } else { - notificationsPermissionState.launchPermissionRequest() + if (!canAsk) { + // the user allowed notifications in system alert or manually in settings, allow to ask him next time if needed + appPrefs.canAskToEnableNotifications.set(true) + } + ntfManager.androidCreateNtfChannelsMaybeShowAlert() } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 79fd85401e..f8d67c6f73 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -80,6 +80,7 @@ class AppPreferences { if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default ) { NotificationsMode.values().firstOrNull { it.name == this } } val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name) + val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true) val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) @@ -358,6 +359,7 @@ class AppPreferences { private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode" private const val SHARED_PREFS_NOTIFICATION_PREVIEW_MODE = "NotificationPreviewMode" + private const val SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS = "CanAskToEnableNotifications" private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index ff776bc8ca..2ed6315466 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -187,6 +187,12 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow Date: Wed, 25 Dec 2024 22:57:04 +0000 Subject: [PATCH 24/95] 6.2.3: ios 257, android 265, desktop 85 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++--- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 910f5d2360..33b0642d2b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -1931,7 +1931,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1956,7 +1956,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1980,7 +1980,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2005,7 +2005,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2021,11 +2021,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2041,11 +2041,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2066,7 +2066,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2081,7 +2081,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2103,7 +2103,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2118,7 +2118,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2140,7 +2140,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2166,7 +2166,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2191,7 +2191,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2217,7 +2217,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2242,7 +2242,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2257,7 +2257,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 256; + CURRENT_PROJECT_VERSION = 257; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.2; + MARKETING_VERSION = 6.2.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 00c7578a8e..9af367a5f6 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.2.2 -android.version_code=263 +android.version_name=6.2.3 +android.version_code=265 -desktop.version_name=6.2.2 -desktop.version_code=84 +desktop.version_name=6.2.3 +desktop.version_code=85 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From 4250a19299447a3246cc79122569f88448576543 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Thu, 26 Dec 2024 12:48:18 +0400 Subject: [PATCH 25/95] flatpak: update metainfo (#5433) --- .../flatpak/chat.simplex.simplex.metainfo.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 182976d030..5ed491f6ab 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,25 @@ + + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-3:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html From fc7f509364680297de6ba7422d779525f98630a7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 26 Dec 2024 14:12:51 +0000 Subject: [PATCH 26/95] core: 6.3.0.0 (simplexmq 6.3.0.0) --- cabal.project | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat.hs | 4 ---- src/Simplex/Chat/Remote.hs | 4 ++-- 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/cabal.project b/cabal.project index ec29f320d7..ee3fab10bd 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: bf289023273f2b94f8649b4c641e1cc9996b8a4b + tag: 184a95cd2a2806fb3d318508ca1f6528201a8114 source-repository-package type: git diff --git a/package.yaml b/package.yaml index 57e67c3329..0a68ec30d7 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 6.2.2.0 +version: 6.3.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d1d3071b63..b68a60ec9b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."bf289023273f2b94f8649b4c641e1cc9996b8a4b" = "1qcyh8n3mws2vbnjw44ih2ji6s9p1dy5rmhs49zf3ia7llnsqzdl"; + "https://github.com/simplex-chat/simplexmq.git"."184a95cd2a2806fb3d318508ca1f6528201a8114" = "0zlxi2dfv86sph9ndnazlvqgy0kq5rd1j6pjh41pswhaz8diwcag"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index feabf61bc8..eec13dcd85 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: 6.2.2.0 +version: 6.3.0.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 69bdddd220..8d5e1fea8d 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -36,11 +36,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types -import Simplex.Chat.Types.Preferences -import Simplex.Chat.Types.Shared import Simplex.Chat.Util (encryptFile, liftIOEither, shuffle) -import qualified Simplex.Chat.Util as U -import Simplex.FileTransfer.Description (maxFileSize, maxFileSizeHard) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cfc4fe2fa0..8c7a0bc552 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -73,11 +73,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 2, 0, 7] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 2, 0, 7] +minRemoteHostVersion = AppVersion [6, 3, 0, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From 0a596e6417186f339189bcb96db637a7f8c327da Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 26 Dec 2024 19:20:23 +0000 Subject: [PATCH 27/95] 6.3-beta.0: ios 258, android 267, desktop 86 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 86f5aebcf2..6ac6a8f9fb 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -517,9 +517,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -673,9 +673,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -756,8 +756,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */, ); path = Libraries; sourceTree = ""; @@ -1935,7 +1935,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1960,7 +1960,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1984,7 +1984,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2009,7 +2009,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2025,11 +2025,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2045,11 +2045,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2070,7 +2070,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2085,7 +2085,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2107,7 +2107,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2122,7 +2122,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2144,7 +2144,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2170,7 +2170,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2195,7 +2195,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2221,7 +2221,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2246,7 +2246,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2261,7 +2261,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2280,7 +2280,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 258; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2295,7 +2295,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 9af367a5f6..cfcec12593 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.2.3 -android.version_code=265 +android.version_name=6.3-beta.0 +android.version_code=267 -desktop.version_name=6.2.3 -desktop.version_code=85 +desktop.version_name=6.3-beta.0 +desktop.version_code=86 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From ddf0adfc296f39d52a9e7d7329a9dec908cbad09 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 27 Dec 2024 15:31:13 +0000 Subject: [PATCH 28/95] build: remove package.yaml (#5440) * build: remove package.yaml * remove --- package.yaml | 187 ------------------------------- simplex-chat.cabal | 267 +++------------------------------------------ 2 files changed, 18 insertions(+), 436 deletions(-) delete mode 100644 package.yaml diff --git a/package.yaml b/package.yaml deleted file mode 100644 index 0a68ec30d7..0000000000 --- a/package.yaml +++ /dev/null @@ -1,187 +0,0 @@ -name: simplex-chat -version: 6.3.0.0 -#synopsis: -#description: -homepage: https://github.com/simplex-chat/simplex-chat#readme -license: AGPL-3 -author: simplex.chat -maintainer: chat@simplex.chat -copyright: 2020-22 simplex.chat -category: Web, System, Services, Cryptography -extra-source-files: - - README.md - - PRIVACY.md - - cabal.project - -dependencies: - - aeson == 2.2.* - - ansi-terminal >= 0.10 && < 0.12 - - async == 2.2.* - - attoparsec == 0.14.* - - base >= 4.7 && < 5 - - base64-bytestring >= 1.0 && < 1.3 - - composition == 1.0.* - - constraints >= 0.12 && < 0.14 - - containers == 0.6.* - - crypton == 0.34.* - - data-default >= 0.7 && < 0.8 - - directory == 1.3.* - - direct-sqlcipher == 2.3.* - - email-validate == 2.3.* - - exceptions == 0.10.* - - filepath == 1.4.* - - file-embed == 0.0.15.* - - http-types == 0.12.* - - http2 >= 4.2.2 && < 4.3 - - memory == 0.18.* - - mtl >= 2.3.1 && < 3.0 - - network >= 3.1.2.7 && < 3.2 - - network-transport == 0.5.6 - - optparse-applicative >= 0.15 && < 0.17 - - random >= 1.1 && < 1.3 - - record-hasfield == 1.0.* - - scientific ==0.3.7.* - - simple-logger == 0.1.* - - simplexmq >= 5.0 - - socks == 0.6.* - - sqlcipher-simple == 0.4.* - - stm == 2.5.* - - terminal == 0.2.* - - time == 1.12.* - - tls >= 1.9.0 && < 1.10 - - unliftio == 0.2.* - - unliftio-core == 0.2.* - - uuid == 1.3.* - - zip == 2.0.* - -flags: - swift: - description: Enable swift JSON format - manual: True - default: False - -when: - - condition: flag(swift) - cpp-options: - - -DswiftJSON - - condition: impl(ghc >= 9.6.2) - dependencies: - - bytestring == 0.11.* - - process == 1.6.* - - template-haskell == 2.20.* - - text >= 2.0.1 && < 2.2 - - condition: impl(ghc < 9.6.2) - dependencies: - - bytestring == 0.10.* - - process >= 1.6 && < 1.6.18 - - template-haskell == 2.16.* - - text >= 1.2.4.0 && < 1.3 - -library: - source-dirs: src - -executables: - simplex-chat: - source-dirs: apps/simplex-chat - main: Main.hs - dependencies: - - simplex-chat - - network == 3.1.* - - websockets == 0.12.* - ghc-options: - - -threaded - - simplex-bot: - source-dirs: apps/simplex-bot - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-bot-advanced: - source-dirs: apps/simplex-bot-advanced - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-broadcast-bot: - source-dirs: - - apps/simplex-broadcast-bot - - apps/simplex-broadcast-bot/src - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - - simplex-directory-service: - source-dirs: - - apps/simplex-directory-service - - apps/simplex-directory-service/src - main: Main.hs - dependencies: - - simplex-chat - ghc-options: - - -threaded - -tests: - simplex-chat-test: - source-dirs: - - tests - - apps/simplex-broadcast-bot/src - - apps/simplex-directory-service/src - main: Test.hs - when: - - condition: impl(ghc >= 9.6.2) - dependencies: - - hspec == 2.11.* - - condition: impl(ghc < 9.6.2) - dependencies: - - hspec == 2.7.* - dependencies: - - QuickCheck == 2.14.* - - simplex-chat - - async == 2.2.* - - deepseq == 1.4.* - - generic-random == 1.5.* - - network == 3.1.* - - silently == 1.2.* - - stm == 2.5.* - ghc-options: - - -threaded - -ghc-options: - # - -haddock - - -O2 - - -Weverything - - -Wno-missing-exported-signatures - - -Wno-missing-import-lists - - -Wno-missed-specialisations - - -Wno-all-missed-specialisations - - -Wno-unsafe - - -Wno-safe - - -Wno-missing-local-signatures - - -Wno-missing-kind-signatures - - -Wno-missing-deriving-strategies - - -Wno-monomorphism-restriction - - -Wno-prepositive-qualified-module - - -Wno-unused-packages - - -Wno-implicit-prelude - - -Wno-missing-safe-haskell-mode - - -Wno-missing-export-lists - - -Wno-partial-fields - - -Wcompat - - -Werror=incomplete-record-updates - - -Werror=incomplete-patterns - - -Werror=missing-methods - - -Werror=incomplete-uni-patterns - - -Werror=tabs - - -Wredundant-constraints - - -Wincomplete-record-updates - - -Wunused-type-patterns - -default-extensions: - - StrictData diff --git a/simplex-chat.cabal b/simplex-chat.cabal index eec13dcd85..c471d342cb 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -239,7 +239,7 @@ library , record-hasfield ==1.0.* , scientific ==0.3.7.* , simple-logger ==0.1.* - , simplexmq >=5.0 + , simplexmq >=6.3 , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* @@ -276,61 +276,10 @@ executable simplex-bot StrictData ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* - , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* + base >=4.7 && <5 , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* - , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON - if impl(ghc >= 9.6.2) - build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 - if impl(ghc < 9.6.2) - build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 executable simplex-bot-advanced main-is: Main.hs @@ -342,61 +291,19 @@ executable simplex-bot-advanced StrictData ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* + async ==2.2.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-broadcast-bot main-is: Main.hs @@ -411,61 +318,20 @@ executable simplex-broadcast-bot Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* + async ==2.2.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-chat main-is: Main.hs @@ -479,61 +345,22 @@ executable simplex-chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* - , attoparsec ==0.14.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 - , composition ==1.0.* - , constraints >=0.12 && <0.14 - , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* , mtl >=2.3.1 && <3.0 , network ==3.1.* - , network-transport ==0.5.6 - , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* - , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* - , time ==1.12.* - , tls >=1.9.0 && <1.10 , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* , websockets ==0.12.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: - bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* - , text >=2.0.1 && <2.2 + text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: - bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* - , text >=1.2.4.0 && <1.3 + text >=1.2.4.0 && <1.3 executable simplex-directory-service main-is: Main.hs @@ -551,60 +378,26 @@ executable simplex-directory-service Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: - aeson ==2.2.* - , ansi-terminal >=0.10 && <0.12 - , async ==2.2.* + async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 - , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* - , constraints >=0.12 && <0.14 , containers ==0.6.* - , crypton ==0.34.* - , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* - , filepath ==1.4.* - , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl >=2.3.1 && <3.0 - , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* - , sqlcipher-simple ==0.4.* + , simplexmq >=6.3 , stm ==2.5.* - , terminal ==0.2.* , time ==1.12.* - , tls >=1.9.0 && <1.10 - , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* - , process ==1.6.* - , template-haskell ==2.20.* , text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: bytestring ==0.10.* - , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* , text >=1.2.4.0 && <1.3 test-suite simplex-chat-test @@ -659,60 +452,36 @@ test-suite simplex-chat-test , base >=4.7 && <5 , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* - , constraints >=0.12 && <0.14 , containers ==0.6.* , crypton ==0.34.* - , data-default ==0.7.* , deepseq ==1.4.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* - , email-validate ==2.3.* - , exceptions ==0.10.* - , file-embed ==0.0.15.* , filepath ==1.4.* , generic-random ==1.5.* , http-types ==0.12.* - , http2 >=4.2.2 && <4.3 , memory ==0.18.* , mtl >=2.3.1 && <3.0 , network ==3.1.* - , network-transport ==0.5.6 , optparse-applicative >=0.15 && <0.17 - , random >=1.1 && <1.3 - , record-hasfield ==1.0.* - , scientific ==0.3.7.* , silently ==1.2.* , simple-logger ==0.1.* , simplex-chat - , simplexmq >=5.0 - , socks ==0.6.* + , simplexmq >=6.3 , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , time ==1.12.* - , tls >=1.9.0 && <1.10 , unliftio ==0.2.* - , unliftio-core ==0.2.* - , uuid ==1.3.* - , zip ==2.0.* default-language: Haskell2010 - if flag(swift) - cpp-options: -DswiftJSON if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* + , hspec ==2.11.* , process ==1.6.* - , template-haskell ==2.20.* , text >=2.0.1 && <2.2 if impl(ghc < 9.6.2) build-depends: bytestring ==0.10.* + , hspec ==2.7.* , process >=1.6 && <1.6.18 - , template-haskell ==2.16.* , text >=1.2.4.0 && <1.3 - if impl(ghc >= 9.6.2) - build-depends: - hspec ==2.11.* - if impl(ghc < 9.6.2) - build-depends: - hspec ==2.7.* From d37d309f8590a4cde25453013d5395b79ed66375 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 28 Dec 2024 12:35:34 +0000 Subject: [PATCH 29/95] core: update simplemq (with PostgreSQL support) (#5444) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 5 +++-- src/Simplex/Chat/Archive.hs | 19 ++++++++++--------- src/Simplex/Chat/Controller.hs | 7 ++++--- src/Simplex/Chat/Core.hs | 5 +++-- src/Simplex/Chat/Library/Commands.hs | 6 ++++-- src/Simplex/Chat/Mobile.hs | 9 +++++---- src/Simplex/Chat/Store.hs | 10 ++++++---- src/Simplex/Chat/Store/AppSettings.hs | 2 +- src/Simplex/Chat/Store/Connections.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 2 +- src/Simplex/Chat/Store/Files.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 2 +- src/Simplex/Chat/Store/Messages.hs | 2 +- src/Simplex/Chat/Store/Migrations.hs | 2 +- src/Simplex/Chat/Store/NoteFolders.hs | 2 +- src/Simplex/Chat/Store/Profiles.hs | 2 +- src/Simplex/Chat/Store/Remote.hs | 2 +- src/Simplex/Chat/Store/Shared.hs | 2 +- src/Simplex/Chat/Terminal/Input.hs | 4 ++-- tests/ChatClient.hs | 5 +++-- tests/ChatTests/Utils.hs | 3 ++- tests/MobileTests.hs | 2 +- tests/SchemaDump.hs | 8 ++++---- 25 files changed, 60 insertions(+), 49 deletions(-) diff --git a/cabal.project b/cabal.project index ee3fab10bd..c7a5c080b4 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 184a95cd2a2806fb3d318508ca1f6528201a8114 + tag: 3cf9dacbc0f006153394a283fdcaf88ea0711c0f source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index b68a60ec9b..803044f19c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."184a95cd2a2806fb3d318508ca1f6528201a8114" = "0zlxi2dfv86sph9ndnazlvqgy0kq5rd1j6pjh41pswhaz8diwcag"; + "https://github.com/simplex-chat/simplexmq.git"."3cf9dacbc0f006153394a283fdcaf88ea0711c0f" = "19j32pv7xcvgz9c2wdnaa8ykixr3c09icy8yvdssb661bd1hc4wr"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8d5e1fea8d..bffe288cf4 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -36,13 +36,14 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types -import Simplex.Chat.Util (encryptFile, liftIOEither, shuffle) +import Simplex.Chat.Util (shuffle) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew)) +import Simplex.Messaging.Agent.Store.Common (DBStore (dbNew)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), SProtocolType (..), SubscriptionMode (..), UserProtocol) diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 218d1e1f2e..693e2fe29f 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -28,7 +28,8 @@ import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Chat.Util () import Simplex.Messaging.Agent.Client (agentClientStore) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), closeSQLiteStore, keyString, sqlString, storeKey) +import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, keyString, sqlString, storeKey) +import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore (..)) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory @@ -74,7 +75,7 @@ importArchive cfg@ArchiveConfig {archivePath} = withTempDir cfg "simplex-chat." $ \dir -> do Z.withArchive archivePath $ Z.unpackInto dir fs@StorageFiles {chatStore, agentStore, filesPath, assetsPath} <- storageFiles - liftIO $ closeSQLiteStore `withStores` fs + liftIO $ closeDBStore `withStores` fs backup `withDBs` fs copyFile (dir archiveChatDbFile) $ dbFilePath chatStore copyFile (dir archiveAgentDbFile) $ dbFilePath agentStore @@ -122,7 +123,7 @@ copyValidDirectoryFiles isFileError fromDir toDir = do deleteStorage :: CM () deleteStorage = do fs <- lift storageFiles - liftIO $ closeSQLiteStore `withStores` fs + liftIO $ closeDBStore `withStores` fs remove `withDBs` fs mapM_ removeDir $ filesPath fs mapM_ removeDir $ assetsPath fs @@ -132,8 +133,8 @@ deleteStorage = do removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d data StorageFiles = StorageFiles - { chatStore :: SQLiteStore, - agentStore :: SQLiteStore, + { chatStore :: DBStore, + agentStore :: DBStore, filesPath :: Maybe FilePath, assetsPath :: Maybe FilePath } @@ -156,20 +157,20 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D removeExported `withDBs` fs export `withDBs` fs -- closing after encryption prevents closing in case wrong encryption key was passed - liftIO $ closeSQLiteStore `withStores` fs + liftIO $ closeDBStore `withStores` fs (moveExported `withStores` fs) `catchChatError` \e -> (restore `withDBs` fs) >> throwError e where backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f - checkEncryption SQLiteStore {dbKey} = do + checkEncryption DBStore {dbKey} = do enc <- maybe True (not . BA.null) <$> readTVarIO dbKey when (enc && BA.null key) $ throwDBError DBErrorEncrypted when (not enc && not (BA.null key)) $ throwDBError DBErrorPlaintext exported = (<> ".exported") removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f) - moveExported SQLiteStore {dbFilePath = f, dbKey} = do + moveExported DBStore {dbFilePath = f, dbKey} = do renameFile (exported f) f atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey) export f = do @@ -219,5 +220,5 @@ sqlCipherTestKey (DBEncryptionKey key) = do withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore) -withStores :: Monad m => (SQLiteStore -> m b) -> StorageFiles -> m b +withStores :: Monad m => (DBStore -> m b) -> StorageFiles -> m b action `withStores` StorageFiles {chatStore, agentStore} = action chatStore >> action agentStore diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ffefddd701..f832542eef 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -73,9 +73,10 @@ import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWo import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction, withTransactionPriority) +import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore, withTransaction, withTransactionPriority) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, UpMigration) import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -203,7 +204,7 @@ defaultInlineFilesConfig = receiveInstant = True -- allow receiving instant files, within receiveChunks limit } -data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLiteStore} +data ChatDatabase = ChatDatabase {chatStore :: DBStore, agentStore :: DBStore} data ChatController = ChatController { currentUser :: TVar (Maybe User), @@ -213,7 +214,7 @@ data ChatController = ChatController firstTime :: Bool, smpAgent :: AgentClient, agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), - chatStore :: SQLiteStore, + chatStore :: DBStore, chatStoreChanged :: TVar Bool, -- if True, chat should be fully restarted random :: TVar ChaChaDRG, eventSeq :: TVar Int, diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 37a5d5bf0d..35f8cacdf5 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -26,7 +26,8 @@ import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Chat.View (serializeChatResponse) -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), SQLiteStore, withTransaction) +import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore (..), withTransaction) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) import System.Exit (exitFailure) import System.IO (hFlush, stdout) import Text.Read (readMaybe) @@ -66,7 +67,7 @@ sendChatCmdStr cc s = runReaderT (execChatCommand Nothing . encodeUtf8 $ T.pack sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc -getSelectActiveUser :: SQLiteStore -> IO (Maybe User) +getSelectActiveUser :: DBStore -> IO (Maybe User) getSelectActiveUser st = do users <- withTransaction st getUsers case find activeUser users of diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index cadf35f580..f167c13cbc 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -90,10 +90,12 @@ import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (execSQL, upMigration, withConnection) +import Simplex.Messaging.Agent.Store.SQLite (execSQL) +import Simplex.Messaging.Agent.Store.SQLite.Common (withConnection) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations +import Simplex.Messaging.Agent.Store.Shared (upMigration) import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (SMAlways), textToHostMode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) @@ -2311,7 +2313,7 @@ processChatCommand' vr = \case ShowVersion -> do -- simplexmqCommitQ makes iOS builds crash m( let versionInfo = coreVersionInfo "" - chatMigrations <- map upMigration <$> withFastStore' (Migrations.getCurrent . DB.conn) + chatMigrations <- map upMigration <$> withFastStore' Migrations.getCurrent agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} DebugLocks -> lift $ do diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 4f39c5d6a0..d790c5cd2c 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -48,7 +48,8 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, closeSQLiteStore, reopenSQLiteStore) +import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, reopenSQLiteStore) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) @@ -226,7 +227,7 @@ defaultMobileConfig = deviceNameForRemote = "Mobile" } -getActiveUser_ :: SQLiteStore -> IO (Maybe User) +getActiveUser_ :: DBStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) @@ -255,8 +256,8 @@ chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExcept chatCloseStore :: ChatController -> IO String chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do - closeSQLiteStore chatStore - closeSQLiteStore $ agentClientStore smpAgent + closeDBStore chatStore + closeDBStore $ agentClientStore smpAgent chatReopenStore :: ChatController -> IO String chatReopenStore ChatController {chatStore, smpAgent} = handleErr $ do diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 4b0591fb3a..58459e71e8 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1,5 +1,5 @@ module Simplex.Chat.Store - ( SQLiteStore, + ( DBStore, StoreError (..), ChatLockEntity (..), UserMsgReceiptSettings (..), @@ -17,10 +17,12 @@ import Data.ByteArray (ScrubbedBytes) import Simplex.Chat.Store.Migrations import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, MigrationError, SQLiteStore (..), createSQLiteStore, withTransaction) +import Simplex.Messaging.Agent.Store.SQLite (createDBStore) +import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore (..), withTransaction) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, MigrationError) -createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError SQLiteStore) -createChatStore dbPath key keepKey = createSQLiteStore dbPath key keepKey migrations +createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore dbPath key keepKey = createDBStore dbPath key keepKey migrations chatStoreFile :: FilePath -> FilePath chatStoreFile = (<> "_chat.db") diff --git a/src/Simplex/Chat/Store/AppSettings.hs b/src/Simplex/Chat/Store/AppSettings.hs index ee0dd30183..acecc577ca 100644 --- a/src/Simplex/Chat/Store/AppSettings.hs +++ b/src/Simplex/Chat/Store/AppSettings.hs @@ -8,7 +8,7 @@ import qualified Data.Aeson as J import Data.Maybe (fromMaybe) import Database.SQLite.Simple (Only (..)) import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings) -import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB saveAppSettings :: DB.Connection -> AppSettings -> IO () diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 6783dae99e..49f5656cb0 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -35,7 +35,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (ConnId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Util (eitherToMaybe) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7697f5d5d8..80172fc1eb 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -101,7 +101,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Protocol (SubscriptionMode (..)) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 2c02d872b1..e4390decf1 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -109,7 +109,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Util (week) import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 98173800cc..dac9f0126b 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -151,7 +151,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 13cadcca77..cebd47f5be 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -152,7 +152,7 @@ import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 33f5c329dc..c0865ed764 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -121,7 +121,7 @@ import Simplex.Chat.Migrations.M20241128_business_chats import Simplex.Chat.Migrations.M20241205_business_chat_members import Simplex.Chat.Migrations.M20241222_operator_conditions import Simplex.Chat.Migrations.M20241223_chat_tags -import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = diff --git a/src/Simplex/Chat/Store/NoteFolders.hs b/src/Simplex/Chat/Store/NoteFolders.hs index e8336a73d8..feb687f2ff 100644 --- a/src/Simplex/Chat/Store/NoteFolders.hs +++ b/src/Simplex/Chat/Store/NoteFolders.hs @@ -15,7 +15,7 @@ import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Types (NoteFolder (..), NoteFolderId, User (..)) import Simplex.Messaging.Agent.Protocol (UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB createNoteFolder :: DB.Connection -> User -> ExceptT StoreError IO () diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 013075841e..27855f50fb 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -100,7 +100,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Ratchet as CR diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index a88d87a04e..68cd1281e7 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -15,7 +15,7 @@ import qualified Database.SQLite.Simple as SQL import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Shared -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index c6ac85dbd3..a2b8fbdf6b 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -38,7 +38,7 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, UserId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..)) diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 836bcd7ec8..073c604009 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -36,7 +36,7 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Styled import Simplex.Chat.Terminal.Output import Simplex.Chat.Types (User (..)) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore, withTransaction) +import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore, withTransaction) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Util (catchAll_, safeDecodeUtf8, whenM) import System.Exit (exitSuccess) @@ -223,7 +223,7 @@ data AutoComplete | ACCommand Text | ACNone -updateTermState :: Maybe User -> SQLiteStore -> String -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState +updateTermState :: Maybe User -> DBStore -> String -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of CharKey c | ms == mempty || ms == shiftKey -> pure $ insertChars $ charsWithContact [c] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 585ef70f6e..c076ee3b4f 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -43,7 +43,8 @@ import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), closeSQLiteStore) +import Simplex.Messaging.Agent.Store.SQLite (closeDBStore) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) @@ -276,7 +277,7 @@ stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, c uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent - closeSQLiteStore chatStore + closeDBStore chatStore threadDelay 200000 withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index ce820b12a7..90d744766f 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -32,8 +32,9 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Client.Main (xftpClientCLI) -import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) +import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff) import Simplex.Messaging.Encoding.String diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 638d3d8078..26e33cb469 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -34,7 +34,7 @@ import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index d13dc94b63..8bbb68f3bf 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,8 +10,8 @@ import Data.List (dropWhileEnd) import Data.Maybe (fromJust, isJust) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), closeSQLiteStore, createSQLiteStore) -import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, createDBStore) +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Util (ifM, whenM) import System.Directory (doesFileExist, removeFile) @@ -68,9 +68,9 @@ testVerifyLintFKeyIndexes = withTmpFiles $ do testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createSQLiteStore testDB "" False noDownMigrations MCError + Right st <- createDBStore testDB "" False noDownMigrations MCError mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations - closeSQLiteStore st + closeDBStore st removeFile testDB whenM (doesFileExist testSchema) $ removeFile testSchema where From 206f7898c367389f1b1ab0a62b77ea6526156d85 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 28 Dec 2024 22:14:06 +0000 Subject: [PATCH 30/95] cli: option to disable vacuum on migration (#5446) * cli: option to disable vacuum on migration * update simplexmq * mobile options * use option in test --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat.hs | 8 ++++---- src/Simplex/Chat/Core.hs | 4 ++-- src/Simplex/Chat/Mobile.hs | 8 +++++--- src/Simplex/Chat/Options.hs | 11 +++++++++-- src/Simplex/Chat/Store.hs | 2 +- tests/ChatClient.hs | 11 ++++++----- tests/MobileTests.hs | 2 +- tests/SchemaDump.hs | 12 ++++++------ 10 files changed, 36 insertions(+), 26 deletions(-) diff --git a/cabal.project b/cabal.project index c7a5c080b4..6ff20fadc9 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 3cf9dacbc0f006153394a283fdcaf88ea0711c0f + tag: c7a07d22520f74f4023f597559abb3793285b5d6 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 803044f19c..5b01a3a330 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3cf9dacbc0f006153394a283fdcaf88ea0711c0f" = "19j32pv7xcvgz9c2wdnaa8ykixr3c09icy8yvdssb661bd1hc4wr"; + "https://github.com/simplex-chat/simplexmq.git"."c7a07d22520f74f4023f597559abb3793285b5d6" = "08bhkqm2hvgql63hrayas7izvxbv99pdzwvn3kj6z0j02pnwng6d"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bffe288cf4..f3be6cbbdb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -183,10 +183,10 @@ fluxXFTPServers = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -createChatDatabase :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase filePrefix key keepKey confirmMigrations = runExceptT $ do - chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key keepKey confirmMigrations - agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key keepKey confirmMigrations +createChatDatabase :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> Bool -> IO (Either MigrationError ChatDatabase) +createChatDatabase filePrefix key keepKey confirmMigrations vacuum = runExceptT $ do + chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key keepKey confirmMigrations vacuum + agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key keepKey confirmMigrations vacuum pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 35f8cacdf5..8e40469d84 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -34,14 +34,14 @@ import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent, yesToUpMigrations}} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent, yesToUpMigrations, vacuumOnMigration}} chat = case logAgent of Just level -> do setLogLevel level withGlobalLogging logCfg initRun _ -> initRun where - initRun = createChatDatabase dbFilePrefix dbKey False confirm' >>= either exit run + initRun = createChatDatabase dbFilePrefix dbKey False confirm' vacuumOnMigration >>= either exit run confirm' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations exit e = do putStrLn $ "Error opening database: " <> show e diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index d790c5cd2c..f8b044676a 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -201,7 +201,8 @@ mobileChatOpts dbFilePrefix = logFile = Nothing, tbqSize = 1024, highlyAvailable = False, - yesToUpMigrations = False + yesToUpMigrations = False, + vacuumOnMigration = True }, deviceName = Nothing, chatCmd = "", @@ -240,12 +241,13 @@ chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExcept agentStore <- migrate createAgentStore (agentStoreFile dbFilePrefix) confirmMigrations liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where + opts = mobileChatOpts dbFilePrefix initialize st db = do user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix) backgroundMode + newChatController db user_ defaultMobileConfig opts backgroundMode migrate createStore dbFile confirmMigrations = ExceptT $ - (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations) + (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations (vacuumOnMigration $ coreOptions opts)) `catch` (pure . checkDBError) `catchAll` (pure . dbError) where diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index f398831194..e034bd03f0 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -66,7 +66,8 @@ data CoreChatOpts = CoreChatOpts logFile :: Maybe FilePath, tbqSize :: Natural, highlyAvailable :: Bool, - yesToUpMigrations :: Bool + yesToUpMigrations :: Bool, + vacuumOnMigration :: Bool } data ChatCmdLog = CCLAll | CCLMessages | CCLNone @@ -240,6 +241,11 @@ coreChatOptsP appDir defaultDbFileName = do <> short 'y' <> help "Automatically confirm \"up\" database migrations" ) + disableVacuum <- + switch + ( long "disable-vacuum" + <> help "Do not vacuum database after migrations" + ) pure CoreChatOpts { dbFilePrefix, @@ -265,7 +271,8 @@ coreChatOptsP appDir defaultDbFileName = do logFile, tbqSize, highlyAvailable, - yesToUpMigrations + yesToUpMigrations, + vacuumOnMigration = not disableVacuum } where useTcpTimeout p t = 1000000 * if t > 0 then t else maybe 7 (const 15) p diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 58459e71e8..7ae1b4a32a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -21,7 +21,7 @@ import Simplex.Messaging.Agent.Store.SQLite (createDBStore) import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore (..), withTransaction) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, MigrationError) -createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> Bool -> IO (Either MigrationError DBStore) createChatStore dbPath key keepKey = createDBStore dbPath key keepKey migrations chatStoreFile :: FilePath -> FilePath diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c076ee3b4f..42f139f05d 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -106,7 +106,8 @@ testCoreOpts = logFile = Nothing, tbqSize = 16, highlyAvailable = False, - yesToUpMigrations = False + yesToUpMigrations = False, + vacuumOnMigration = True } getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts @@ -247,15 +248,15 @@ groupLinkViaContactVRange :: VersionRangeChat groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do - Right db@ChatDatabase {chatStore, agentStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError +createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey, vacuumOnMigration}} dbPrefix profile = do + Right db@ChatDatabase {chatStore, agentStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError vacuumOnMigration withTransaction agentStore (`DB.execute_` "INSERT INTO users (user_id) VALUES (1);") Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True startTestChat_ db cfg opts user startTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> IO TestCC -startTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix = do - Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError +startTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey, vacuumOnMigration}} dbPrefix = do + Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError vacuumOnMigration Just user <- find activeUser <$> withTransaction chatStore getUsers startTestChat_ db cfg opts user diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 26e33cb469..905f927f37 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -214,7 +214,7 @@ testChatApi :: FilePath -> IO () testChatApi tmp = do let dbPrefix = tmp "1" f = chatStoreFile dbPrefix - Right st <- createChatStore f "myKey" False MCYesUp + Right st <- createChatStore f "myKey" False MCYesUp True Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 8bbb68f3bf..a2794ca0f7 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -53,7 +53,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore testDB "" False MCError + void $ createChatStore testDB "" False MCError True getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -61,14 +61,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore testDB "" False MCError + void $ createChatStore testDB "" False MCError True getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore testDB "" False noDownMigrations MCError + Right st <- createDBStore testDB "" False noDownMigrations MCError True mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -78,14 +78,14 @@ testSchemaMigrations = withTmpFiles $ do putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testDB testSchema - Migrations.run st $ MTRUp [m] + Migrations.run st True $ MTRUp [m] schema' <- getSchema testDB testSchema schema' `shouldNotBe` schema - Migrations.run st $ MTRDown [downMigr] + Migrations.run st True $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema schema'' `shouldBe` schema - Migrations.run st $ MTRUp [m] + Migrations.run st True $ MTRUp [m] schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema' From e27f8a8d6a42350bb6b6af7bd436cb345f201716 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 30 Dec 2024 15:27:43 +0000 Subject: [PATCH 31/95] core: fix reference to simplexmq (#5454) * core: fix reference to simplexmq * nix --------- Co-authored-by: Evgeny Poberezkin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 6ff20fadc9..5fbfede730 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: c7a07d22520f74f4023f597559abb3793285b5d6 + tag: 992b42e92224ec663684923aaa40ed1f9a683f61 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 5b01a3a330..bae65f4c17 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."c7a07d22520f74f4023f597559abb3793285b5d6" = "08bhkqm2hvgql63hrayas7izvxbv99pdzwvn3kj6z0j02pnwng6d"; + "https://github.com/simplex-chat/simplexmq.git"."992b42e92224ec663684923aaa40ed1f9a683f61" = "08bhkqm2hvgql63hrayas7izvxbv99pdzwvn3kj6z0j02pnwng6d"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From 0dfcd604901a88484cdd260a905ec77969153cd6 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 2 Jan 2025 04:31:06 +0700 Subject: [PATCH 32/95] android, desktop: moving chats changing in main thread (#5461) * android, desktop: moving chats changing in main thread * modifying chat items in main thread only * comment --- .../simplex/common/platform/UI.android.kt | 11 ++++-- .../chat/simplex/common/model/ChatModel.kt | 39 ++++++++++++------- .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- .../common/views/chat/ChatItemsLoader.kt | 20 +++++----- .../simplex/common/views/chat/ChatView.kt | 8 +++- .../views/chat/group/GroupMemberInfoView.kt | 2 +- .../views/chatlist/ChatListNavLinkView.kt | 16 +++++--- .../views/contacts/ContactListNavView.kt | 4 +- .../common/views/database/DatabaseView.kt | 2 +- .../common/views/newchat/AddGroupView.kt | 2 +- .../kotlin/chat/simplex/common/DesktopApp.kt | 9 ++++- 11 files changed, 74 insertions(+), 41 deletions(-) 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 ae5966b20f..1a4d0b72e9 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 @@ -14,6 +14,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.clear import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* @@ -74,9 +75,13 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { if (ModalManager.start.hasModalsOpen()) { ModalManager.start.closeModal() } else if (chatModel.chatId.value != null) { - // Since no modals are open, the problem is probably in ChatView - chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() + withApi { + withChats { + // Since no modals are open, the problem is probably in ChatView + chatModel.chatId.value = null + chatItems.clearAndNotify() + } + } } else { // ChatList, nothing to do. Maybe to show other view except ChatList } 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 e2fe96e178..f4ffa5e175 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 @@ -61,7 +61,6 @@ object ChatModel { val incompleteInitializedDbRemoved = mutableStateOf(false) private val _chats = mutableStateOf(SnapshotStateList()) val chats: State> = _chats - private val chatsContext = ChatsContext() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) @@ -72,7 +71,10 @@ object ChatModel { * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages]. * If you use api call to get the items, use just [add] instead of [addAndNotify]. * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ - val chatItems = mutableStateOf(SnapshotStateList()) + private val _chatItems = mutableStateOf(SnapshotStateList()) + val chatItems: State> = _chatItems + // declaration of chatsContext should be after any other variable that is directly attached to ChatsContext class, otherwise, strange crash with NullPointerException for "this" parameter in random functions + private val chatsContext = ChatsContext() // set listener here that will be notified on every add/delete of a chat item var chatItemsChangesListener: ChatItemsChangesListener? = null val chatState = ActiveChatState() @@ -157,7 +159,6 @@ object ChatModel { val updatingProgress = mutableStateOf(null as Float?) var updatingRequest: Closeable? = null - private val updatingChatsMutex: Mutex = Mutex() val changingActiveUserMutex: Mutex = Mutex() val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null @@ -336,12 +337,14 @@ object ChatModel { } } - suspend fun withChats(action: suspend ChatsContext.() -> T): T = updatingChatsMutex.withLock { + // running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread + suspend fun withChats(action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) { chatsContext.action() } class ChatsContext { val chats = _chats + val chatItems = _chatItems suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) @@ -762,7 +765,7 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) - withContext(Dispatchers.Main) { + withChats { chatItems.addAndNotify(cItem) } return cItem @@ -770,7 +773,11 @@ object ChatModel { fun removeLiveDummy() { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.removeLastAndNotify() + withApi { + withChats { + chatItems.removeLastAndNotify() + } + } } } @@ -891,19 +898,25 @@ object ChatModel { fun replaceConnReqView(id: String, withId: String) { if (id == showingInvitation.value?.connId) { - showingInvitation.value = null - chatModel.chatItems.clearAndNotify() - chatModel.chatId.value = withId + withApi { + withChats { + showingInvitation.value = null + chatItems.clearAndNotify() + chatModel.chatId.value = withId + } + } ModalManager.start.closeModals() ModalManager.end.closeModals() } } - fun dismissConnReqView(id: String) { + fun dismissConnReqView(id: String) = withApi { if (id == showingInvitation.value?.connId) { - showingInvitation.value = null - chatModel.chatItems.clearAndNotify() - chatModel.chatId.value = null + withChats { + showingInvitation.value = null + chatItems.clearAndNotify() + chatModel.chatId.value = null + } // Close NewChatView ModalManager.start.closeModals() ModalManager.center.closeModals() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index a86be622b9..7ed138d7fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3044,8 +3044,8 @@ object ChatController { chatModel.users.addAll(users) chatModel.currentUser.value = user if (user == null) { - chatModel.chatItems.clearAndNotify() withChats { + chatItems.clearAndNotify() chats.clear() popChatCollector.clear() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index be09c04ec1..5cbc01271a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -45,9 +45,9 @@ suspend fun apiLoadMessages( addChat(chat) } } - withContext(Dispatchers.Main) { + withChats { chatModel.chatItemStatuses.clear() - chatModel.chatItems.replaceAll(chat.chatItems) + chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { @@ -70,8 +70,8 @@ suspend fun apiLoadMessages( ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats { + chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) } @@ -89,8 +89,8 @@ suspend fun apiLoadMessages( val indexToAdd = min(indexInCurrentItems + 1, newItems.size) val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats { + chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) // loading clear bottom area, updating number of unread items after the newest loaded item @@ -104,8 +104,8 @@ suspend fun apiLoadMessages( val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) // currently, items will always be added on top, which is index 0 newItems.addAll(0, chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats { + chatItems.replaceAll(newItems) splits.value = listOf(chat.chatItems.last().id) + newSplits unreadAfterItemId.value = chat.chatItems.last().id totalAfter.value = navInfo.afterTotal @@ -119,8 +119,8 @@ suspend fun apiLoadMessages( newItems.addAll(oldItems) removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats { + chatItems.replaceAll(newItems) unreadAfterNewestLoaded.value = 0 } } 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 c58561718e..a4c1d40602 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 @@ -550,7 +550,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - chatModel.chatItems.clearAndNotify() + withChats { + chatItems.clearAndNotify() + } } } is ChatInfo.InvalidJSON -> { @@ -561,7 +563,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - chatModel.chatItems.clearAndNotify() + withChats { + chatItems.clearAndNotify() + } } } else -> {} 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 e607efeddc..657c0923a5 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 @@ -98,8 +98,8 @@ fun GroupMemberInfoView( val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) withChats { addChat(memberChat) - openLoadedChat(memberChat) } + openLoadedChat(memberChat) closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } 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 2f0311b087..4c4c52e58d 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 @@ -216,15 +216,19 @@ suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo. private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) = apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) -fun openLoadedChat(chat: Chat) { - chatModel.chatItemStatuses.clear() - chatModel.chatItems.replaceAll(chat.chatItems) - chatModel.chatId.value = chat.chatInfo.id - chatModel.chatState.clear() +suspend fun openLoadedChat(chat: Chat) { + withChats { + chatModel.chatItemStatuses.clear() + chatItems.replaceAll(chat.chatItems) + chatModel.chatId.value = chat.chatInfo.id + chatModel.chatState.clear() + } } suspend fun apiFindMessages(ch: Chat, search: String) { - chatModel.chatItems.clearAndNotify() + withChats { + chatItems.clearAndNotify() + } apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 0af8e7ca38..da70aef621 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -21,7 +21,9 @@ fun onRequestAccepted(chat: Chat) { if (chatInfo is ChatInfo.Direct) { ModalManager.start.closeModals() if (chatInfo.contact.sndReady) { - openLoadedChat(chat) + withApi { + openLoadedChat(chat) + } } } } 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 28772f01d3..933bc0c93a 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 @@ -528,9 +528,9 @@ fun deleteChatDatabaseFilesAndState() { // Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() withLongRunningApi { withChats { + chatItems.clearAndNotify() chats.clear() popChatCollector.clear() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 6cecbe4979..aa23eb355f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -44,7 +44,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c if (groupInfo != null) { withChats { updateGroup(rhId = rhId, groupInfo) - chatModel.chatItems.clearAndNotify() + chatItems.clearAndNotify() chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id } 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 2702862e47..221f1a1291 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,6 +14,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme @@ -55,8 +56,12 @@ fun showApp() { // Better to not close fullscreen since it can contain passcode } else { // The last possible cause that can be closed - chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() + withApi { + withChats { + chatModel.chatId.value = null + chatItems.clearAndNotify() + } + } } chatModel.activeCall.value?.let { withBGApi { From cab938b9f0bbb7dc5e6210ecfcb34630aceb01bc Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 2 Jan 2025 04:31:32 +0700 Subject: [PATCH 33/95] android, desktop: improving group members loading to prevent crashes (#5462) --- .../chat/simplex/common/model/ChatModel.kt | 29 ++++++++++++------- .../chat/simplex/common/model/SimpleXAPI.kt | 2 ++ .../simplex/common/views/chat/ChatView.kt | 27 +++++++++++------ .../views/chat/group/AddGroupMembersView.kt | 2 +- .../views/chat/group/GroupChatInfoView.kt | 9 ++++-- .../views/chatlist/ChatListNavLinkView.kt | 10 +++---- 6 files changed, 50 insertions(+), 29 deletions(-) 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 f4ffa5e175..c1a9971e9c 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 @@ -81,8 +81,8 @@ object ChatModel { // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) val chatItemStatuses = mutableMapOf() - val groupMembers = mutableStateListOf() - val groupMembersIndexes = mutableStateMapOf() + val groupMembers = mutableStateOf>(emptyList()) + val groupMembersIndexes = mutableStateOf>(emptyMap()) // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -322,16 +322,18 @@ object ChatModel { fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } fun populateGroupMembersIndexes() { - groupMembersIndexes.clear() - groupMembers.forEachIndexed { i, member -> - groupMembersIndexes[member.groupMemberId] = i + groupMembersIndexes.value = emptyMap() + val gmIndexes = groupMembersIndexes.value.toMutableMap() + groupMembers.value.forEachIndexed { i, member -> + gmIndexes[member.groupMemberId] = i } + groupMembersIndexes.value = gmIndexes } fun getGroupMember(groupMemberId: Long): GroupMember? { - val memberIndex = groupMembersIndexes[groupMemberId] + val memberIndex = groupMembersIndexes.value[groupMemberId] return if (memberIndex != null) { - groupMembers[memberIndex] + groupMembers.value[memberIndex] } else { null } @@ -697,7 +699,7 @@ object ChatModel { } // update current chat return if (chatId.value == groupInfo.id) { - val memberIndex = groupMembersIndexes[member.groupMemberId] + val memberIndex = groupMembersIndexes.value[member.groupMemberId] val updated = chatItems.value.map { // Take into account only specific changes, not all. Other member updates are not important and can be skipped if (it.chatDir is CIDirection.GroupRcv && it.chatDir.groupMember.groupMemberId == member.groupMemberId && @@ -713,12 +715,17 @@ object ChatModel { if (updated != chatItems.value) { chatItems.replaceAll(updated) } + val gMembers = groupMembers.value.toMutableList() if (memberIndex != null) { - groupMembers[memberIndex] = member + gMembers[memberIndex] = member + groupMembers.value = gMembers false } else { - groupMembers.add(member) - groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1 + gMembers.add(member) + groupMembers.value = gMembers + val gmIndexes = groupMembersIndexes.value.toMutableMap() + gmIndexes[member.groupMemberId] = groupMembers.size - 1 + groupMembersIndexes.value = gmIndexes true } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7ed138d7fa..f16c19fdb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -683,6 +683,8 @@ object ChatController { Log.d(TAG, "sendCmd: ${cmd.cmdType}") } val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) + // coroutine was cancelled already, no need to process response (helps with apiListMembers - very heavy query in large groups) + interruptIfCancelled() val r = APIResponse.decodeStr(json) if (log) { Log.d(TAG, "sendCmd response type ${r.resp.responseType}") 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 a4c1d40602..71e1a422a6 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 @@ -114,6 +114,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { + var groupMembersJob: Job = remember { Job() } val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null } val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } @@ -220,8 +221,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - hideKeyboard(view) AudioPlayer.stop() chatModel.chatId.value = null - chatModel.groupMembers.clear() - chatModel.groupMembersIndexes.clear() + chatModel.groupMembers.value = emptyList() + chatModel.groupMembersIndexes.value = emptyMap() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -229,7 +230,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - return@ChatLayout } hideKeyboard(view) - withBGApi { + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { // The idea is to preload information before showing a modal because large groups can take time to load all members var preloadedContactInfo: Pair? = null var preloadedCode: String? = null @@ -241,6 +243,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) } + if (!isActive) return@launch + ModalManager.end.showModalCloseable(true) { close -> val chatInfo = remember { activeChatInfo }.value if (chatInfo is ChatInfo.Direct) { @@ -276,7 +280,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - }, showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) - withBGApi { + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { @@ -286,6 +291,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - member to null } setGroupMembers(chatRh, groupInfo, chatModel) + if (!isActive) return@launch + ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> @@ -431,7 +438,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - chatModel.getChat(chatId) }, findModelMember = { memberId -> - chatModel.groupMembers.find { it.id == memberId } + chatModel.groupMembers.value.find { it.id == memberId } }, setReaction = { cInfo, cItem, add, reaction -> withBGApi { @@ -451,17 +458,19 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } }, showItemDetails = { cInfo, cItem -> - suspend fun loadChatItemInfo(): ChatItemInfo? { + suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope { val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) if (ciInfo != null) { if (chatInfo is ChatInfo.Group) { setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) + if (!isActive) return@coroutineScope null } } - return ciInfo + ciInfo } - withBGApi { - var initialCiInfo = loadChatItemInfo() ?: return@withBGApi + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { + var initialCiInfo = loadChatItemInfo() ?: return@launch ModalManager.end.closeModals() ModalManager.end.showModalCloseable(endButtons = { ShareButton { 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 25661f00a0..6072abfc36 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 @@ -83,7 +83,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea fun getContactsToAdd(chatModel: ChatModel, search: String): List { val s = search.trim().lowercase() - val memberContactIds = chatModel.groupMembers + val memberContactIds = chatModel.groupMembers.value .filter { it.memberCurrent } .mapNotNull { it.memberContactId } return chatModel.chats.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index c92ac2ddc3..21d678ba50 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -40,7 +40,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource -import kotlinx.coroutines.launch +import kotlinx.coroutines.* const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 @@ -54,6 +54,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { val groupInfo = chat.chatInfo.groupInfo val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) } + val scope = rememberCoroutineScope() GroupChatInfoLayout( chat, groupInfo, @@ -64,14 +65,16 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, - members = chatModel.groupMembers + members = remember { chatModel.groupMembers }.value .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole }, developerTools, groupLink, addMembers = { - withBGApi { + scope.launch(Dispatchers.Default) { setGroupMembers(rhId, groupInfo, chatModel) + if (!isActive) return@launch + ModalManager.end.showModalCloseable(true) { close -> AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } 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 4c4c52e58d..fd63c0d315 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 @@ -232,9 +232,10 @@ suspend fun apiFindMessages(ch: Chat, search: String) { apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search) } -suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { +suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { + // groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) - val currentMembers = chatModel.groupMembers + val currentMembers = chatModel.groupMembers.value val newMembers = groupMembers.map { newMember -> val currentMember = currentMembers.find { it.id == newMember.id } val currentMemberStats = currentMember?.activeConn?.connectionStats @@ -245,9 +246,8 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo newMember } } - chatModel.groupMembers.clear() - chatModel.groupMembersIndexes.clear() - chatModel.groupMembers.addAll(newMembers) + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.groupMembers.value = newMembers chatModel.populateGroupMembersIndexes() } From ab0c320fcb9480015263fafd3cfcbac63e5589a8 Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 1 Jan 2025 22:18:15 +0000 Subject: [PATCH 34/95] android, desktop: chat tags UX improvements (#5455) * show "all" in meny when any active filter or text enabled, reset search when all selected * show active preset filter as blue * label changes * edit, delete and change order via context menu * simplify filter logic to match and make sure active chat always present * notes preset * remove no longer needed code * reorder mode boolean, rememberSaveable * avoid glitch in dropdown menu animation * move dropdown menu to tagListview * tagsRow via actual/expect * current chat id always on top * avoid recompose * fix android * selected preset should be blue * show change list in context menu if chat already had tag * swap icons --------- Co-authored-by: Evgeny Poberezkin --- .../views/chatlist/ChatListView.android.kt | 13 + .../chat/simplex/common/model/ChatModel.kt | 8 +- .../views/chatlist/ChatListNavLinkView.kt | 4 +- .../common/views/chatlist/ChatListView.kt | 223 +++++++++--------- .../common/views/chatlist/TagListView.kt | 59 +++-- .../views/helpers/DefaultDropdownMenu.kt | 9 +- .../commonMain/resources/MR/base/strings.xml | 3 + .../resources/MR/images/ic_folder_closed.svg | 1 + .../MR/images/ic_folder_closed_filled.svg | 1 + .../views/chatlist/ChatListView.desktop.kt | 6 + 10 files changed, 178 insertions(+), 149 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt index 7db39b7d3e..8c3b161a5c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -29,6 +29,19 @@ private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFF private val CALL_BOTTOM_ICON_OFFSET = (-15).dp private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET +@Composable +actual fun TagsRow(content: @Composable() (() -> Unit)) { + Row( + modifier = Modifier + .padding(horizontal = 14.dp) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + content() + } +} + @Composable actual fun ActiveCallInteractiveArea(call: Call) { val onClick = { platform.androidStartCallActivity(false) } 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 c1a9971e9c..b7da45f2a5 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 @@ -1354,7 +1354,13 @@ sealed class ChatInfo: SomeChat, NamedChat { is Group -> groupInfo.chatTags else -> null } -} + + val contactCard: Boolean + get() = when (this) { + is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active + else -> false + } + } @Serializable sealed class NetworkStatus { 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 fd63c0d315..994d56d1fc 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 @@ -355,14 +355,14 @@ fun TagListAction( ) { val userTags = remember { chatModel.userTags } ItemAction( - stringResource(MR.strings.list_menu), + stringResource(if (chat.chatInfo.chatTags.isNullOrEmpty()) MR.strings.add_to_list else MR.strings.change_list), painterResource(MR.images.ic_label), onClick = { ModalManager.start.showModalCloseable { close -> if (userTags.value.isEmpty()) { TagListEditor(rhId = chat.remoteHostId, chat = chat, close = close) } else { - TagListView(rhId = chat.remoteHostId, chat = chat, close = close) + TagListView(rhId = chat.remoteHostId, chat = chat, close = close, reorderMode = false) } } showMenu.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 4648ac5037..b4a381809d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -49,7 +49,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds -enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS } +enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } sealed class ActiveFilter { data class PresetTag(val tag: PresetTagKind) : ActiveFilter() @@ -815,13 +815,13 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat if (oneHandUI.value) { Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) { Divider() - TagsView() + TagsView(searchText) ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) } } else { ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) - TagsView() + TagsView(searchText) Divider() } } @@ -925,25 +925,13 @@ private fun ChatListFeatureCards() { private val TAG_MIN_HEIGHT = 35.dp @Composable -private fun TagsView() { +private fun TagsView(searchText: MutableState) { val userTags = remember { chatModel.userTags } val presetTags = remember { chatModel.presetTags } val activeFilter = remember { chatModel.activeChatTagFilter } val unreadTags = remember { chatModel.unreadTags } val rhId = chatModel.remoteHostId() - fun showTagList() { - ModalManager.start.showCustomModal { close -> - val editMode = remember { stateGetOrPut("editMode") { false } } - ModalView(close, showClose = true, endButtons = { - TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = CircleShape)) { - Text(stringResource(if (editMode.value) MR.strings.cancel_verb else MR.strings.edit_verb)) - } - }) { - TagListView(rhId = rhId, close = close, editMode = editMode) - } - } - } val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) TagsRow { @@ -953,7 +941,7 @@ private fun TagsView() { ExpandedTagFilterView(tag) } } else { - CollapsedTagsFilterView() + CollapsedTagsFilterView(searchText) } } @@ -963,69 +951,75 @@ private fun TagsView() { else -> false } val interactionSource = remember { MutableInteractionSource() } - Row( - rowSizeModifier - .clip(shape = CircleShape) - .combinedClickable( - onClick = { - if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { - chatModel.activeChatTagFilter.value = null - } else { - chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) + val showMenu = rememberSaveable { mutableStateOf(false) } + val saving = remember { mutableStateOf(false) } + Box { + Row( + rowSizeModifier + .clip(shape = CircleShape) + .combinedClickable( + onClick = { + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) + } + }, + onLongClick = { showMenu.value = true }, + interactionSource = interactionSource, + indication = LocalIndication.current, + enabled = !saving.value + ) + .onRightClick { showMenu.value = true } + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) + } else { + Icon( + painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), + null, + Modifier.size(18.sp.toDp()), + tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground + ) + } + Spacer(Modifier.width(4.dp)) + Box { + val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" + val invisibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { + append(badgeText) } - }, - onLongClick = { showTagList() }, - interactionSource = interactionSource, - indication = LocalIndication.current - ) - .onRightClick { showTagList() } - .padding(4.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - if (tag.chatTagEmoji != null) { - ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) - } else { - Icon( - painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), - null, - Modifier.size(18.sp.toDp()), - tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground - ) - } - Spacer(Modifier.width(4.dp)) - Box { - val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" - val invisibleText = buildAnnotatedString { - append(tag.chatTagText) - withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { - append(badgeText) } - } - Text( - text = invisibleText, - fontWeight = FontWeight.Medium, - fontSize = 15.sp, - color = Color.Transparent, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - // Visible text with styles - val visibleText = buildAnnotatedString { - append(tag.chatTagText) - withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) { - append(badgeText) + Text( + text = invisibleText, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + color = Color.Transparent, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Visible text with styles + val visibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) { + append(badgeText) + } } + Text( + text = visibleText, + fontWeight = if (current) FontWeight.Medium else FontWeight.Normal, + fontSize = 15.sp, + color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } - Text( - text = visibleText, - fontWeight = if (current) FontWeight.Medium else FontWeight.Normal, - fontSize = 15.sp, - color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) } + TagsDropdownMenu(rhId, tag, showMenu, saving) } } val plusClickModifier = Modifier @@ -1051,23 +1045,8 @@ private fun TagsView() { } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun TagsRow(content: @Composable() (() -> Unit)) { - if (appPlatform.isAndroid) { - Row( - modifier = Modifier - .padding(horizontal = 14.dp) - .horizontalScroll(rememberScrollState()), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - content() - } - } else { - FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() } - } -} +expect fun TagsRow(content: @Composable() (() -> Unit)) @Composable private fun ExpandedTagFilterView(tag: PresetTagKind) { @@ -1076,12 +1055,12 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { is ActiveFilter.PresetTag -> af.tag == tag else -> false } - val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) val (icon, text) = presetTagLabel(tag, active) val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Row( - modifier = rowSizeModifier + modifier = Modifier + .sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) .clip(shape = CircleShape) .clickable { if (activeFilter.value == ActiveFilter.PresetTag(tag)) { @@ -1121,7 +1100,7 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { @Composable -private fun CollapsedTagsFilterView() { +private fun CollapsedTagsFilterView(searchText: MutableState) { val activeFilter = remember { chatModel.activeChatTagFilter } val presetTags = remember { chatModel.presetTags } val showMenu = remember { mutableStateOf(false) } @@ -1145,7 +1124,7 @@ private fun CollapsedTagsFilterView() { painterResource(icon), stringResource(text), Modifier.size(18.sp.toDp()), - tint = MaterialTheme.colors.secondary + tint = MaterialTheme.colors.primary ) } else { Icon( @@ -1155,20 +1134,26 @@ private fun CollapsedTagsFilterView() { ) } - DefaultDropdownMenu(showMenu = showMenu) { - if (selectedPresetTag != null) { + val onCloseMenuAction = remember { mutableStateOf<(() -> Unit)>({}) } + + DefaultDropdownMenu(showMenu = showMenu, onClosed = onCloseMenuAction) { + if (activeFilter.value != null || searchText.value.text.isNotBlank()) { ItemAction( stringResource(MR.strings.chat_list_all), painterResource(MR.images.ic_menu), onClick = { - chatModel.activeChatTagFilter.value = null + onCloseMenuAction.value = { + searchText.value = TextFieldValue() + chatModel.activeChatTagFilter.value = null + onCloseMenuAction.value = {} + } showMenu.value = false } ) } PresetTagKind.entries.forEach { tag -> if ((presetTags[tag] ?: 0) > 0) { - ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu) + ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction) } } } @@ -1179,14 +1164,19 @@ private fun CollapsedTagsFilterView() { fun ItemPresetFilterAction( presetTag: PresetTagKind, active: Boolean, - showMenu: MutableState + showMenu: MutableState, + onCloseMenuAction: MutableState<(() -> Unit)> ) { val (icon, text) = presetTagLabel(presetTag, active) ItemAction( stringResource(text), painterResource(icon), + color = if (active) MaterialTheme.colors.primary else Color.Unspecified, onClick = { - chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + onCloseMenuAction.value = { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + onCloseMenuAction.value = {} + } showMenu.value = false } ) @@ -1205,26 +1195,18 @@ fun filteredChats( } else { val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() if (s.isEmpty()) - chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD && filtered(chat, activeFilter) } + chats.filter { chat -> chat.id == chatModel.chatId.value || (!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat, activeFilter)) } else { chats.filter { chat -> - when (val cInfo = chat.chatInfo) { - is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && ( - if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat, activeFilter) - } else { - cInfo.anyNameContains(s) - }) - is ChatInfo.Group -> if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat, activeFilter) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited - } else { - cInfo.anyNameContains(s) + chat.id == chatModel.chatId.value || + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> !cInfo.contact.chatDeleted && !chat.chatInfo.contactCard && cInfo.anyNameContains(s) + is ChatInfo.Group -> cInfo.anyNameContains(s) + is ChatInfo.Local -> cInfo.anyNameContains(s) + is ChatInfo.ContactRequest -> cInfo.anyNameContains(s) + is ChatInfo.ContactConnection -> cInfo.contactConnection.localAlias.lowercase().contains(s) + is ChatInfo.InvalidJSON -> false } - is ChatInfo.Local -> s.isEmpty() || cInfo.anyNameContains(s) - is ChatInfo.ContactRequest -> s.isEmpty() || cInfo.anyNameContains(s) - is ChatInfo.ContactConnection -> (s.isNotEmpty() && cInfo.anyNameContains(s)) || (s.isEmpty() && chat.id == chatModel.chatId.value) - is ChatInfo.InvalidJSON -> chat.id == chatModel.chatId.value - } } } } @@ -1256,6 +1238,10 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business else -> false } + PresetTagKind.NOTES -> when (chatInfo) { + is ChatInfo.Local -> !chatInfo.noteFolder.chatDeleted + else -> false + } } private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair = @@ -1264,6 +1250,7 @@ private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses + PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes } fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 2cd0c953c7..f8ddc16bde 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -5,8 +5,7 @@ import SectionDivider import SectionItemView import TextIconSpaced import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.itemsIndexed @@ -44,12 +43,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: MutableState = remember { mutableStateOf(false) }) { - if (remember { editMode }.value) { - BackHandler { - editMode.value = false - } - } +fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) { val userTags = remember { chatModel.userTags } val oneHandUI = remember { appPrefs.oneHandUI.state } val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState() @@ -77,7 +71,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu val topPaddingToContent = topPaddingToContent(false) LazyColumnWithScrollBar( - modifier = if (editMode.value) Modifier.dragContainer(dragDropState) else Modifier, + modifier = if (reorderMode) Modifier.dragContainer(dragDropState) else Modifier, contentPadding = PaddingValues( top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp @@ -97,7 +91,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu } } - if (oneHandUI.value && !editMode.value) { + if (oneHandUI.value && !reorderMode) { item { CreateList() } @@ -111,15 +105,14 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu backgroundColor = if (isDragging) colors.surface else Color.Unspecified ) { Column { - val showMenu = remember { mutableStateOf(false) } val selected = chatTagIds.value.contains(tag.chatTagId) Row( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) - .combinedClickable( - enabled = !saving.value, + .clickable( + enabled = !saving.value && !reorderMode, onClick = { if (chat == null) { ModalManager.start.showModalCloseable { close -> @@ -139,13 +132,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu }) } }, - onLongClick = if (editMode.value) null else { - { showMenu.value = true } - }, - interactionSource = remember { MutableInteractionSource() }, - indication = LocalIndication.current ) - .onRightClick { showMenu.value = true } .padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)), verticalAlignment = Alignment.CenterVertically ) { @@ -163,21 +150,17 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu if (selected) { Spacer(Modifier.weight(1f)) Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) - } else if (editMode.value) { + } else if (reorderMode) { Spacer(Modifier.weight(1f)) Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) } - DefaultDropdownMenu(showMenu, dropdownMenuItems = { - EditTagAction(rhId, tag, showMenu) - DeleteTagAction(rhId, tag, showMenu, saving) - }) } SectionDivider() } } } } - if (!oneHandUI.value && !editMode.value) { + if (!oneHandUI.value && !reorderMode) { item { CreateList() } @@ -279,7 +262,7 @@ fun ModalData.TagListEditor( SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) { Text( - generalGetString(if (chat != null) MR.strings.add_to_list else if (tagId == null) MR.strings.create_list else MR.strings.save_list), + generalGetString(if (chat != null) MR.strings.add_to_list else MR.strings.save_list), color = if (disabled) colors.secondary else colors.primary ) } @@ -309,6 +292,15 @@ fun ModalData.TagListEditor( } } +@Composable +fun TagsDropdownMenu(rhId: Long?, tag: ChatTag, showMenu: MutableState, saving: MutableState) { + DefaultDropdownMenu(showMenu, dropdownMenuItems = { + EditTagAction(rhId, tag, showMenu) + DeleteTagAction(rhId, tag, showMenu, saving) + ChangeOrderTagAction(rhId, showMenu) + }) +} + @Composable private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState, saving: MutableState) { ItemAction( @@ -343,6 +335,21 @@ private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.change_order_chat_list_menu_action), + painterResource(MR.images.ic_drag_handle), + onClick = { + showMenu.value = false + ModalManager.start.showModalCloseable { close -> + TagListView(rhId = rhId, close = close, reorderMode = true) + } + }, + color = MenuTextColor + ) +} + @Composable expect fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt index 1f00af2809..c6a566c6f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt @@ -5,8 +5,7 @@ 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.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -16,6 +15,7 @@ fun DefaultDropdownMenu( showMenu: MutableState, modifier: Modifier = Modifier, offset: DpOffset = DpOffset(0.dp, 0.dp), + onClosed: State<() -> Unit> = remember { mutableStateOf({}) }, dropdownMenuItems: (@Composable () -> Unit)? ) { MaterialTheme( @@ -31,6 +31,11 @@ fun DefaultDropdownMenu( offset = offset, ) { dropdownMenuItems?.invoke() + DisposableEffect(Unit) { + onDispose { + onClosed.value() + } + } } } } 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 ffbe473df8..e45ba24822 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -421,6 +421,7 @@ Contacts Groups Businesses + Notes All Add list @@ -644,6 +645,7 @@ Create list Add to list + Change list Save list List name... List name and emoji should be different for all lists. @@ -651,6 +653,7 @@ Delete list? All chats will be removed from the list %s, and the list deleted Edit + Change order You invited a contact diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg new file mode 100644 index 0000000000..0f9889083d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg new file mode 100644 index 0000000000..6291f7ab8e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index 3fa78bbbb5..e295144191 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -21,6 +21,12 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +@OptIn(ExperimentalLayoutApi::class) +@Composable +actual fun TagsRow(content: @Composable() (() -> Unit)) { + FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() } +} + @Composable actual fun ActiveCallInteractiveArea(call: Call) { val showMenu = remember { mutableStateOf(false) } From aa7095dee2b10fdef35c0751f272514336121f58 Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 1 Jan 2025 23:12:12 +0000 Subject: [PATCH 35/95] ios: chat tags ux improvements (#5456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix build * Rename the second “create list” button to “save list” * add notes preset tag * reset search text if active filter is changed * reset search when preset are pressed --------- Co-authored-by: Evgeny Poberezkin --- .../Shared/Views/ChatList/ChatListNavLink.swift | 2 -- .../ios/Shared/Views/ChatList/ChatListView.swift | 16 ++++++++++++++-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 36a98e3f2f..9ed310692f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -868,8 +868,6 @@ struct ChatListTagEditor: View { Text( chat != nil ? "Add to list" - : tagId == nil - ? "Create list" : "Save list" ) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index edf9a3e5d2..b54a58a1fe 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -36,6 +36,7 @@ enum PresetTag: Int, Identifiable, CaseIterable, Equatable { case contacts = 1 case groups = 2 case business = 3 + case notes = 4 var id: Int { rawValue } } @@ -563,7 +564,7 @@ struct ChatListSearchBar: View { var body: some View { VStack(spacing: 12) { - ScrollView([.horizontal], showsIndicators: false) { ChatTagsView(parentSheet: $parentSheet) } + ScrollView([.horizontal], showsIndicators: false) { ChatTagsView(parentSheet: $parentSheet, searchText: $searchText) } HStack(spacing: 12) { HStack(spacing: 4) { Image(systemName: "magnifyingglass") @@ -621,6 +622,9 @@ struct ChatListSearchBar: View { } } } + .onChange(of: chatTagsModel.activeFilter) { _ in + searchText = "" + } .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" }) } @@ -667,6 +671,7 @@ struct ChatTagsView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var parentSheet: SomeSheet? + @Binding var searchText: String var body: some View { HStack { @@ -787,9 +792,10 @@ struct ChatTagsView: View { nil } Menu { - if selectedPresetTag != nil { + if chatTagsModel.activeFilter != nil || !searchText.isEmpty { Button { chatTagsModel.activeFilter = nil + searchText = "" } label: { HStack { Image(systemName: "list.bullet") @@ -829,6 +835,7 @@ struct ChatTagsView: View { case .contacts: (active ? "person.fill" : "person", "Contacts") case .groups: (active ? "person.2.fill" : "person.2", "Groups") case .business: (active ? "briefcase.fill" : "briefcase", "Businesses") + case .notes: (active ? "folder.fill" : "folder", "Notes") } } @@ -871,6 +878,11 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool { } case .business: chatInfo.groupInfo?.businessChat?.chatType == .business + case .notes: + switch chatInfo { + case .local: true + default: false + } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 6ac6a8f9fb..08493f081d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -517,9 +517,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -673,9 +673,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -756,8 +756,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */, ); path = Libraries; sourceTree = ""; From 23b20ac74321aa0ad65a1cbc415e71ecc83e5e37 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 3 Jan 2025 14:52:17 +0700 Subject: [PATCH 36/95] android: fixed scrolling in message text field (#5467) --- .../chat/simplex/common/views/TerminalView.kt | 2 +- .../simplex/common/views/chat/ComposeView.kt | 287 +++++++++--------- 2 files changed, 144 insertions(+), 145 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 6a6db0da85..c2fd52a58c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -88,7 +88,7 @@ fun TerminalLayout( .background(MaterialTheme.colors.background) ) { Divider() - Box(Modifier.padding(horizontal = 8.dp)) { + Surface(Modifier.padding(horizontal = 8.dp), color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { SendMsgView( composeState = composeState, showVoiceRecordIcon = false, 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 3a63cf508e..b0b12a7443 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 @@ -918,154 +918,153 @@ fun ComposeView( } } } - Box(Modifier.background(MaterialTheme.colors.background)) { - Divider() - Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) - val attachmentClicked = if (isGroupAndProhibitedFiles) { - { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.files_and_media_prohibited), - text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { + Divider() + Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { + val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) + val attachmentClicked = if (isGroupAndProhibitedFiles) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.files_and_media_prohibited), + text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + ) + } + } else { + showChooseAttachment + } + val attachmentEnabled = + !composeState.value.attachmentDisabled + && sendMsgEnabled.value + && userCanSend.value + && !isGroupAndProhibitedFiles + && !nextSendGrpInv.value + IconButton( + attachmentClicked, + Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), + enabled = attachmentEnabled + ) { + Icon( + painterResource(MR.images.ic_attach_file_filled_500), + contentDescription = stringResource(MR.strings.attach), + tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) ) } - } else { - showChooseAttachment - } - val attachmentEnabled = - !composeState.value.attachmentDisabled - && sendMsgEnabled.value - && userCanSend.value - && !isGroupAndProhibitedFiles - && !nextSendGrpInv.value - IconButton( - attachmentClicked, - Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), - enabled = attachmentEnabled - ) { - Icon( - painterResource(MR.images.ic_attach_file_filled_500), - contentDescription = stringResource(MR.strings.attach), - tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } + LaunchedEffect(allowedVoiceByPrefs) { + if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { + // Voice was disabled right when this user records it, just cancel it + cancelVoice() + } + } + val needToAllowVoiceToContact = remember(chat.chatInfo) { + chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { + ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && + contactPreference.allow == FeatureAllowed.YES + } + } + LaunchedEffect(Unit) { + snapshotFlow { recState.value } + .distinctUntilChanged() + .collect { + when (it) { + is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) + is RecordingState.Finished -> if (it.durationMs > 300) { + onAudioAdded(it.filePath, it.durationMs, true) + } else { + cancelVoice() + } + is RecordingState.NotStarted -> {} + } + } + } + + LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { + if (!chat.chatInfo.userCanSend) { + clearCurrentDraft() + clearState() + } + } + + KeyChangeEffect(chatModel.chatId.value) { prevChatId -> + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage(null) + resetLinkPreview() + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } else if (cs.inProgress) { + clearPrevDraft(prevChatId) + } else if (!cs.empty) { + if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) + } + if (saveLastDraft) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = prevChatId + } + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { + composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + } else { + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } + chatModel.removeLiveDummy() + CIFile.cachedRemoteFileRequests.clear() + } + if (appPlatform.isDesktop) { + // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` + DisposableEffect(Unit) { + onDispose { + if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } + } + } + } + val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } + val sendButtonColor = + if (chat.chatInfo.incognito) + if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) + else MaterialTheme.colors.primary + SendMsgView( + composeState, + showVoiceRecordIcon = true, + recState, + chat.chatInfo is ChatInfo.Direct, + liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, + sendMsgEnabled = sendMsgEnabled.value, + sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), + nextSendGrpInv = nextSendGrpInv.value, + needToAllowVoiceToContact, + allowedVoiceByPrefs, + allowVoiceToContact = ::allowVoiceToContact, + userIsObserver = userIsObserver.value, + userCanSend = userCanSend.value, + sendButtonColor = sendButtonColor, + timedMessageAllowed = timedMessageAllowed, + customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, + placeholder = stringResource(MR.strings.compose_message_placeholder), + sendMessage = { ttl -> + sendMessage(ttl) + resetLinkPreview() + }, + sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, + updateLiveMessage = ::updateLiveMessage, + cancelLiveMessage = { + composeState.value = composeState.value.copy(liveMessage = null) + chatModel.removeLiveDummy() + }, + editPrevMessage = ::editPrevMessage, + onFilesPasted = { composeState.onFilesAttached(it) }, + onMessageChange = ::onMessageChange, + textStyle = textStyle ) } - val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } - LaunchedEffect(allowedVoiceByPrefs) { - if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { - // Voice was disabled right when this user records it, just cancel it - cancelVoice() - } - } - val needToAllowVoiceToContact = remember(chat.chatInfo) { - chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { - ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && - contactPreference.allow == FeatureAllowed.YES - } - } - LaunchedEffect(Unit) { - snapshotFlow { recState.value } - .distinctUntilChanged() - .collect { - when (it) { - is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) - is RecordingState.Finished -> if (it.durationMs > 300) { - onAudioAdded(it.filePath, it.durationMs, true) - } else { - cancelVoice() - } - is RecordingState.NotStarted -> {} - } - } - } - - LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) { - if (!chat.chatInfo.userCanSend) { - clearCurrentDraft() - clearState() - } - } - - KeyChangeEffect(chatModel.chatId.value) { prevChatId -> - val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage(null) - resetLinkPreview() - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } else if (cs.inProgress) { - clearPrevDraft(prevChatId) - } else if (!cs.empty) { - if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { - composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) - } - if (saveLastDraft) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = prevChatId - } - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) - } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { - composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) - } else { - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } - chatModel.removeLiveDummy() - CIFile.cachedRemoteFileRequests.clear() - } - if (appPlatform.isDesktop) { - // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` - DisposableEffect(Unit) { - onDispose { - if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = chat.id - } - } - } - } - - val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } - val sendButtonColor = - if (chat.chatInfo.incognito) - if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) - else MaterialTheme.colors.primary - SendMsgView( - composeState, - showVoiceRecordIcon = true, - recState, - chat.chatInfo is ChatInfo.Direct, - liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, - sendMsgEnabled = sendMsgEnabled.value, - sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), - nextSendGrpInv = nextSendGrpInv.value, - needToAllowVoiceToContact, - allowedVoiceByPrefs, - allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, - userCanSend = userCanSend.value, - sendButtonColor = sendButtonColor, - timedMessageAllowed = timedMessageAllowed, - customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = stringResource(MR.strings.compose_message_placeholder), - sendMessage = { ttl -> - sendMessage(ttl) - resetLinkPreview() - }, - sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, - updateLiveMessage = ::updateLiveMessage, - cancelLiveMessage = { - composeState.value = composeState.value.copy(liveMessage = null) - chatModel.removeLiveDummy() - }, - editPrevMessage = ::editPrevMessage, - onFilesPasted = { composeState.onFilesAttached(it) }, - onMessageChange = ::onMessageChange, - textStyle = textStyle - ) } } - } } From 4813ab526d33b48830b5c111133772db72c0149d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 3 Jan 2025 14:53:10 +0700 Subject: [PATCH 37/95] android: limit PiP view size to adapt to Android limitations (#5468) --- .../src/main/java/chat/simplex/app/views/call/CallActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt index a5a1726757..995b584fce 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -99,7 +99,8 @@ class CallActivity: ComponentActivity(), ServiceConnection { fun setPipParams(video: Boolean, sourceRectHint: Rect? = null, viewRatio: Rational? = null) { // By manually specifying source rect we exclude empty background while toggling PiP val builder = PictureInPictureParams.Builder() - .setAspectRatio(viewRatio) + // that's limitation of Android. Otherwise, may crash on devices like Z Fold 3 + .setAspectRatio(viewRatio?.coerceIn(Rational(100, 239)..Rational(239, 100))) .setSourceRectHint(sourceRectHint) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(video) From 2793692a16ab45d7a81e0e1ce75079ffa36577aa Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 3 Jan 2025 14:53:37 +0700 Subject: [PATCH 38/95] android, desktop: ability to scroll in all alerts if screen is small (#5470) --- .../chat/simplex/common/views/helpers/AlertManager.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 6bfcf2809f..30c5d9cc3c 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 @@ -297,7 +297,7 @@ private fun AlertContent( belowTextContent: @Composable (() -> Unit) = {}, content: @Composable (() -> Unit) ) { - BoxWithConstraints { + BoxWithConstraints(Modifier.verticalScroll(rememberScrollState())) { Column( Modifier .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) @@ -311,7 +311,6 @@ private fun AlertContent( if (text != null) { Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) .padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING) - .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( @@ -334,10 +333,9 @@ private fun AlertContent( @Composable private fun AlertContent(text: AnnotatedString?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { - BoxWithConstraints { + BoxWithConstraints(Modifier.verticalScroll(rememberScrollState())) { Column( Modifier - .verticalScroll(rememberScrollState()) .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) ) { if (appPlatform.isDesktop) { @@ -349,7 +347,6 @@ private fun AlertContent(text: AnnotatedString?, hostDevice: Pair if (text != null) { Column( Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) - .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( From c9f6f3c0531842a02fdfc418633be211b4822f9b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 4 Jan 2025 18:33:27 +0000 Subject: [PATCH 39/95] core: api and protocol support for reporting messages to group moderators (#5469) * core: api and protocol support for reporting messages to group moderators * moderator role * delete mode * remove auto-accepting conditions for SimpleX Chat Ltd * mark as deleted locally * ui: delete mode type * store msg_content_tag with chat items, support moderator option on receiving side * report API * send reports only to moderators that support them, fail if none support * fix tests * test * remove comment * revert version * do not build ghc8107 in stable branch * skip job * fix condition * remove condition * condition * exit * update --- .github/workflows/build.yml | 5 + apps/ios/SimpleXChat/ChatTypes.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 1 + docs/rfcs/2024-12-28-reports.md | 84 +++++++++++ docs/rfcs/2024-12-30-content-moderation.md | 136 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 72 +++++++--- src/Simplex/Chat/Controller.hs | 2 + src/Simplex/Chat/Messages/CIContent.hs | 5 +- .../Chat/Migrations/M20241223_chat_tags.hs | 47 ++++++ .../Chat/Migrations/M20241230_reports.hs | 18 +++ src/Simplex/Chat/Migrations/chat_schema.sql | 33 ++++- src/Simplex/Chat/Operators.hs | 14 +- src/Simplex/Chat/Protocol.hs | 70 +++++++-- src/Simplex/Chat/Store/Groups.hs | 13 +- src/Simplex/Chat/Store/Messages.hs | 8 +- src/Simplex/Chat/Store/Migrations.hs | 6 +- src/Simplex/Chat/Store/Profiles.hs | 8 +- src/Simplex/Chat/Types.hs | 4 +- src/Simplex/Chat/Types/Shared.hs | 3 + tests/ChatTests/Direct.hs | 4 +- tests/ChatTests/Groups.hs | 60 ++++++++ tests/ChatTests/Profiles.hs | 3 +- tests/ProtocolTests.hs | 14 +- 24 files changed, 551 insertions(+), 62 deletions(-) create mode 100644 docs/rfcs/2024-12-28-reports.md create mode 100644 docs/rfcs/2024-12-30-content-moderation.md create mode 100644 src/Simplex/Chat/Migrations/M20241223_chat_tags.hs create mode 100644 src/Simplex/Chat/Migrations/M20241230_reports.hs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7ac10b9cf..2338258d82 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,7 +89,12 @@ jobs: cache_path: C:/cabal asset_name: simplex-chat-windows-x86-64 desktop_asset_name: simplex-desktop-windows-x86_64.msi + steps: + - name: Skip unreliable ghc 8.10.7 build on stable branch + if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable' + run: exit 0 + - name: Configure pagefile (Windows) if: matrix.os == 'windows-latest' uses: al-cheb/configure-pagefile-action@v1.3 diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index da1ce24b73..f8711ff779 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3075,6 +3075,7 @@ public enum CIForwardedFrom: Decodable, Hashable { public enum CIDeleteMode: String, Decodable, Hashable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" + case cidmInternalMark = "internalMark" } protocol ItemContent { 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 d407174e52..f42ace7c7c 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 @@ -2772,6 +2772,7 @@ sealed class CIForwardedFrom { @Serializable enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), + @SerialName("internalMark") cidmInternalMark("internalMark"), @SerialName("broadcast") cidmBroadcast("broadcast"); } diff --git a/docs/rfcs/2024-12-28-reports.md b/docs/rfcs/2024-12-28-reports.md new file mode 100644 index 0000000000..729ad47f19 --- /dev/null +++ b/docs/rfcs/2024-12-28-reports.md @@ -0,0 +1,84 @@ +# Content complaints / reports + +## Problem + +Group moderation is a hard work, particularly when members can join anonymously. + +As groups count and size grows, and as we are moving to working large groups, so will the abuse, so we need report function for active groups that would forward the message that members may find offensive or inappropriate or off-topic or violating any rules that community wants to have. + +It doesn't mean that the moderators must censor everything that is reported, and even less so, that it should be centralized (although in our directory our directory bot would also receive these complaints, and would allow us supporting group owners). + +While we have necessary basic features to remove content and block members, we need to simplify identifying the content both to the group owners and to ourselves, when it comes to the groups listed in directory, or for the groups and files hosted on our servers. + +Having simpler way to report content would also improve the perceived safety of the network for the majority of the users. + +## Solution proposal + +"Report" feature on the messages that would highlight this message to all group admins and moderators. + +Group directory service is also an admin (and will be reduced to moderator in the future), so reported content will be visible to us, so that we can both help group owners to moderate their groups and also to remove the group from directory if necessary. + +To the user who have the new version the reports will be sent as a special event, similar to reaction (or it can be simply an extended reaction?) the usual forwarded messages in the same group, but only to moderators (including admins and owners), with additional flag indicating that this is the report. + +In the clients with the new version the reports could be shown as a flag, possibly with the counter, on group messages that were reported, in the same line where we show emojis. + +If we do that these flags will be seen only by moderators and by the user who submitted the report. When the moderator taps the flag, s/he would see the list of user who reported it, together with the reason. + +The downside of the above UX is that it: +- does not solve the problem of highlighting the problem to admins, particularly if them manage many groups. +- creates confusion about who can see the reports. +- further increases data model complexity, as it requires additional table or self-references (as with quotes), as reports can be received prior to the reported content. +- does not allow admins to see the reported content before it is received by them (would be less important with super-peers). + +Alternatively, and it is probably a better option, all reports, both sent by the users and received by moderators across all groups can be shown in the special subview Reports in each group. The report should be shown as the reported message with the header showing the report reason and the reporter. The report should allow these actions: +- moderate the original message, +- navigate to the original message (requires infinite scrolling, so initially will be only supported on Android and desktop), +- connect to the user who sent the report - it should be possible even if the group prohibits direct messages. There are two options how this communication can be handled - either by creating a new connection, and shown as normal contacts, or as comments to the report, and sent in the same group connection. The latter approach has the advantage that the interface would not be clutter the interace. The former is much simpler, so should probably be offered as MVP. + +This additional chat is necessary, as without it it would be very hard to notice the reports, particularly for the people who moderate multiple groups, and even more so - in our group directory and future super peers. + +## Protocol + +**Option 1** + +The special message `x.msg.report` will be sent in the group with this schema: + +```json +{ + "properties": { + "msgId": {"ref": "base64url"}, + "params": { + "properties": { + "msgId": {"ref": "base64url"}, + "reason": {"enum": ["spam", "illegal", "community", "other"]} + }, + "optionalProperties": { + "memberId": {"ref": "base64url"}, + "comment": {"type": "string"} + } + } + } +} +``` + +The downside is that it does not include the original message, so that the admin cannot act on it before the message is received. + +**Option 2** + +Message quote with the new content type. + +Pro - backwards compatible (quote would include text repeating the reason). + +Con - allows reporting non-existing messages, or even mis-reporting, but it is the same consideration that applies to all quotes. In this case though the admin might moderate the message they did not see yet, and it can be abused to remove appropriate content, so the UI should show warning "do you trust the reporter, as you did not receive the message yet". Moderation via reports may have additional information to ensure that exactly the reported message is moderated - e.g., the receiving client would check that the hash of the message in moderation event matches the hash of one of the messages in history. Possibly this is unnecessary with the view of migration of groups to super-peers. + +The report itself would be a new message content type where the report reason would be repeated as text, for backward compatibility. + +The option 2 seems to be simpler to implement, backward compatible and also more naturally fitting the protocol design - the report is simply a message with the new type that the old clients would be able to show correctly as the usual quote. + +The new clients would have a special presentation of these messages and also merging them into one - e.g. they can be shown as group events on in a more prominent way, but less prominent than the actual messages, and also merge subsequent reports about the same message. + +Given that the old clients would not be able to differentiate the reports and normal replies, and can inadvertently reply to all, we probably should warn the members submitting the report that some of the moderators are running the old version, and give them a choice - send to all or send only to moderators with the new version (or don't send, in case all admins run the old version). + +Having the conversation with the member about their report probably fits with the future comment feature that we should start adding to the backend and to the UI as well, as there is no reasonable backward compatibility for it, and members with the old clients simply won't see the comments, so we will have to release it in two stages and simply not send comments to the members with the old version. + +The model for the comments is a new subtype of MsgContainer, that references the original message and member, but does not include the full message. diff --git a/docs/rfcs/2024-12-30-content-moderation.md b/docs/rfcs/2024-12-30-content-moderation.md new file mode 100644 index 0000000000..e4f21a2d21 --- /dev/null +++ b/docs/rfcs/2024-12-30-content-moderation.md @@ -0,0 +1,136 @@ +# Evolving content moderation + +## Problem + +As the users and groups grow, and particularly given that we are planning to make large (10-100k members) groups work, the abuse will inevitably grow as well. + +Our current approach to content moderation is the following: +- receive a user complaints about the group that violates content guidelines (e.g., most users who send complaints, send them about relatively rare cases of CSAM distribution). This complaint contains the link to join the group, so it is a public group that anybody can join, and there is no expectation of privacy of communications in this group. +- we forward this complaint to our automatic bot joins this group and validates the complaint. +- if the complaint is valid, and the link is hosted on one of the pre-configured servers, then we can disable the link to join the group. +- in addition to that, the bot automatically deletes all files sent to the group, in case they are uploaded to our servers, via secure SSH connection directly to server control port (we don't expose shell access in this way, only to a limited set of server control port commands). + +The problem of CSAM is small at the moment, compared with the network size, but without moderation it would grow, and we need to be ahead of this problem, so this solution was in place since early 2024 - we wrote about it on social media. + +The limitation of this approach is that nothing prevents users who created such group to create a new one, and communicate the link to the new group to the existing members so they can migrate there. While this whack-a-mole game has been working so far, it will not be sustainable once we add support for large groups, so we need to be ahead of this problem again, and implement more efficient solutions. + +At the same time, the advantage of both this solution and of the proposed one is that it achieves removal of CSAM without compromising privacy in any way. Most CSAM distribution in all communication networks happens in publicly accessible channels, and it's the same for SimpleX network. So while as server operators we cannot access any content, as users, anybody can access it, and we, acting as users can use available information to remove this content without any compromise to privacy in security. + +This is covered in our [Privacy Policy](https://simplex.chat/privacy/). + +## Solution + +The solution to prevent further CSAM distribution by the users who did it requires restricting their activity on the client side, and also preventing migration of blocked group to another group. + +Traditionally, communication networks have some form of identification on the server side, and that identification is used to block offending users. + +Innovative SimpleX network design removed the need for persistent user identification of users, and many users see it as an unsolvable dilemma - if we cannot identify the users, then we cannot restrict their actions. + +But it is not true. In the same way we already impose restriction on the sent file size, limiting it to 1gb only on the client-side, we can restrict any user actions on the client side, without having any form of user identification, and without knowing how many users were blocked - we would only know how many blocking actions we applied, but we would not have any information about whether they were applied to one or to many users, in the same way as we don't know whether multiple messaging queues are controlled by one or by multiple users. + +The usual counter-argument is that this can be easily circumvented, because the code is open-source, and the users can modify it, so this approach won't work. While this argument premise is correct, the conclusion that this solution won't be effective is incorrect for two reasons: +- most users are either unable or unwilling to invest time into modifying code. This fact alone makes this solution effective in absolute majority of cases. +- any restriction on communication can be applied both on sending and on receiving client, without the need to identify either of these clients. We already do it with 1gb file restriction - e.g., even if file sender modifies their client to allow sending larger files, most of the recipients won't be able to receive this file anyway, as their clients also restrict the size of file that can be received to 1gb. + +For the group that is blocked to continue functioning, not only message senders have to modify their clients, but also message recipients, which won't happen in the absence of ability to communicate in disabled group. Such groups will only be able to function in an isolated segment of the network, when all users use modified clients and with self-hosted servers, which is outside of our zone of any moral and any potential legal responsibility (while we do not have any responsibility for user-generated content under the existing laws, there are requirements we have to comply with that exist outside of law, e.g. requirements of application stores). + +## Potential changes + +This section is the brain-dump of technically possible changes for the future. They will not be implemented all at once, and this list is neither exhaustive, as we or our users can come up with better ideas, nor committed - some of the ideas below may never be implemented. So these ideas are only listed as technical possibilities. + +Our priority is to continue being able to prevent CSAM distribution as network and groups grow, while doing what is reasonable and minimally possible, to save our costs, to avoid any disruption to the users, and to avoid the reduction in privacy and security - on the opposite, we are planning multiple privacy and security improvements in 2025. + +### Mark files and group links as blocked on the server, with the relevant client action + +Add additional protocol command `BLOCK` that would contain the blocking reason that will be presented to the users who try to connect to the link or to download the file. This would differentiate between "not working" scenarios, when file simply fails to download, and "blocked" scenario, and this simple measure would already reduce any prohibited usage of our servers. This change is likely to be implemented in the near future, to make users aware that we are actively moderating illegal content on the network, to educate users about how we do it without any compromise to their privacy and security, and to increase trust in network reliability, as currently our moderation actions are perceived as "something is broken" by affected users. + +### Extend blocking records on files to include client-side restrictions, and apply them to the client who received this blocking record. + +E.g., the client of the user who uploaded the file would periodically check who this file was received by (this functionality currently does not exist), and during this check the client may find out that the file was blocked. When client finds it out it may do any of the following: +- show a warning that the file violated allowed usage conditions that user agreed to. +- apply restrictions, whether temporary or permanent, to upload further files to servers of this operator only (it would be inappropriate to apply wider restrictions - so we appreciate this comment made by one of the users during the consultation). In case we decide that permanent restrictions should be applied, we could also program the ability to appeal this decision to support team and lift it via unblock code - without the need to have any user identification. + +The downside of this approach is that the client would have to check the file after it is uploaded, which may create additional traffic. But at the same time it would provide file delivery receipts, so overall it could be a valuable, although substantial, change. + +To continue with the file, the clients of the users who attempt to receive the file after it was blocked could do one of the following, depending on the blocking record: +- see the warning that the file is blocked. If CSAM was sent in a group that is not distributing CSAM, this adds comfort and the feeling of safety. +- block image preview, in the same way we block avatars of blocked members. +- users can configure automatic deletion of messages with blocked files. +- refuse, temporarily or permanently, to receive future files and/or messages from this group member. Permanent restriction may be automatically lifted once the member's client presents the proof of being unblocked by server operator. + +Applying the restrictions on the receiving side is technically simpler, and requires only minimal protocol changes mentioned above. + +While file senders can circumvent client side restrictions applied by server operators, these measures can be effective, because the recipients would also have to circumvent them, which is much less likely to happen in a coordinated way. + +The upside of this approach is that it does not compromise users' privacy in any way, and it does not interfere with users rights too. A user voluntarily accepted the Conditions of Use that prohibit upload of illegal content to our servers, so it is in line with the agreement for us to enforce these conditions and restrict functionality in case of conditions being violated. At the same time it would be inappropriate for us to restrict the ability to upload files to the servers of 3rd party operators that are not pre-configured in the app - only these operators should be able to restrict uploads to their servers. + +It also avoids the need for any scanning of content, whether client- or server-side, that would also be an infringement on the users right to privacy under European Convention of Human Rights, article 8. It also makes it unnecessary to identify users, contrary to common belief that to restrict users one needs to identify them. + +In the same way the network design allows delivering user messages without any form of user identification on the network protocol level, which is the innovation that does not exist in any other network, we can apply client-side restrictions on user activities without the need to identify a user. So if the block we apply to a specific piece of content results in client-side upload/download restrictions, all we would know is how many times this restriction was applied, but not to how many users - multiple blocked files could have been all uploaded by one user or by multiple users, but this is not the knowledge that is required to restrict further abuse of our servers and violation of condition of use. Again, this is an innovative approach to moderation that is not present in any of the networks, that allows us both to remain in compliance with the contractual obligations (e.g., with application store owners) and any potential legal obligation (even though the legal advice we have is that we do not have obligation to moderate content, as we are not providing communication services), once it becomes a bigger issue. + +### Extend blocking records on links to include client-side restrictions, and apply them to the clients who received this blocking record. + +Similarly to files, once the link to join the group is blocked, both the owner's client and all members' clients can impose (technically) any of the following restrictions. + +For the owner: +- restrict, temporarily or permanently, ability to create public groups on the servers of the operator (or group of operators, in case of pre-configured operators) who applied this blocking record. +- restrict, temporarily or permanently, ability to upload files to operator's servers. +- restrict, temporarily or permanently, sending any messages to operator's servers, not only in the blocked group. + +For all group members: +- restrict, temporarily or permanently, ability to send and receive messages in the blocked group. + +For the same reason as with files, this measure will be an effective deterrence, even though the code is open-source. + +While full blocking may be seen as draconian, for the people who repeatedly violate the conditions of use, ignoring temporary or limited restrictions, it may be appropriate. The tracking of repeat violations of conditions also does not require any user identification and can be done fully on the client side, with sufficient efficiency. + +### Implement ability to submit reports to group owners and moderators + +This is covered under a [separate RFC](./2024-12-28-reports.md) and is currently in progress. This would improve the ability of group owners to moderate their groups, and would also improve our ability to moderate all listed groups, both manually and automatically, as Directory Service has moderation rights. + +### Implement ability to submit reports to 3rd party server operators + +While users already can send reports to ourselves directly via the app, sending them to other server operators requires additional steps from the users. + +This function would allow sending reports to any server operator directly via the app, to the address sent by the server during the initial connection. + +Server operators may be then offered efficient interfaces in the clients to manage these complaints and to apply client-side restrictions to the users who violate the conditions. + +### Blacklist servers who refuse to remove CSAM from receiving any traffic from our servers + +We cannot and should not enforce that 3rd party server operators remove CSAM from their servers. We will only be recommending it and providing tools to simplify it. + +But we can, technically, implement block-lists of servers so that the users who need to send messages to these servers would not be able to do that via our servers. + +We also can require mandatory server identification to requests to proxy messages via client certificates of the server that could be validated via a reverse connection, and also block incoming traffic from these servers. + +While both these measures are undesirable and would result in network fragmentation, they are technically possible. Similar restrictions already happen in fediverse networks, and they are effective. + +## Actual planned changes + +To summarize, the changes that are planned in the near future: + +- client-side notifications that files or group links were blocked (as opposed to show error, creating an impression that something is not working). +- [content reports](./2024-12-28-reports.md) to group owners and moderators. +- additional short notice about conditions of use that apply to file uploads prior to the first upload. + +Additional simple changes that are considered: + +- applying client-side restriction to create new public groups on operator's servers on admins of blocked groups (do not confuse that with the groups that we decided not to list in our directory, or decided to remove from our directory - this is not blocking that is being discussed here). +- if the group link was registered via directory service, we can prevent further registration of public groups in directory service for this user by, communicating that this link is blocked to directory service. +- preventing any communication in blocked groups. + +To clarify, all these restrictions are considered only for the groups that were created primarily to distribute or to promote CSAM content, they won't apply in cases some group members maliciously posted illegal content in a public group - in which case they will only be applied to this member, helping group owners to moderate. + +We will continue moderating the content as we do now, and as long as CSAM distribution is prevented, we may not need additional measures listed here. + +At the same time, we are committed to make it impossible to distribute CSAM in the part of SimpleX network that we or any other pre-configured operators operate. + +We are also committed to achieve this goal without any reduction in privacy and security even for the affected users. E.g., unless there is an enforceable order, we will not be recording any information identifying the user, such as IP address, because it may inadvertently affect the users whose content was flagged by mistake. + +Our ultimate commitment, and our business is to provide private and secure communication to the users who comply with conditions of use, and to prevent mass-scale surveillance of non-suspects (which is a direct violation of European Convention of Human Rights). + +Privacy and security of the network will further improve in 2025, as we plan: +- adding post-quantum encryption to small groups. +- adding proxying during file reception from unknown (or all) servers. +- adding scheduled and delayed re-broadcasts in large groups, to frustrate timing attacks that could otherwise allow identifying users who send messages to groups. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 37153da27e..4dc755b856 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -156,6 +156,8 @@ library Simplex.Chat.Migrations.M20241128_business_chats Simplex.Chat.Migrations.M20241205_business_chat_members Simplex.Chat.Migrations.M20241222_operator_conditions + Simplex.Chat.Migrations.M20241223_chat_tags + Simplex.Chat.Migrations.M20241230_reports Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8cc3267f3a..8bcfd60adf 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -885,7 +885,7 @@ processChatCommand' vr = \case Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> Just <$> withFastStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) _ -> pure Nothing - APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> case cType of + APISendMessages (ChatRef cType chatId) live itemTTL cms -> withUser $ \user -> mapM_ assertAllowedContent' cms >> case cType of CTDirect -> withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map (,Nothing) cms) @@ -895,9 +895,28 @@ processChatCommand' vr = \case CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - APICreateChatItems folderId cms -> withUser $ \user -> + APICreateChatItems folderId cms -> withUser $ \user -> do + mapM_ assertAllowedContent' cms createNoteFolderContentItems user folderId (L.map (,Nothing) cms) - APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of + APIReportMessage gId reportedItemId reportReason reportText -> withUser $ \user -> + withGroupLock "reportMessage" gId $ do + (gInfo, ms) <- + withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user gId + (gInfo,) <$> liftIO (getGroupModerators db vr user gInfo) + let ms' = filter compatibleModerator ms + mc = MCReport reportText reportReason + cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc} + when (null ms') $ throwChatError $ CECommandError "no moderators support receiving reports" + sendGroupContentMessages_ user gInfo ms' False Nothing [(cm, Nothing)] + where + compatibleModerator GroupMember {activeConn, memberChatVRange} = + maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= contentReportsVersion + ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do + gId <- withFastStore $ \db -> getGroupIdByName db user groupName + reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage + processChatCommand $ APIReportMessage gId reportedItemId reportReason "" + APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> assertAllowedContent mc >> case cType of CTDirect -> withContactLock "updateChatItem" chatId $ do ct@Contact {contactId} <- withFastStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ @@ -965,6 +984,7 @@ processChatCommand' vr = \case (ct, items) <- getCommandDirectChatItems user chatId itemIds case mode of CIDMInternal -> deleteDirectCIs user ct items True False + CIDMInternalMark -> markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertDirectAllowed user MDSnd ct XMsgDel_ @@ -980,6 +1000,7 @@ processChatCommand' vr = \case ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo case mode of CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime + CIDMInternalMark -> markGroupCIsDeleted user gInfo items True Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier @@ -1010,7 +1031,7 @@ processChatCommand' vr = \case (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable gInfo items - assertUserGroupRole gInfo GRAdmin + assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate let msgMemIds = itemsMsgMemIds gInfo items events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId)) msgMemIds mapM_ (sendGroupMessages user gInfo ms) events @@ -1131,6 +1152,7 @@ processChatCommand' vr = \case MCVideo {text} -> text /= "" MCVoice {text} -> text /= "" MCFile t -> t /= "" + MCReport {} -> True MCUnknown {} -> True APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do @@ -1888,6 +1910,7 @@ processChatCommand' vr = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId m <- withFastStore $ \db -> getGroupMember db vr user gId mId let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo + -- TODO GRModerator when most users migrate when (membershipRole >= GRAdmin) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages let settings = (memberSettings m) {showMessages} processChatCommand $ APISetMemberSettings gId mId settings @@ -2312,6 +2335,7 @@ processChatCommand' vr = \case Nothing -> throwChatError $ CEException "expected to find a single blocked member" Just (bm, remainingMembers) -> do let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm + -- TODO GRModerator when most users migrate assertUserGroupRole gInfo $ max GRAdmin bmRole when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked" withGroupLock "blockForAll" groupId . procCmd $ do @@ -3198,6 +3222,12 @@ processChatCommand' vr = \case forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, itemId) _ -> pure () -- prohibited + assertAllowedContent :: MsgContent -> CM () + assertAllowedContent = \case + MCReport {} -> throwChatError $ CECommandError "sending reports via this API is not supported" + _ -> pure () + assertAllowedContent' :: ComposedMessage -> CM () + assertAllowedContent' ComposedMessage {msgContent} = assertAllowedContent msgContent sendContactContentMessages :: User -> ContactId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendContactContentMessages user contactId live itemTTL cmrs = do assertMultiSendable live cmrs @@ -3258,13 +3288,17 @@ processChatCommand' vr = \case sendGroupContentMessages :: User -> GroupId -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendGroupContentMessages user groupId live itemTTL cmrs = do assertMultiSendable live cmrs - g@(Group gInfo _) <- withFastStore $ \db -> getGroup db vr user groupId + Group gInfo ms <- withFastStore $ \db -> getGroup db vr user groupId + sendGroupContentMessages_ user gInfo ms live itemTTL cmrs + sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do + assertMultiSendable live cmrs assertUserGroupRole gInfo GRAuthor - assertGroupContentAllowed gInfo - processComposedMessages g + assertGroupContentAllowed + processComposedMessages where - assertGroupContentAllowed :: GroupInfo -> CM () - assertGroupContentAllowed gInfo@GroupInfo {membership} = + assertGroupContentAllowed :: CM () + assertGroupContentAllowed = case findProhibited (L.toList cmrs) of Just f -> throwChatError (CECommandError $ "feature not allowed " <> T.unpack (groupFeatureNameText f)) Nothing -> pure () @@ -3274,8 +3308,8 @@ processChatCommand' vr = \case foldr' (\(ComposedMessage {fileSource, msgContent = mc}, _) acc -> prohibitedGroupContent gInfo membership mc fileSource <|> acc) Nothing - processComposedMessages :: Group -> CM ChatResponse - processComposedMessages g@(Group gInfo ms) = do + processComposedMessages :: CM ChatResponse + processComposedMessages = do (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainers, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ @@ -3296,7 +3330,7 @@ processChatCommand' vr = \case forM cmrs $ \(ComposedMessage {fileSource = file_}, _) -> case file_ of Just file -> do fileSize <- checkSndFile file - (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup g + (fInv, ciFile) <- xftpSndFileTransfer user file fileSize n $ CGGroup gInfo ms pure (Just fInv, Just ciFile) Nothing -> pure (Nothing, Nothing) prepareMsgs :: NonEmpty (ComposeMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTGroup))) @@ -3354,7 +3388,7 @@ processChatCommand' vr = \case case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -3545,6 +3579,7 @@ quoteContent mc qmc ciFile_ MCImage {} -> True MCVideo {} -> True MCVoice {} -> False + MCReport {} -> False MCUnknown {} -> True qText = msgContentText qmc getFileName :: CIFile d -> String @@ -6076,7 +6111,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci groupMsgToView gInfo ci' applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} - | moderatorRole < GRAdmin || moderatorRole < memberRole = + | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureAllowed SGFFullDelete gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ brokerTs CIRcvModerated Nothing timed' False @@ -6161,7 +6196,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CIGroupSnd -> moderate membership cci Left e | msgMemberId == memberId -> messageError $ "x.msg.del: message not found, " <> tshow e - | senderRole < GRAdmin -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e + | senderRole < GRModerator -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e | otherwise -> withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs where moderate :: GroupMember -> CChatItem 'CTGroup -> CM () @@ -6171,7 +6206,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" _ -> messageError "x.msg.del: message of another member without memberId" checkRole GroupMember {memberRole} a - | senderRole < GRAdmin || senderRole < memberRole = + | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM ChatResponse @@ -6907,7 +6942,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} - | senderRole < GRAdmin || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" + | senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do bm' <- setMemberBlocked bmId toggleNtf user bm' (not blocked) @@ -8404,6 +8439,8 @@ chatCommandP = "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), + "/report #" *> (ReportMessage <$> displayName <*> optional (" @" *> displayName) <*> _strP <* A.space <*> msgTextP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), @@ -8758,6 +8795,7 @@ chatCommandP = A.choice [ " owner" $> GROwner, " admin" $> GRAdmin, + " moderator" $> GRModerator, " member" $> GRMember, " observer" $> GRObserver ] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 593c328d0c..318d1939c8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -300,6 +300,8 @@ data ChatCommand | APIGetChatItemInfo ChatRef ChatItemId | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage} + | APIReportMessage {groupId :: GroupId, chatItemId :: ChatItemId, reportReason :: ReportReason, reportText :: Text} + | ReportMessage {groupName :: GroupName, contactName_ :: Maybe ContactName, reportReason :: ReportReason, reportedMessage :: Text} | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index e198183b06..6722a5427c 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -103,7 +103,7 @@ msgDirectionIntP = \case 1 -> Just MDSnd _ -> Nothing -data CIDeleteMode = CIDMBroadcast | CIDMInternal +data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark deriving (Show) $(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) @@ -111,7 +111,8 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case CIDMBroadcast -> "this item is deleted (broadcast)" - CIDMInternal -> "this item is deleted (internal)" + CIDMInternal -> "this item is deleted (locally)" + CIDMInternalMark -> "this item is deleted (locally)" -- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! Nested sum types also have to use different encodings for database and API diff --git a/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs b/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs new file mode 100644 index 0000000000..a83be7549d --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs @@ -0,0 +1,47 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20241223_chat_tags where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241223_chat_tags :: Query +m20241223_chat_tags = + [sql| +CREATE TABLE chat_tags ( + chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order INTEGER NOT NULL +); + +CREATE TABLE chat_tags_chats ( + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); + +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags(user_id, chat_tag_text); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags(user_id, chat_tag_emoji); + +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats(contact_id, chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(group_id, chat_tag_id); +|] + +down_m20241223_chat_tags :: Query +down_m20241223_chat_tags = + [sql| +DROP INDEX idx_chat_tags_user_id; +DROP INDEX idx_chat_tags_user_id_chat_tag_text; +DROP INDEX idx_chat_tags_user_id_chat_tag_emoji; + +DROP INDEX idx_chat_tags_chats_chat_tag_id; +DROP INDEX idx_chat_tags_chats_chat_tag_id_contact_id; +DROP INDEX idx_chat_tags_chats_chat_tag_id_group_id; + +DROP TABLE chat_tags_chats; +DROP TABLE chat_tags; +|] diff --git a/src/Simplex/Chat/Migrations/M20241230_reports.hs b/src/Simplex/Chat/Migrations/M20241230_reports.hs new file mode 100644 index 0000000000..d5b3c183cf --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241230_reports.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + + module Simplex.Chat.Migrations.M20241230_reports where + + import Database.SQLite.Simple (Query) + import Database.SQLite.Simple.QQ (sql) + + m20241230_reports :: Query + m20241230_reports = + [sql| +ALTER TABLE chat_items ADD COLUMN msg_content_tag TEXT; +|] + + down_m20241230_reports :: Query + down_m20241230_reports = + [sql| +ALTER TABLE chat_items DROP COLUMN msg_content_tag; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 0a6a581cbe..ee968383e0 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -402,7 +402,8 @@ CREATE TABLE chat_items( fwd_from_contact_id INTEGER REFERENCES contacts ON DELETE SET NULL, fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, - via_proxy INTEGER + via_proxy INTEGER, + msg_content_tag TEXT ); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( @@ -625,6 +626,18 @@ CREATE TABLE operator_usage_conditions( , auto_accepted INTEGER DEFAULT 0 ); +CREATE TABLE chat_tags( + chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order INTEGER NOT NULL +); +CREATE TABLE chat_tags_chats( + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -931,3 +944,21 @@ CREATE INDEX idx_chat_items_notes ON chat_items( created_at ); CREATE INDEX idx_groups_business_xcontact_id ON groups(business_xcontact_id); +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags( + user_id, + chat_tag_text +); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags( + user_id, + chat_tag_emoji +); +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats( + contact_id, + chat_tag_id +); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats( + group_id, + chat_tag_id +); diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 9eda85aaf3..07225856b6 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -300,22 +300,22 @@ newUserServer_ preset enabled server = UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} -- This function should be used inside DB transaction to update conditions in the database --- it evaluates to (conditions to mark as accepted to SimpleX operator, current conditions, and conditions to add) -usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +-- it evaluates to (current conditions, and conditions to add) +usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) usageConditionsToAdd = usageConditionsToAdd' previousConditionsCommit usageConditionsCommit -- This function is used in unit tests -usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions]) +usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case [] - | newUser -> (Just sourceCond, sourceCond, [sourceCond]) - | otherwise -> (Just prevCond, sourceCond, [prevCond, sourceCond]) + | newUser -> (sourceCond, [sourceCond]) + | otherwise -> (sourceCond, [prevCond, sourceCond]) where prevCond = conditions 1 prevCommit sourceCond = conditions 2 sourceCommit conds - | hasSourceCond -> (Nothing, last conds, []) - | otherwise -> (Nothing, sourceCond, [sourceCond]) + | hasSourceCond -> (last conds, []) + | otherwise -> (sourceCond, [sourceCond]) where hasSourceCond = any ((sourceCommit ==) . conditionsCommit) conds sourceCond = conditions cId sourceCommit diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 934f23007d..89664d66f7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -37,7 +37,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.String import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Text.Encoding (decodeASCII', decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Type.Equality import Data.Typeable (Typeable) @@ -48,6 +48,7 @@ import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding @@ -69,12 +70,13 @@ import Simplex.Messaging.Version hiding (version) -- 9 - batch sending in direct connections (2024-07-24) -- 10 - business chats (2024-11-29) -- 11 - fix profile update in business chats (2024-12-05) +-- 12 - fix profile update in business chats (2025-01-03) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 11 +currentChatVersion = VersionChat 12 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -121,6 +123,10 @@ businessChatsVersion = VersionChat 10 businessChatPrefsVersion :: VersionChat businessChatPrefsVersion = VersionChat 11 +-- support sending and receiving content reports (MCReport message content) +contentReportsVersion :: VersionChat +contentReportsVersion = VersionChat 12 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion @@ -246,6 +252,9 @@ data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) +data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRUnknown Text + deriving (Eq, Show) + $(pure []) instance FromJSON LinkContent where @@ -265,6 +274,30 @@ instance ToJSON LinkContent where $(JQ.deriveJSON defaultJSON ''LinkPreview) +instance StrEncoding ReportReason where + strEncode = \case + RRSpam -> "spam" + RRContent -> "content" + RRCommunity -> "community" + RRProfile -> "profile" + RROther -> "other" + RRUnknown t -> encodeUtf8 t + strP = + A.takeTill (== ' ') >>= \case + "spam" -> pure RRSpam + "content" -> pure RRContent + "community" -> pure RRCommunity + "profile" -> pure RRProfile + "other" -> pure RROther + t -> maybe (fail "bad ReportReason") (pure . RRUnknown) $ decodeASCII' t + +instance FromJSON ReportReason where + parseJSON = strParseJSON "ReportReason" + +instance ToJSON ReportReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data ChatMessage e = ChatMessage { chatVRange :: VersionRangeChat, msgId :: Maybe SharedMsgId, @@ -451,7 +484,7 @@ cmToQuotedMsg = \case ACME _ (XMsgNew (MCQuote quotedMsg _)) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCUnknown_ Text +data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCReport_ | MCUnknown_ Text deriving (Eq) instance StrEncoding MsgContentTag where @@ -462,6 +495,7 @@ instance StrEncoding MsgContentTag where MCVideo_ -> "video" MCFile_ -> "file" MCVoice_ -> "voice" + MCReport_ -> "report" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ @@ -470,6 +504,7 @@ instance StrEncoding MsgContentTag where "video" -> Right MCVideo_ "voice" -> Right MCVoice_ "file" -> Right MCFile_ + "report" -> Right MCReport_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -480,6 +515,10 @@ instance ToJSON MsgContentTag where toJSON = strToJSON toEncoding = strToJEncoding +instance FromField MsgContentTag where fromField = fromBlobField_ strDecode + +instance ToField MsgContentTag where toField = toField . strEncode + data MsgContainer = MCSimple ExtMsgContent | MCQuote QuotedMsg ExtMsgContent @@ -504,6 +543,7 @@ data MsgContent | MCVideo {text :: Text, image :: ImageData, duration :: Int} | MCVoice {text :: Text, duration :: Int} | MCFile Text + | MCReport {text :: Text, reason :: ReportReason} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -518,6 +558,10 @@ msgContentText = \case where msg = "voice message " <> durationText duration MCFile t -> t + MCReport {text, reason} -> + if T.null text then msg else msg <> ": " <> text + where + msg = "report " <> safeDecodeUtf8 (strEncode reason) MCUnknown {text} -> text toMCText :: MsgContent -> MsgContent @@ -532,16 +576,9 @@ durationText duration = | otherwise = show n msgContentHasText :: MsgContent -> Bool -msgContentHasText = \case - MCText t -> hasText t - MCLink {text} -> hasText text - MCImage {text} -> hasText text - MCVideo {text} -> hasText text - MCVoice {text} -> hasText text - MCFile t -> hasText t - MCUnknown {text} -> hasText text - where - hasText = not . T.null +msgContentHasText = not . T.null . \case + MCVoice {text} -> text + mc -> msgContentText mc isVoice :: MsgContent -> Bool isVoice = \case @@ -556,6 +593,7 @@ msgContentTag = \case MCVideo {} -> MCVideo_ MCVoice {} -> MCVoice_ MCFile {} -> MCFile_ + MCReport {} -> MCReport_ MCUnknown {tag} -> MCUnknown_ tag data ExtMsgContent = ExtMsgContent {content :: MsgContent, file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool} @@ -654,6 +692,10 @@ instance FromJSON MsgContent where duration <- v .: "duration" pure MCVoice {text, duration} MCFile_ -> MCFile <$> v .: "text" + MCReport_ -> do + text <- v .: "text" + reason <- v .: "reason" + pure MCReport {text, reason} MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -681,6 +723,7 @@ instance ToJSON MsgContent where MCVideo {text, image, duration} -> J.object ["type" .= MCVideo_, "text" .= text, "image" .= image, "duration" .= duration] MCVoice {text, duration} -> J.object ["type" .= MCVoice_, "text" .= text, "duration" .= duration] MCFile t -> J.object ["type" .= MCFile_, "text" .= t] + MCReport {text, reason} -> J.object ["type" .= MCReport_, "text" .= text, "reason" .= reason] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t @@ -689,6 +732,7 @@ instance ToJSON MsgContent where MCVideo {text, image, duration} -> J.pairs $ "type" .= MCVideo_ <> "text" .= text <> "image" .= image <> "duration" .= duration MCVoice {text, duration} -> J.pairs $ "type" .= MCVoice_ <> "text" .= text <> "duration" .= duration MCFile t -> J.pairs $ "type" .= MCFile_ <> "text" .= t + MCReport {text, reason} -> J.pairs $ "type" .= MCReport_ <> "text" .= text <> "reason" .= reason instance ToField MsgContent where toField = toField . encodeJSON diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 36ce7f3575..51d23cf1e9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -49,6 +49,7 @@ module Simplex.Chat.Store.Groups getGroupMemberById, getGroupMemberByMemberId, getGroupMembers, + getGroupModerators, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupConnectionsAndFiles, @@ -740,8 +741,16 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) <$> DB.query db - (groupMemberQuery <> " WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") - (userId, groupId, userId, userContactId) + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") + (userId, userId, groupId, userContactId) + +getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND member_role IN (?,?,?)") + (userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 13cadcca77..01ae7b7c28 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -405,21 +405,21 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q -- user and IDs user_id, created_by_msg_id, contact_id, group_id, group_member_id, note_folder_id, -- meta - item_sent, item_ts, item_content, item_content_tag, item_text, item_status, shared_msg_id, + item_sent, item_ts, item_content, item_content_tag, item_text, item_status, msg_content_tag, shared_msg_id, forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 7d4d96dff2..8710641b33 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -120,6 +120,8 @@ import Simplex.Chat.Migrations.M20241125_indexes import Simplex.Chat.Migrations.M20241128_business_chats import Simplex.Chat.Migrations.M20241205_business_chat_members import Simplex.Chat.Migrations.M20241222_operator_conditions +import Simplex.Chat.Migrations.M20241223_chat_tags +import Simplex.Chat.Migrations.M20241230_reports import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -239,7 +241,9 @@ schemaMigrations = ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes), ("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats), ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members), - ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions) + ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), + ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), + ("20241230_reports", m20241230_reports, Just down_m20241230_reports) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 013075841e..252f468130 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -617,18 +617,14 @@ getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> getUpdateServerOperators db presetOps newUser = do conds <- map toUsageConditions <$> DB.query_ db usageCondsQuery now <- getCurrentTime - let (acceptForSimplex_, currentConds, condsToAdd) = usageConditionsToAdd newUser now conds + let (currentConds, condsToAdd) = usageConditionsToAdd newUser now conds mapM_ insertConditions condsToAdd latestAcceptedConds_ <- getLatestAcceptedConditions db ops <- updatedServerOperators presetOps <$> getServerOperators_ db forM ops $ traverse $ mapM $ \(ASO _ op) -> -- traverse for tuple, mapM for Maybe case operatorId op of - DBNewEntity -> do - op' <- insertOperator op - case (operatorTag op', acceptForSimplex_) of - (Just OTSimplex, Just cond) -> autoAcceptConditions op' cond now - _ -> pure op' + DBNewEntity -> insertOperator op DBEntityId _ -> do updateOperator op getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 77a02a4bc1..d5b45b35c0 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -411,12 +411,12 @@ data GroupSummary = GroupSummary } deriving (Show) -data ContactOrGroup = CGContact Contact | CGGroup Group +data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId) contactAndGroupIds = \case CGContact Contact {contactId} -> (Just contactId, Nothing) - CGGroup (Group GroupInfo {groupId} _) -> (Nothing, Just groupId) + CGGroup GroupInfo {groupId} _ -> (Nothing, Just groupId) -- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties) data ChatSettings = ChatSettings diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index f44457160f..4601fe4e4a 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -16,6 +16,7 @@ data GroupMemberRole = GRObserver -- connects to all group members and receives all messages, can't send messages | GRAuthor -- reserved, unused | GRMember -- + can send messages to all group members + | GRModerator -- + moderate messages and block members (excl. Admins and Owners) | GRAdmin -- + add/remove members, change member role (excl. Owners) | GROwner -- + delete and change group information, add/remove/change roles for Owners deriving (Eq, Show, Ord) @@ -28,12 +29,14 @@ instance StrEncoding GroupMemberRole where strEncode = \case GROwner -> "owner" GRAdmin -> "admin" + GRModerator -> "moderator" GRMember -> "member" GRAuthor -> "author" GRObserver -> "observer" strDecode = \case "owner" -> Right GROwner "admin" -> Right GRAdmin + "moderator" -> Right GRModerator "member" -> Right GRMember "author" -> Right GRAuthor "observer" -> Right GRObserver diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 95d27801f6..960b4bd96e 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1231,14 +1231,14 @@ testOperators = alice ##> "/_conditions" alice <##. "Current conditions: 2." alice ##> "/_operators" - alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" alice <##. "The new conditions will be accepted for SimpleX Chat Ltd at " -- set conditions notified alice ##> "/_conditions_notified 2" alice <## "ok" alice ##> "/_operators" - alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required (" + alice <##. "1 (simplex). SimpleX Chat (SimpleX Chat Ltd), domains: simplex.im, servers: enabled, conditions: required" alice <## "2 (flux). Flux (InFlux Technologies Limited), domains: simplexonflux.com, servers: disabled, conditions: required" alice ##> "/_conditions" alice <##. "Current conditions: 2 (notified)." diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a185b0038e..7f1a9d3e56 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -175,6 +175,8 @@ chatGroupTests = do it "can't repeat block, unblock" testBlockForAllCantRepeat describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive + describe "group member reports" $ do + it "should send report to group owner, admins and moderators, but not other users" testGroupMemberReports where _0 = supportedChatVRange -- don't create direct connections _1 = groupCreateDirectVRange @@ -6540,3 +6542,61 @@ testGroupMemberInactive tmp = do { smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7003"] } } + +testGroupMemberReports :: HasCallStack => FilePath -> IO () +testGroupMemberReports = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "jokes" alice bob cath + alice ##> "/mr jokes bob moderator" + concurrentlyN_ + [ alice <## "#jokes: you changed the role of bob from admin to moderator", + bob <## "#jokes: alice changed your role from admin to moderator", + cath <## "#jokes: alice changed the role of bob from admin to moderator" + ] + alice ##> "/mr jokes cath member" + concurrentlyN_ + [ alice <## "#jokes: you changed the role of cath from admin to member", + bob <## "#jokes: alice changed the role of cath from admin to member", + cath <## "#jokes: alice changed your role from admin to member" + ] + alice ##> "/create link #jokes" + gLink <- getGroupLink alice "jokes" GRMember True + dan ##> ("/c " <> gLink) + dan <## "connection request sent!" + concurrentlyN_ + [ do + alice <## "dan (Daniel): accepting request to join group #jokes..." + alice <## "#jokes: dan joined the group", + do + dan <## "#jokes: joining the group..." + dan <## "#jokes: you joined the group" + dan <### + [ "#jokes: member bob (Bob) is connected", + "#jokes: member cath (Catherine) is connected" + ], + do + bob <## "#jokes: alice added dan (Daniel) to the group (connecting...)" + bob <## "#jokes: new member dan is connected", + do + cath <## "#jokes: alice added dan (Daniel) to the group (connecting...)" + cath <## "#jokes: new member dan is connected" + ] + cath #> "#jokes inappropriate joke" + concurrentlyN_ + [ alice <# "#jokes cath> inappropriate joke", + bob <# "#jokes cath> inappropriate joke", + dan <# "#jokes cath> inappropriate joke" + ] + dan ##> "/report #jokes content inappropriate joke" + dan <# "#jokes > cath inappropriate joke" + dan <## " report content" + concurrentlyN_ + [ do + alice <# "#jokes dan> > cath inappropriate joke" + alice <## " report content", + do + bob <# "#jokes dan> > cath inappropriate joke" + bob <## " report content", + (cath show currentChatVersion <> ")") groupInfo a = do a <## "group ID: 1" a <## "current members: 1" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 523df81ade..aba67ed034 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-11\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-12\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -182,6 +182,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ) ) ) + it "x.msg.new report" $ + "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"reason\":\"spam\",\"type\":\"report\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" + ##==## ChatMessage + chatInitialVRange + (Just $ SharedMsgId "\1\2\3\4") + (XMsgNew (MCQuote quotedMsg (extMsgContent (MCReport "" RRSpam) Nothing))) it "x.msg.new forward with file" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -243,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -264,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-11\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-12\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" From 8b5bc44106f93ee82c5308cd726ea0be41719d1d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 4 Jan 2025 19:18:55 +0000 Subject: [PATCH 40/95] core: remove duplicate check when sending message --- src/Simplex/Chat.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8bcfd60adf..f92b60dbc1 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3292,7 +3292,6 @@ processChatCommand' vr = \case sendGroupContentMessages_ user gInfo ms live itemTTL cmrs sendGroupContentMessages_ :: User -> GroupInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposeMessageReq -> CM ChatResponse sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} ms live itemTTL cmrs = do - assertMultiSendable live cmrs assertUserGroupRole gInfo GRAuthor assertGroupContentAllowed processComposedMessages From 38db2d075d57f15578528f1949fc9b6333bd3c15 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 6 Jan 2025 16:42:46 +0000 Subject: [PATCH 41/95] android, desktop: types/api for reports (#5483) * android, desktop: types/api for reports * extra char * data object -> object * change --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../chat/simplex/common/model/ChatModel.kt | 54 +++++++++++++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 14 +++++ .../simplex/common/views/chat/ComposeView.kt | 2 + .../commonMain/resources/MR/base/strings.xml | 1 + 4 files changed, 71 insertions(+) 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 f42ace7c7c..b9bf51f3f1 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 @@ -3321,6 +3321,7 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val isVoice: Boolean get() = @@ -3397,6 +3398,10 @@ object MsgContentSerializer : KSerializer { element("MCFile", buildClassSerialDescriptor("MCFile") { element("text") }) + element("MCReport", buildClassSerialDescriptor("MCReport") { + element("text") + element("reason") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -3427,6 +3432,10 @@ object MsgContentSerializer : KSerializer { MsgContent.MCVoice(text, duration) } "file" -> MsgContent.MCFile(text) + "report" -> { + val reason = Json.decodeFromString(json["reason"].toString()) + MsgContent.MCReport(text, reason) + } else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -3475,6 +3484,12 @@ object MsgContentSerializer : KSerializer { put("type", "file") put("text", value.text) } + is MsgContent.MCReport -> + buildJsonObject { + put("type", "report") + put("text", value.text) + put("reason", json.encodeToJsonElement(value.reason)) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) @@ -3569,6 +3584,45 @@ enum class FormatColor(val color: String) { } } + +@Serializable(with = ReportReasonSerializer::class) +sealed class ReportReason { + @Serializable @SerialName("spam") object Spam: ReportReason() + @Serializable @SerialName("illegal") object Illegal: ReportReason() + @Serializable @SerialName("community") object Community: ReportReason() + @Serializable @SerialName("profile") object Profile: ReportReason() + @Serializable @SerialName("other") object Other: ReportReason() + @Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason() +} + +object ReportReasonSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("ReportReason", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): ReportReason { + return when (val value = decoder.decodeString()) { + "spam" -> ReportReason.Spam + "illegal" -> ReportReason.Illegal + "community" -> ReportReason.Community + "profile" -> ReportReason.Profile + "other" -> ReportReason.Other + else -> ReportReason.Unknown(value) + } + } + + override fun serialize(encoder: Encoder, value: ReportReason) { + val stringValue = when (value) { + is ReportReason.Spam -> "spam" + is ReportReason.Illegal -> "illegal" + is ReportReason.Community -> "community" + is ReportReason.Profile -> "profile" + is ReportReason.Other -> "other" + is ReportReason.Unknown -> value.type + } + encoder.encodeString(stringValue) + } +} + @Serializable class SndFileTransfer() {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index f8d67c6f73..e78472d2d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -941,6 +941,17 @@ object ChatController { } } + suspend fun apiReportMessage(rh: Long?, groupId: Long, chatItemId: Long, reportReason: ReportReason, reportText: String): List? { + val r = sendCmd(rh, CC.ApiReportMessage(groupId, chatItemId, reportReason, reportText)) + return when (r) { + is CR.NewChatItems -> r.chatItems + else -> { + apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r) + null + } + } + } + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo @@ -3159,6 +3170,7 @@ sealed class CC { class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() + class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() @@ -3321,6 +3333,7 @@ sealed class CC { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" } + is ApiReportMessage -> "/_report #$groupId $chatItemId reason=$reportReason $reportText" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" @@ -3478,6 +3491,7 @@ sealed class CC { is ApiGetChatItemInfo -> "apiGetChatItemInfo" is ApiSendMessages -> "apiSendMessages" is ApiCreateChatItems -> "apiCreateChatItems" + is ApiReportMessage -> "apiReportMessage" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" 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 b0b12a7443..2247a615df 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 @@ -170,6 +170,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) + is MsgContent.MCReport -> ComposePreview.NoPreview is MsgContent.MCUnknown, null -> ComposePreview.NoPreview } } @@ -483,6 +484,7 @@ fun ComposeView( is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration) is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) is MsgContent.MCFile -> MsgContent.MCFile(msgText) + is MsgContent.MCReport -> MsgContent.MCReport(msgText, reason = msgContent.reason) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) } } 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 b052727ad2..22ad02a52e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -139,6 +139,7 @@ Error sending message Error forwarding messages Error creating message + Error creating report Error loading details Error adding member(s) Error joining group From e3e5d9646c598ff28a38c30bea710c9d8764f4b2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 6 Jan 2025 20:14:31 +0000 Subject: [PATCH 42/95] core: fix delete api #5484 --- src/Simplex/Chat.hs | 3 +-- src/Simplex/Chat/Messages/CIContent.hs | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f92b60dbc1..ca8ec60c41 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -8441,7 +8441,7 @@ chatCommandP = "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), "/report #" *> (ReportMessage <$> displayName <*> optional (" @" *> displayName) <*> _strP <* A.space <*> msgTextP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), - "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode), + "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), @@ -8724,7 +8724,6 @@ chatCommandP = <|> (PTBefore <$ "before=" <*> strP <* A.space <* "count=" <*> A.decimal) mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP - ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal chatDeleteMode = A.choice [ " full" *> (CDMFull <$> notifyP), diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 6722a5427c..96a4f9f6c8 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -17,6 +17,7 @@ module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ +import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -106,7 +107,24 @@ msgDirectionIntP = \case data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark deriving (Show) -$(JQ.deriveJSON (enumJSON $ dropPrefix "CIDM") ''CIDeleteMode) +instance StrEncoding CIDeleteMode where + strEncode = \case + CIDMBroadcast -> "broadcast" + CIDMInternal -> "internal" + CIDMInternalMark -> "internalMark" + strP = + A.takeTill (== ' ') >>= \case + "broadcast" -> pure CIDMBroadcast + "internal" -> pure CIDMInternal + "internalMark" -> pure CIDMInternalMark + _ -> fail "bad CIDeleteMode" + +instance ToJSON CIDeleteMode where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromJSON CIDeleteMode where + parseJSON = strParseJSON "CIDeleteMode" ciDeleteModeToText :: CIDeleteMode -> Text ciDeleteModeToText = \case From 05a5d161fb6c5b5a109b858088a406ac06e0571c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 7 Jan 2025 16:52:01 +0700 Subject: [PATCH 43/95] desktop: saving settings in a safer way to handle process death (#4687) * desktop: saving settings in a safer way to handle process death * enhancements * unused * changes * rename --- .../simplex/common/platform/Files.android.kt | 2 + .../chat/simplex/common/model/SimpleXAPI.kt | 8 +++- .../chat/simplex/common/platform/Files.kt | 28 +++++++++---- .../simplex/common/ui/theme/ThemeManager.kt | 4 +- .../simplex/common/views/helpers/Utils.kt | 5 ++- .../common/views/onboarding/WhatsNewView.kt | 6 ++- .../commonMain/resources/MR/base/strings.xml | 1 + .../simplex/common/platform/Files.desktop.kt | 2 + .../common/platform/Resources.desktop.kt | 41 +++++++++++++++---- 9 files changed, 73 insertions(+), 24 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index bfe961a512..03012e318c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -19,6 +19,8 @@ actual val wallpapersDir: File = File(filesDir.absolutePath + File.separator + " actual val coreTmpDir: File = File(filesDir.absolutePath + File.separator + "temp_files") actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "files" actual val preferencesDir = File(dataDir.absolutePath + File.separator + "shared_prefs") +actual val preferencesTmpDir = File(tmpDir, "prefs_tmp") + .also { it.deleteRecursively() } actual val chatDatabaseFileName: String = "files_chat.db" actual val agentDatabaseFileName: String = "files_agent.db" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index e2ff76f53f..ac9c1a131b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3168,8 +3168,12 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { init { this.set = { value -> - set(value) - _state.value = value + try { + set(value) + _state.value = value + } catch (e: Exception) { + Log.e(TAG, "Error saving settings: ${e.stackTraceToString()}") + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index e9fc8c97f9..0a4f670fe0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -3,7 +3,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import com.charleskorn.kaml.* import kotlinx.serialization.encodeToString @@ -11,6 +11,8 @@ import java.io.* import java.net.URI import java.net.URLDecoder import java.net.URLEncoder +import java.nio.file.Files +import java.nio.file.StandardCopyOption expect val dataDir: File expect val tmpDir: File @@ -20,6 +22,7 @@ expect val wallpapersDir: File expect val coreTmpDir: File expect val dbAbsolutePrefixPath: String expect val preferencesDir: File +expect val preferencesTmpDir: File expect val chatDatabaseFileName: String expect val agentDatabaseFileName: String @@ -142,16 +145,23 @@ fun readThemeOverrides(): List { } } +private const val lock = "themesWriter" + fun writeThemeOverrides(overrides: List): Boolean = - try { - File(getPreferenceFilePath("themes.yaml")).outputStream().use { - val string = yaml.encodeToString(ThemesFile(themes = overrides)) - it.bufferedWriter().use { it.write(string) } + synchronized(lock) { + try { + val themesFile = File(getPreferenceFilePath("themes.yaml")) + createTmpFileAndDelete(preferencesTmpDir) { tmpFile -> + val string = yaml.encodeToString(ThemesFile(themes = overrides)) + tmpFile.bufferedWriter().use { it.write(string) } + themesFile.parentFile.mkdirs() + Files.move(tmpFile.toPath(), themesFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + true + } catch (e: Exception) { + Log.e(TAG, "Error writing themes file: ${e.stackTraceToString()}") + false } - true - } catch (e: Throwable) { - Log.e(TAG, "Error while writing themes file: ${e.stackTraceToString()}") - false } private fun fileReady(file: CIFile, filePath: String) = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index a5293b6a24..07f2b678cf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -102,7 +102,9 @@ object ThemeManager { } fun applyTheme(theme: String) { - appPrefs.currentTheme.set(theme) + if (appPrefs.currentTheme.get() != theme) { + appPrefs.currentTheme.set(theme) + } CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) platform.androidSetNightModeIfSupported() val c = CurrentColors.value.colors diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 39611361e3..d5bbe4b352 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -316,8 +316,9 @@ fun removeWallpaperFile(fileName: String? = null) { WallpaperType.cachedImages.remove(fileName) } -fun createTmpFileAndDelete(onCreated: (File) -> T): T { - val tmpFile = File(tmpDir, UUID.randomUUID().toString()) +fun createTmpFileAndDelete(dir: File = tmpDir, onCreated: (File) -> T): T { + val tmpFile = File(dir, UUID.randomUUID().toString()) + tmpFile.parentFile.mkdirs() tmpFile.deleteOnExit() ChatModel.filesToDelete.add(tmpFile) try { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index a5cb944f0a..a66ef9ff7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -16,6 +16,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.setConditionsNotified import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo @@ -766,7 +768,9 @@ private val versionDescriptions: List = listOf( private val lastVersion = versionDescriptions.last().version fun setLastVersionDefault(m: ChatModel) { - m.controller.appPrefs.whatsNewVersion.set(lastVersion) + if (appPrefs.whatsNewVersion.get() != lastVersion) { + appPrefs.whatsNewVersion.set(lastVersion) + } } fun shouldShowWhatsNew(m: ChatModel): Boolean { 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 ece68481fa..04b025718a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -943,6 +943,7 @@ Show slow API calls Shutdown? Notifications will stop working until you re-launch the app + Error saving settings Create address diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index eeeb13e5cc..f7a87e3ced 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -17,6 +17,8 @@ actual val wallpapersDir: File = File(dataDir.absolutePath + File.separator + "s actual val coreTmpDir: File = File(dataDir.absolutePath + File.separator + "tmp") actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "simplex_v1" actual val preferencesDir = File(desktopPlatform.configPath).also { it.parentFile.mkdirs() } +actual val preferencesTmpDir = File(desktopPlatform.configPath, "tmp") + .also { it.deleteRecursively() } actual val chatDatabaseFileName: String = "simplex_v1_chat.db" actual val agentDatabaseFileName: String = "simplex_v1_agent.db" diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt index 951185dc98..4e7594f998 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt @@ -9,15 +9,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.simplexWindowState import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR import com.jthemedetecor.OsThemeDetector import com.russhwolf.settings.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.desc.desc -import kotlinx.coroutines.* import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.* -import java.util.concurrent.Executors @Composable actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font = @@ -37,10 +38,8 @@ catch (e: Exception) { private val settingsFile = File(desktopPlatform.configPath + File.separator + "settings.properties") - .also { it.parentFile.mkdirs() } private val settingsThemesFile = File(desktopPlatform.configPath + File.separator + "themes.properties") - .also { it.parentFile.mkdirs() } private val settingsProps = Properties() @@ -61,11 +60,35 @@ private val settingsThemesProps = Properties() .also { props -> try { settingsThemesFile.reader().use { props.load(it) } } catch (e: Exception) { /**/ } } - -private val settingsWriterThread = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - -actual val settings: Settings = PropertiesSettings(settingsProps) { CoroutineScope(settingsWriterThread).launch { settingsFile.writer().use { settingsProps.store(it, "") } } } -actual val settingsThemes: Settings = PropertiesSettings(settingsThemesProps) { CoroutineScope(settingsWriterThread).launch { settingsThemesFile.writer().use { settingsThemesProps.store(it, "") } } } +private const val lock = "settingsSaver" +actual val settings: Settings = PropertiesSettings(settingsProps) { + synchronized(lock) { + try { + createTmpFileAndDelete(preferencesTmpDir) { tmpFile -> + tmpFile.writer().use { settingsProps.store(it, "") } + settingsFile.parentFile.mkdirs() + Files.move(tmpFile.toPath(), settingsFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.prefs_error_saving_settings), e.stackTraceToString()) + throw e + } + } +} +actual val settingsThemes: Settings = PropertiesSettings(settingsThemesProps) { + synchronized(lock) { + try { + createTmpFileAndDelete(preferencesTmpDir) { tmpFile -> + tmpFile.writer().use { settingsThemesProps.store(it, "") } + settingsThemesFile.parentFile.mkdirs() + Files.move(tmpFile.toPath(), settingsThemesFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.prefs_error_saving_settings), e.stackTraceToString()) + throw e + } + } +} actual fun windowOrientation(): WindowOrientation = if (simplexWindowState.windowState.size.width > simplexWindowState.windowState.size.height) { From f33a9650bc12d18d362bd0a9a9bb6ad9e281df82 Mon Sep 17 00:00:00 2001 From: Diogo Date: Tue, 7 Jan 2025 20:58:22 +0000 Subject: [PATCH 44/95] android, desktop: fix previous years display on chat view (#5486) --- .../commonMain/kotlin/chat/simplex/common/model/ChatModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b9bf51f3f1..ac33917588 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 @@ -2540,7 +2540,7 @@ fun getTimestampDateText(t: Instant): String { val time = t.toLocalDateTime(tz).toJavaLocalDateTime() val weekday = time.format(DateTimeFormatter.ofPattern("EEE")) val dayMonthYear = time.format(DateTimeFormatter.ofPattern( - if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM YYYY") + if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM yyyy") ) return "$weekday, $dayMonthYear" From 8dc29082d5125e4ce13d218feb135e675c1d059e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 8 Jan 2025 13:31:32 +0400 Subject: [PATCH 45/95] core: fix auto-accepting conditions for simplex operator (#5489) --- src/Simplex/Chat/Store/Profiles.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 252f468130..5f72915701 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -628,7 +628,6 @@ getUpdateServerOperators db presetOps newUser = do DBEntityId _ -> do updateOperator op getOperatorConditions_ db op currentConds latestAcceptedConds_ now >>= \case - CARequired Nothing | operatorTag op == Just OTSimplex -> autoAcceptConditions op currentConds now CARequired (Just ts) | ts < now -> autoAcceptConditions op currentConds now ca -> pure op {conditionsAcceptance = ca} where From 569832c8de7d485de828ccc7153e677cb4859b15 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 8 Jan 2025 09:42:26 +0000 Subject: [PATCH 46/95] core: rfc, protocol and types for user reports (#5451) * core: rfc, protocol and types for user reports * add comment * rfc * moderation rfc * api, types * update * typos * migration * update * report reason * query * deleted * remove auto-accepting conditions for SimpleX Chat Ltd * api, query * make indices work * index without filtering * query for unread * postgres: rework chat list pagination query (#5441) * fix query * fix * report counts to stats * internalMark * fix parser * AND * delete reports on event, fix counters * test * remove reports when message is moderated on sending side --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/ios/SimpleXChat/ChatTypes.swift | 2 + .../chat/simplex/common/model/ChatModel.kt | 4 +- .../src/Directory/Service.hs | 2 +- docs/protocol/simplex-chat.schema.json | 6 + simplex-chat.cabal | 1 + src/Simplex/Chat/Controller.hs | 9 +- src/Simplex/Chat/Library/Commands.hs | 16 +- src/Simplex/Chat/Library/Subscriber.hs | 8 +- src/Simplex/Chat/Messages.hs | 15 +- .../Chat/Migrations/M20241230_reports.hs | 18 +- .../Chat/Migrations/M20250105_indexes.hs | 20 + src/Simplex/Chat/Migrations/chat_schema.sql | 13 + src/Simplex/Chat/Protocol.hs | 20 +- src/Simplex/Chat/Store/Messages.hs | 807 +++++++++--------- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Groups.hs | 31 + 17 files changed, 519 insertions(+), 458 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20250105_indexes.hs diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index cdee6df558..571ac20684 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3348,9 +3348,11 @@ public enum MREmojiChar: String, Codable, CaseIterable, Hashable { case thumbsup = "👍" case thumbsdown = "👎" case smile = "😀" + case laugh = "😂" case sad = "😢" case heart = "❤" case launch = "🚀" + case check = "✅" } extension MsgReaction: Decodable { 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 0c99d5f42b..26af461615 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 @@ -3202,9 +3202,11 @@ enum class MREmojiChar(val value: String) { @SerialName("👍") ThumbsUp("👍"), @SerialName("👎") ThumbsDown("👎"), @SerialName("😀") Smile("😀"), + @SerialName("😂") Laugh("😂"), @SerialName("😢") Sad("😢"), @SerialName("❤") Heart("❤"), - @SerialName("🚀") Launch("🚀"); + @SerialName("🚀") Launch("🚀"), + @SerialName("✅") Check("✅"); } @Serializable diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index afcdb233e8..d2016ff1f5 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -688,7 +688,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe sendComposedMessage cc ct Nothing $ MCText text getContact :: ChatController -> ContactId -> IO (Maybe Contact) -getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId) (CPLast 0) Nothing) +getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId) Nothing (CPLast 0) Nothing) where resp :: ChatResponse -> Maybe Contact resp = \case diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 2e94a4f2c2..50d41265f7 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -113,6 +113,12 @@ "properties": { "text": {"type": "string", "metadata": {"comment": "can be empty"}} } + }, + "report": { + "properties": { + "text": {"type": "string", "metadata": {"comment": "can be empty, includes report reason for old clients"}}, + "reason": {"enum": ["spam", "illegal", "community", "other"]} + } } }, "metadata": { diff --git a/simplex-chat.cabal b/simplex-chat.cabal index dd9d24eeed..66eecf141d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -162,6 +162,7 @@ library Simplex.Chat.Migrations.M20241222_operator_conditions Simplex.Chat.Migrations.M20241223_chat_tags Simplex.Chat.Migrations.M20241230_reports + Simplex.Chat.Migrations.M20250105_indexes Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9a4b35cb6a..f6f7416bb1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -297,7 +297,7 @@ data ChatCommand | SlowSQLQueries | APIGetChatTags UserId | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} - | APIGetChat ChatRef ChatPagination (Maybe String) + | APIGetChat ChatRef (Maybe ContentFilter) ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatRef ChatItemId | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} @@ -635,6 +635,7 @@ data ChatResponse | CRChatItemReaction {user :: User, added :: Bool, reaction :: ACIReaction} | CRReactionMembers {user :: User, memberReactions :: [MemberReaction]} | CRChatItemsDeleted {user :: User, chatItemDeletions :: [ChatItemDeletion], byUser :: Bool, timed :: Bool} + | CRGroupChatItemsDeleted {user :: User, groupInfo :: GroupInfo, chatItemIDs :: [ChatItemId], byUser :: Bool, member_ :: Maybe GroupMember} | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} | CRBroadcastSent {user :: User, msgContent :: MsgContent, successes :: Int, failures :: Int, timestamp :: UTCTime} | CRMsgIntegrityError {user :: User, msgError :: MsgErrorType} @@ -867,6 +868,12 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False +data ContentFilter = ContentFilter + { mcTag :: MsgContentTag, + deleted :: Maybe Bool + } + deriving (Show) + data ChatPagination = CPLast Int | CPAfter ChatItemId Int diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f0fbdd26f8..d25444a358 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -481,15 +481,17 @@ processChatCommand' vr = \case (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) pure $ CRApiChats user previews - APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of + APIGetChat (ChatRef cType cId) contentFilter pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do + when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" (directChat, navInfo) <- withFastStore (\db -> getDirectChat db vr user cId pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do - (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId pagination search) + (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId contentFilter pagination search) pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo CTLocal -> do + when (isJust contentFilter) $ throwChatError $ CECommandError "content filter not supported" (localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search) pure $ CRApiChat user (AChat SCTLocal localChat) navInfo CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" @@ -2158,14 +2160,14 @@ processChatCommand' vr = \case pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName - chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search + chatResp <- processChatCommand $ APIGetChat chatRef Nothing (CPLast count) search pure $ CRChatItems user (Just chatName) (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast count) search pure $ CRChatItems user Nothing chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName - chatResp <- processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) + chatResp <- processChatCommand (APIGetChat chatRef Nothing (CPLast $ index + 1) Nothing) pure $ CRChatItemId user (fmap aChatItemId . listToMaybe . aChatItems . chat $ chatResp) LastChatItemId Nothing index -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user (CPLast $ index + 1) Nothing @@ -2639,6 +2641,9 @@ processChatCommand' vr = \case delGroupChatItems :: User -> GroupInfo -> [CChatItem 'CTGroup] -> Maybe GroupMember -> CM ChatResponse delGroupChatItems user gInfo items byGroupMember = do deletedTs <- liftIO getCurrentTime + forM_ byGroupMember $ \byMember -> do + ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci byMember deletedTs) + unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) if groupFeatureAllowed SGFFullDelete gInfo then deleteGroupCIs user gInfo items True False byGroupMember deletedTs else markGroupCIsDeleted user gInfo items True byGroupMember deletedTs @@ -3573,7 +3578,7 @@ chatCommandP = <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) <*> (A.space *> jsonP <|> pure clqNoFilters) ), - "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), + "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> optional (contentFilterP <* A.space) <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), @@ -3948,6 +3953,7 @@ chatCommandP = ct -> ChatName ct <$> displayName chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName chatRefP = ChatRef <$> chatTypeP <*> A.decimal + contentFilterP = ContentFilter <$> ("content=" *> strP) <*> optional (" deleted=" *> onOffP) msgCountP = A.space *> A.decimal <|> pure 10 ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal) ciTTL = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 59a765b674..b47855a18f 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1840,7 +1840,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = moderate :: GroupMember -> CChatItem 'CTGroup -> CM () moderate mem cci = case sndMemberId_ of Just sndMemberId - | sameMemberId sndMemberId mem -> checkRole mem $ delete cci (Just m) >>= toView + | sameMemberId sndMemberId mem -> checkRole mem $ do + delete cci (Just m) >>= toView + archiveMessageReports cci m | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" _ -> messageError "x.msg.del: message of another member without memberId" checkRole GroupMember {memberRole} a @@ -1851,6 +1853,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = delete cci byGroupMember | groupFeatureAllowed SGFFullDelete gInfo = deleteGroupCIs user gInfo [cci] False False byGroupMember brokerTs | otherwise = markGroupCIsDeleted user gInfo [cci] False byGroupMember brokerTs + archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () + archiveMessageReports (CChatItem _ ci) byMember = do + ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs + unless (null ciIds) $ toView $ CRGroupChatItemsDeleted user gInfo ciIds False (Just byMember) -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> CM () diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index a477deeb2c..25bea24e74 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -91,14 +91,6 @@ chatInfoChatTs = \case GroupChat GroupInfo {chatTs} -> chatTs _ -> Nothing -chatInfoUpdatedAt :: ChatInfo c -> UTCTime -chatInfoUpdatedAt = \case - DirectChat Contact {updatedAt} -> updatedAt - GroupChat GroupInfo {updatedAt} -> updatedAt - LocalChat NoteFolder {updatedAt} -> updatedAt - ContactRequest UserContactRequest {updatedAt} -> updatedAt - ContactConnection PendingContactConnection {updatedAt} -> updatedAt - chatInfoToRef :: ChatInfo c -> ChatRef chatInfoToRef = \case DirectChat Contact {contactId} -> ChatRef CTDirect contactId @@ -318,12 +310,17 @@ data AChat = forall c. ChatTypeI c => AChat (SChatType c) (Chat c) deriving instance Show AChat data ChatStats = ChatStats - { unreadCount :: Int, + { unreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API + reportsCount :: Int, -- returned both in /_get chat initial API and in /_get chats API + archivedReportsCount :: Int, -- only returned in /_get chat initial API minUnreadItemId :: ChatItemId, unreadChat :: Bool } deriving (Show) +emptyChatStats :: ChatStats +emptyChatStats = ChatStats 0 0 0 0 False + data NavigationInfo = NavigationInfo { afterUnread :: Int, afterTotal :: Int diff --git a/src/Simplex/Chat/Migrations/M20241230_reports.hs b/src/Simplex/Chat/Migrations/M20241230_reports.hs index d5b3c183cf..7d605824f5 100644 --- a/src/Simplex/Chat/Migrations/M20241230_reports.hs +++ b/src/Simplex/Chat/Migrations/M20241230_reports.hs @@ -1,18 +1,18 @@ {-# LANGUAGE QuasiQuotes #-} - module Simplex.Chat.Migrations.M20241230_reports where +module Simplex.Chat.Migrations.M20241230_reports where - import Database.SQLite.Simple (Query) - import Database.SQLite.Simple.QQ (sql) +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) - m20241230_reports :: Query - m20241230_reports = - [sql| +m20241230_reports :: Query +m20241230_reports = + [sql| ALTER TABLE chat_items ADD COLUMN msg_content_tag TEXT; |] - down_m20241230_reports :: Query - down_m20241230_reports = - [sql| +down_m20241230_reports :: Query +down_m20241230_reports = + [sql| ALTER TABLE chat_items DROP COLUMN msg_content_tag; |] diff --git a/src/Simplex/Chat/Migrations/M20250105_indexes.hs b/src/Simplex/Chat/Migrations/M20250105_indexes.hs new file mode 100644 index 0000000000..dd01f21389 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20250105_indexes.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20250105_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250105_indexes :: Query +m20250105_indexes = + [sql| +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items(user_id, group_id, msg_content_tag, item_ts); +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_deleted_item_ts ON chat_items(user_id, group_id, msg_content_tag, item_deleted, item_ts); +|] + +down_m20250105_indexes :: Query +down_m20250105_indexes = + [sql| +DROP INDEX idx_chat_items_groups_msg_content_tag_item_ts; +DROP INDEX idx_chat_items_groups_msg_content_tag_item_deleted_item_ts; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index ee968383e0..870feba6b5 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -962,3 +962,16 @@ CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats( group_id, chat_tag_id ); +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items( + user_id, + group_id, + msg_content_tag, + item_ts +); +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_deleted_item_ts ON chat_items( + user_id, + group_id, + msg_content_tag, + item_deleted, + item_ts +); diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 89664d66f7..cda8cdf04c 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -70,7 +70,7 @@ import Simplex.Messaging.Version hiding (version) -- 9 - batch sending in direct connections (2024-07-24) -- 10 - business chats (2024-11-29) -- 11 - fix profile update in business chats (2024-12-05) --- 12 - fix profile update in business chats (2025-01-03) +-- 12 - support sending and receiving content reports (2025-01-03) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. @@ -443,7 +443,7 @@ instance FromJSON MREmojiChar where mrEmojiChar :: Char -> Either String MREmojiChar mrEmojiChar c - | c `elem` ("👍👎😀😢❤️🚀" :: String) = Right $ MREmojiChar c + | c `elem` ("👍👎😀😂😢❤️🚀✅" :: String) = Right $ MREmojiChar c | otherwise = Left "bad emoji" data FileChunk = FileChunk {chunkNo :: Integer, chunkBytes :: ByteString} | FileChunkCancel @@ -485,7 +485,7 @@ cmToQuotedMsg = \case _ -> Nothing data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCVideo_ | MCVoice_ | MCFile_ | MCReport_ | MCUnknown_ Text - deriving (Eq) + deriving (Eq, Show) instance StrEncoding MsgContentTag where strEncode = \case @@ -522,6 +522,7 @@ instance ToField MsgContentTag where toField = toField . strEncode data MsgContainer = MCSimple ExtMsgContent | MCQuote QuotedMsg ExtMsgContent + | MCComment MsgRef ExtMsgContent | MCForward ExtMsgContent deriving (Eq, Show) @@ -529,13 +530,9 @@ mcExtMsgContent :: MsgContainer -> ExtMsgContent mcExtMsgContent = \case MCSimple c -> c MCQuote _ c -> c + MCComment _ c -> c MCForward c -> c -isQuote :: MsgContainer -> Bool -isQuote = \case - MCQuote {} -> True - _ -> False - data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} @@ -564,9 +561,6 @@ msgContentText = \case msg = "report " <> safeDecodeUtf8 (strEncode reason) MCUnknown {text} -> text -toMCText :: MsgContent -> MsgContent -toMCText = MCText . msgContentText - durationText :: Int -> Text durationText duration = let (mins, secs) = duration `divMod` 60 in T.pack $ "(" <> with0 mins <> ":" <> with0 secs <> ")" @@ -657,7 +651,10 @@ markCompressedBatch = B.cons 'X' parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = MCQuote <$> v .: "quote" <*> mc + <|> MCComment <$> v .: "parent" <*> mc <|> (v .: "forward" >>= \f -> (if f then MCForward else MCSimple) <$> mc) + -- The support for arbitrary object in "forward" property is added to allow + -- forward compatibility with forwards that include public group links. <|> (MCForward <$> ((v .: "forward" :: JT.Parser J.Object) *> mc)) <|> MCSimple <$> mc where @@ -708,6 +705,7 @@ unknownMsgType = "unknown message type" msgContainerJSON :: MsgContainer -> J.Object msgContainerJSON = \case MCQuote qm mc -> o $ ("quote" .= qm) : msgContent mc + MCComment ref mc -> o $ ("parent" .= ref) : msgContent mc MCForward mc -> o $ ("forward" .= True) : msgContent mc MCSimple mc -> o $ msgContent mc where diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f729c8d073..c8d31c713a 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -58,6 +58,7 @@ module Simplex.Chat.Store.Messages markGroupChatItemDeleted, markGroupChatItemBlocked, markGroupCIBlockedByAdmin, + markMessageReportsDeleted, deleteLocalChatItem, updateDirectChatItemsRead, getDirectUnreadTimedItems, @@ -139,9 +140,9 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) +import Database.SQLite.Simple (FromRow, NamedParam (..), Only (..), Query, ToRow, (:.) (..)) import Database.SQLite.Simple.QQ (sql) -import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..)) +import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), ContentFilter (..), PaginationByTime (..)) import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent @@ -153,7 +154,8 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) @@ -548,16 +550,10 @@ data ChatPreviewData (c :: ChatType) where data AChatPreviewData = forall c. ChatTypeI c => ACPD (SChatType c) (ChatPreviewData c) -paginationByTimeFilter :: PaginationByTime -> (Query, [NamedParam]) -paginationByTimeFilter = \case - PTLast count -> ("\nORDER BY ts DESC LIMIT :count", [":count" := count]) - PTAfter ts count -> ("\nAND ts > :ts ORDER BY ts ASC LIMIT :count", [":ts" := ts, ":count" := count]) - PTBefore ts count -> ("\nAND ts < :ts ORDER BY ts DESC LIMIT :count", [":ts" := ts, ":count" := count]) - -type ChatStatsRow = (Int, ChatItemId, Bool) +type ChatStatsRow = (Int, Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats -toChatStats (unreadCount, minUnreadItemId, unreadChat) = ChatStats {unreadCount, minUnreadItemId, unreadChat} +toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, reportsCount, archivedReportsCount = 0, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = @@ -568,84 +564,76 @@ findDirectChatPreviews_ db User {userId} pagination clq = ACPD SCTDirect $ DirectChatPD ts contactId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT ct.contact_id, ct.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), ct.unread_chat + SELECT ct.contact_id, ct.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat FROM contacts ct LEFT JOIN ( SELECT contact_id, chat_item_id, MAX(created_at) FROM chat_items - WHERE user_id = :user_id AND contact_id IS NOT NULL + WHERE user_id = ? AND contact_id IS NOT NULL GROUP BY contact_id ) LastItems ON LastItems.contact_id = ct.contact_id LEFT JOIN ( SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = :user_id AND contact_id IS NOT NULL AND item_status = :rcv_new + WHERE user_id = ? AND contact_id IS NOT NULL AND item_status = ? GROUP BY contact_id ) ChatStats ON ChatStats.contact_id = ct.contact_id |] - (pagQuery, pagParams) = paginationByTimeFilter pagination + baseParams = (userId, userId, CISRcvNew) getPreviews = case clq of - CLQFilters {favorite = False, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + CLQFilters {favorite = False, unread = False} -> do + let q = baseQuery <> " WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used" + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = True, unread = False} -> do + let q = + baseQuery + <> [sql| + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + AND ct.favorite = 1 |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND ct.favorite = 1 + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = False, unread = True} -> do + let q = + baseQuery + <> [sql| + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = False, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = True, unread = True} -> do + let q = + baseQuery + <> [sql| + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + AND (ct.favorite = 1 + OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND (ct.favorite = 1 - OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQSearch {search} -> do + let q = + baseQuery + <> [sql| + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + AND ( + ct.local_display_name LIKE '%' || ? || '%' + OR cp.display_name LIKE '%' || ? || '%' + OR cp.full_name LIKE '%' || ? || '%' + OR cp.local_alias LIKE '%' || ? || '%' + ) |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQSearch {search} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - WHERE ct.user_id = :user_id AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used - AND ( - ct.local_display_name LIKE '%' || :search || '%' - OR cp.display_name LIKE '%' || :search || '%' - OR cp.full_name LIKE '%' || :search || '%' - OR cp.local_alias LIKE '%' || :search || '%' - ) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) + p = baseParams :. (userId, search, search, search, search) + queryWithPagination db q p pagination + +queryWithPagination :: ToRow p => DB.Connection -> Query -> p -> PaginationByTime -> IO [(ContactId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] +queryWithPagination db query params = \case + PTLast count -> DB.query db (query <> " ORDER BY ts DESC LIMIT ?") (params :. Only count) + PTAfter ts count -> DB.query db (query <> " AND ts > ? ORDER BY ts ASC LIMIT ?") (params :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND ts < ? ORDER BY ts DESC LIMIT ?") (params :. (ts, count)) getDirectChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do @@ -664,84 +652,77 @@ findGroupChatPreviews_ db User {userId} pagination clq = ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT g.group_id, g.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat + SELECT g.group_id, g.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g LEFT JOIN ( SELECT group_id, chat_item_id, MAX(item_ts) FROM chat_items - WHERE user_id = :user_id AND group_id IS NOT NULL + WHERE user_id = ? AND group_id IS NOT NULL GROUP BY group_id ) LastItems ON LastItems.group_id = g.group_id LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = :user_id AND group_id IS NOT NULL AND item_status = :rcv_new + WHERE user_id = ? AND group_id IS NOT NULL AND item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id + LEFT JOIN ( + SELECT group_id, COUNT(1) AS Count + FROM chat_items + WHERE user_id = ? AND group_id IS NOT NULL + AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + GROUP BY group_id + ) ReportCount ON ReportCount.group_id = g.group_id |] - (pagQuery, pagParams) = paginationByTimeFilter pagination + baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False) getPreviews = case clq of - CLQFilters {favorite = False, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE g.user_id = :user_id + CLQFilters {favorite = False, unread = False} -> do + let q = baseQuery <> " WHERE g.user_id = ?" + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = True, unread = False} -> do + let q = + baseQuery + <> [sql| + WHERE g.user_id = ? + AND g.favorite = 1 |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE g.user_id = :user_id - AND g.favorite = 1 + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = False, unread = True} -> do + let q = + baseQuery + <> [sql| + WHERE g.user_id = ? + AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = False, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE g.user_id = :user_id - AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = True, unread = True} -> do + let q = + baseQuery + <> [sql| + WHERE g.user_id = ? + AND (g.favorite = 1 + OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE g.user_id = :user_id - AND (g.favorite = 1 - OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQSearch {search} -> do + let q = + baseQuery + <> [sql| + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? + AND ( + g.local_display_name LIKE '%' || ? || '%' + OR gp.display_name LIKE '%' || ? || '%' + OR gp.full_name LIKE '%' || ? || '%' + OR gp.description LIKE '%' || ? || '%' + ) |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQSearch {search} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE g.user_id = :user_id - AND ( - g.local_display_name LIKE '%' || :search || '%' - OR gp.display_name LIKE '%' || :search || '%' - OR gp.full_name LIKE '%' || :search || '%' - OR gp.description LIKE '%' || :search || '%' - ) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew, ":search" := search] <> pagParams) + p = baseParams :. (userId, search, search, search, search) + queryWithPagination db q p pagination getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do @@ -760,67 +741,55 @@ findLocalChatPreviews_ db User {userId} pagination clq = ACPD SCTLocal $ LocalChatPD ts noteFolderId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT nf.note_folder_id, nf.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), nf.unread_chat + SELECT nf.note_folder_id, nf.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat FROM note_folders nf LEFT JOIN ( SELECT note_folder_id, chat_item_id, MAX(created_at) FROM chat_items - WHERE user_id = :user_id AND note_folder_id IS NOT NULL + WHERE user_id = ? AND note_folder_id IS NOT NULL GROUP BY note_folder_id ) LastItems ON LastItems.note_folder_id = nf.note_folder_id LEFT JOIN ( SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items - WHERE user_id = :user_id AND note_folder_id IS NOT NULL AND item_status = :rcv_new + WHERE user_id = ? AND note_folder_id IS NOT NULL AND item_status = ? GROUP BY note_folder_id ) ChatStats ON ChatStats.note_folder_id = nf.note_folder_id |] - (pagQuery, pagParams) = paginationByTimeFilter pagination + baseParams = (userId, userId, CISRcvNew) getPreviews = case clq of - CLQFilters {favorite = False, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE nf.user_id = :user_id + CLQFilters {favorite = False, unread = False} -> do + let q = baseQuery <> " WHERE nf.user_id = ?" + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = True, unread = False} -> do + let q = + baseQuery + <> [sql| + WHERE nf.user_id = ? + AND nf.favorite = 1 |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = False} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE nf.user_id = :user_id - AND nf.favorite = 1 + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = False, unread = True} -> do + let q = + baseQuery + <> [sql| + WHERE nf.user_id = ? + AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = False, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE nf.user_id = :user_id - AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) + p = baseParams :. Only userId + queryWithPagination db q p pagination + CLQFilters {favorite = True, unread = True} -> do + let q = + baseQuery + <> [sql| + WHERE nf.user_id = ? + AND (nf.favorite = 1 + OR nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) - CLQFilters {favorite = True, unread = True} -> - DB.queryNamed - db - ( baseQuery - <> [sql| - WHERE nf.user_id = :user_id - AND (nf.favorite = 1 - OR nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] - <> pagQuery - ) - ([":user_id" := userId, ":rcv_new" := CISRcvNew] <> pagParams) + p = baseParams :. Only userId + queryWithPagination db q p pagination CLQSearch {} -> pure [] getLocalChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTLocal -> ExceptT StoreError IO AChat @@ -872,82 +841,76 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex getContactRequestChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of - CLQFilters {favorite = False, unread = False} -> query "" + CLQFilters {favorite = False, unread = False} -> map toPreview <$> getPreviews "" CLQFilters {favorite = True, unread = False} -> pure [] - CLQFilters {favorite = False, unread = True} -> query "" - CLQFilters {favorite = True, unread = True} -> query "" - CLQSearch {search} -> query search + CLQFilters {favorite = False, unread = True} -> map toPreview <$> getPreviews "" + CLQFilters {favorite = True, unread = True} -> map toPreview <$> getPreviews "" + CLQSearch {search} -> map toPreview <$> getPreviews search where - (pagQuery, pagParams) = paginationByTimeFilter pagination - query search = - map toPreview - <$> DB.queryNamed - db - ( [sql| - SELECT - cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, - c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, - cr.created_at, cr.updated_at as ts, - cr.peer_chat_min_version, cr.peer_chat_max_version - FROM contact_requests cr - JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id - JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id - JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id - WHERE cr.user_id = :user_id - AND uc.user_id = :user_id - AND uc.local_display_name = '' - AND uc.group_id IS NULL - AND ( - cr.local_display_name LIKE '%' || :search || '%' - OR p.display_name LIKE '%' || :search || '%' - OR p.full_name LIKE '%' || :search || '%' - ) - |] - <> pagQuery + query = + [sql| + SELECT + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, + c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, + cr.created_at, cr.updated_at as ts, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id + JOIN contact_profiles p ON p.contact_profile_id = cr.contact_profile_id + JOIN user_contact_links uc ON uc.user_contact_link_id = cr.user_contact_link_id + WHERE cr.user_id = ? + AND uc.user_id = ? + AND uc.local_display_name = '' + AND uc.group_id IS NULL + AND ( + cr.local_display_name LIKE '%' || ? || '%' + OR p.display_name LIKE '%' || ? || '%' + OR p.full_name LIKE '%' || ? || '%' ) - ([":user_id" := userId, ":search" := search] <> pagParams) + |] + params search = (userId, userId, search, search, search) + getPreviews search = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY ts DESC LIMIT ?") (params search :. Only count) + PTAfter ts count -> DB.query db (query <> " AND ts > ? ORDER BY ts ASC LIMIT ?") (params search :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND ts < ? ORDER BY ts DESC LIMIT ?") (params search :. (ts, count)) toPreview :: ContactRequestRow -> AChatPreviewData toPreview cReqRow = let cReq@UserContactRequest {updatedAt} = toContactRequest cReqRow - stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] stats + aChat = AChat SCTContactRequest $ Chat (ContactRequest cReq) [] emptyChatStats in ACPD SCTContactRequest $ ContactRequestPD updatedAt aChat getContactConnectionChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of - CLQFilters {favorite = False, unread = False} -> query "" + CLQFilters {favorite = False, unread = False} -> map toPreview <$> getPreviews "" CLQFilters {favorite = True, unread = False} -> pure [] CLQFilters {favorite = False, unread = True} -> pure [] CLQFilters {favorite = True, unread = True} -> pure [] - CLQSearch {search} -> query search + CLQSearch {search} -> map toPreview <$> getPreviews search where - (pagQuery, pagParams) = paginationByTimeFilter pagination - query search = - map toPreview - <$> DB.queryNamed - db - ( [sql| - SELECT - connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at as ts - FROM connections - WHERE user_id = :user_id - AND conn_type = :conn_contact - AND conn_status != :conn_status - AND contact_id IS NULL - AND conn_level = 0 - AND via_contact IS NULL - AND (via_group_link = 0 || (via_group_link = 1 AND group_link_id IS NOT NULL)) - AND local_alias LIKE '%' || :search || '%' - |] - <> pagQuery - ) - ([":user_id" := userId, ":conn_contact" := ConnContact, ":conn_status" := ConnPrepared, ":search" := search] <> pagParams) + query = + [sql| + SELECT + connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, + custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at as ts + FROM connections + WHERE user_id = ? + AND conn_type = ? + AND conn_status != ? + AND contact_id IS NULL + AND conn_level = 0 + AND via_contact IS NULL + AND (via_group_link = 0 || (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND local_alias LIKE '%' || ? || '%' + |] + params search = (userId, ConnContact, ConnPrepared, search) + getPreviews search = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY ts DESC LIMIT ?") (params search :. Only count) + PTAfter ts count -> DB.query db (query <> " AND ts > ? ORDER BY ts ASC LIMIT ?") (params search :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND ts < ? ORDER BY ts DESC LIMIT ?") (params search :. (ts, count)) toPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> AChatPreviewData toPreview connRow = let conn@PendingContactConnection {updatedAt} = toPendingContactConnection connRow - stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] stats + aChat = AChat SCTContactConnection $ Chat (ContactConnection conn) [] emptyChatStats in ACPD SCTContactConnection $ ContactConnectionPD updatedAt aChat getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) @@ -966,11 +929,10 @@ getDirectChat db vr user contactId pagination search_ = do -- the last items in reverse order (the last item in the conversation is the first in the returned list) getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) getDirectChatLast_ db user ct count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} ciIds <- getDirectChatItemIdsLast_ db user ct count search ts <- getCurrentTime cis <- mapM (safeGetDirectItem db user ct ts) ciIds - pure $ Chat (DirectChat ct) (reverse cis) stats + pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId] getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search = @@ -1030,12 +992,11 @@ getDirectChatItemLast db user@User {userId} contactId = do getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterCI <- getDirectChatItem db user contactId afterId ciIds <- liftIO $ getDirectCIsAfter_ db user ct afterCI count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds - pure $ Chat (DirectChat ct) cis stats + pure $ Chat (DirectChat ct) cis emptyChatStats getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = @@ -1054,12 +1015,11 @@ getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeCI <- getDirectChatItem db user contactId beforeId ciIds <- liftIO $ getDirectCIsBefore_ db user ct beforeCI count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds - pure $ Chat (DirectChat ct) (reverse cis) stats + pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = @@ -1102,7 +1062,7 @@ getDirectChatInitial_ db user ct count = do liftIO (getContactMinUnreadId_ db user ct) >>= \case Just minUnreadItemId -> do unreadCount <- liftIO $ getContactUnreadCount_ db user ct - let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + let stats = emptyChatStats {unreadCount, minUnreadItemId} getDirectChatAround' db user ct minUnreadItemId count "" stats Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct count "" @@ -1110,7 +1070,7 @@ getContactStats_ :: DB.Connection -> User -> Contact -> IO ChatStats getContactStats_ db user ct = do minUnreadItemId <- fromMaybe 0 <$> getContactMinUnreadId_ db user ct unreadCount <- getContactUnreadCount_ db user ct - pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + pure emptyChatStats {unreadCount, minUnreadItemId} getContactMinUnreadId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId) getContactMinUnreadId_ db User {userId} Contact {contactId} = @@ -1147,87 +1107,103 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do getAfterUnreadCount :: IO Int getAfterUnreadCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND contact_id = :contact_id AND item_status = :rcv_new - AND created_at > :created_at + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND contact_id = :contact_id AND item_status = :rcv_new - AND created_at = :created_at AND chat_item_id > :item_id + WHERE user_id = ? AND contact_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? ) |] - [ ":user_id" := userId, - ":contact_id" := contactId, - ":rcv_new" := CISRcvNew, - ":created_at" := ciCreatedAt afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, contactId, CISRcvNew, ciCreatedAt afterCI) + :. (userId, contactId, CISRcvNew, ciCreatedAt afterCI, cChatItemId afterCI) + ) getAfterTotalCount :: IO Int getAfterTotalCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND contact_id = :contact_id - AND created_at > :created_at + WHERE user_id = ? AND contact_id = ? + AND created_at > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND contact_id = :contact_id - AND created_at = :created_at AND chat_item_id > :item_id + WHERE user_id = ? AND contact_id = ? + AND created_at = ? AND chat_item_id > ? ) |] - [ ":user_id" := userId, - ":contact_id" := contactId, - ":created_at" := ciCreatedAt afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, contactId, ciCreatedAt afterCI) + :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) + ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChat db vr user groupId pagination search_ = do +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe ContentFilter -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat db vr user groupId contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId case pagination of - CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g count search - CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g afterId count search - CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g beforeId count search - CPAround aroundId count -> getGroupChatAround_ db user g aroundId count search + CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g contentFilter count search + CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g contentFilter afterId count search + CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g contentFilter beforeId count search + CPAround aroundId count -> getGroupChatAround_ db user g contentFilter aroundId count search CPInitial count -> do unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" - getGroupChatInitial_ db user g count + getGroupChatInitial_ db user g contentFilter count -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO (Chat 'CTGroup) -getGroupChatLast_ db user g count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} - ciIds <- getGroupChatItemIdsLast_ db user g count search +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> Int -> String -> IO (Chat 'CTGroup) +getGroupChatLast_ db user g contentFilter count search = do + ciIds <- getGroupChatItemIDs db user g contentFilter GRLast count search ts <- getCurrentTime cis <- mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) (reverse cis) stats + pure $ Chat (GroupChat g) (reverse cis) emptyChatStats -getGroupChatItemIdsLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> IO [ChatItemId] -getGroupChatItemIdsLast_ db User {userId} GroupInfo {groupId} count search = - map fromOnly - <$> DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' - ORDER BY item_ts DESC, chat_item_id DESC - LIMIT ? - |] - (userId, groupId, search, count) +data GroupItemIDsRange = GRLast | GRAfter UTCTime ChatItemId | GRBefore UTCTime ChatItemId + +getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> GroupItemIDsRange -> Int -> String -> IO [ChatItemId] +getGroupChatItemIDs db User {userId} GroupInfo {groupId} contentFilter range count search = case contentFilter of + Just ContentFilter {mcTag, deleted} -> case deleted of + Just deleted' -> idsQuery (baseCond <> " AND msg_content_tag = ? AND item_deleted = ? ") (userId, groupId, mcTag, BI deleted') + Nothing -> idsQuery (baseCond <> " AND msg_content_tag = ? ") (userId, groupId, mcTag) + Nothing -> idsQuery baseCond (userId, groupId) + where + baseQuery = " SELECT chat_item_id FROM chat_items WHERE " + baseCond = " user_id = ? AND group_id = ? " + idsQuery :: ToRow p => Query -> p -> IO [ChatItemId] + idsQuery c p = case range of + GRLast -> rangeQuery c p " ORDER BY item_ts DESC, chat_item_id DESC " + GRAfter ts itemId -> + rangeQuery + (" item_ts > ? " `orCond` " item_ts = ? AND chat_item_id > ? ") + (orParams ts itemId) + " ORDER BY item_ts ASC, chat_item_id ASC " + GRBefore ts itemId -> + rangeQuery + (" item_ts < ? " `orCond` " item_ts = ? AND chat_item_id < ? ") + (orParams ts itemId) + " ORDER BY item_ts DESC, chat_item_id DESC " + where + orCond c1 c2 = " (" <> c <> " AND " <> c1 <> ") OR (" <> c <> " AND " <> c2 <> ") " + orParams ts itemId = (p :. (Only ts) :. p :. (ts, itemId)) + rangeQuery :: ToRow p => Query -> p -> Query -> IO [ChatItemId] + rangeQuery c p ob + | null search = searchQuery "" () + | otherwise = searchQuery " AND item_text LIKE '%' || ? || '%' " (Only search) + where + searchQuery :: ToRow p' => Query -> p' -> IO [ChatItemId] + searchQuery c' p' = + map fromOnly <$> DB.query db (baseQuery <> c <> c' <> ob <> " LIMIT ?") (p :. p' :. Only count) safeGetGroupItem :: DB.Connection -> User -> GroupInfo -> UTCTime -> ChatItemId -> IO (CChatItem 'CTGroup) safeGetGroupItem db user g currentTs itemId = @@ -1271,64 +1247,36 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatAfter_ db user g@GroupInfo {groupId} afterId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ db user g@GroupInfo {groupId} contentFilter afterId count search = do afterCI <- getGroupChatItem db user groupId afterId - ciIds <- liftIO $ getGroupCIsAfter_ db user g afterCI count search + let range = GRAfter (chatItemTs afterCI) (cChatItemId afterCI) + ciIds <- liftIO $ getGroupChatItemIDs db user g contentFilter range count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) cis stats + pure $ Chat (GroupChat g) cis emptyChatStats -getGroupCIsAfter_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> Int -> String -> IO [ChatItemId] -getGroupCIsAfter_ db User {userId} GroupInfo {groupId} afterCI count search = - map fromOnly - <$> DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' - AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) - ORDER BY item_ts ASC, chat_item_id ASC - LIMIT ? - |] - (userId, groupId, search, chatItemTs afterCI, chatItemTs afterCI, cChatItemId afterCI, count) - -getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatBefore_ db user g@GroupInfo {groupId} beforeId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ db user g@GroupInfo {groupId} contentFilter beforeId count search = do beforeCI <- getGroupChatItem db user groupId beforeId - ciIds <- liftIO $ getGroupCIsBefore_ db user g beforeCI count search + let range = GRBefore (chatItemTs beforeCI) (cChatItemId beforeCI) + ciIds <- liftIO $ getGroupChatItemIDs db user g contentFilter range count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) (reverse cis) stats + pure $ Chat (GroupChat g) (reverse cis) emptyChatStats -getGroupCIsBefore_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> Int -> String -> IO [ChatItemId] -getGroupCIsBefore_ db User {userId} GroupInfo {groupId} beforeCI count search = - map fromOnly - <$> DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_text LIKE '%' || ? || '%' - AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) - ORDER BY item_ts DESC, chat_item_id DESC - LIMIT ? - |] - (userId, groupId, search, chatItemTs beforeCI, chatItemTs beforeCI, cChatItemId beforeCI, count) - -getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround_ db user g aroundId count search = do +getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround_ db user g contentFilter aroundId count search = do stats <- liftIO $ getGroupStats_ db user g - getGroupChatAround' db user g aroundId count search stats + getGroupChatAround' db user g contentFilter aroundId count search stats -getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatAround' db user g@GroupInfo {groupId} aroundId count search stats = do +getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround' db user g@GroupInfo {groupId} contentFilter aroundId count search stats = do aroundCI <- getGroupChatItem db user groupId aroundId - beforeIds <- liftIO $ getGroupCIsBefore_ db user g aroundCI count search - afterIds <- liftIO $ getGroupCIsAfter_ db user g aroundCI count search + let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) + afterRange = GRAfter (chatItemTs aroundCI) (cChatItemId aroundCI) + beforeIds <- liftIO $ getGroupChatItemIDs db user g contentFilter beforeRange count search + afterIds <- liftIO $ getGroupChatItemIDs db user g contentFilter afterRange count search ts <- liftIO getCurrentTime beforeCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) beforeIds afterCIs <- liftIO $ mapM (safeGetGroupItem db user g ts) afterIds @@ -1340,46 +1288,66 @@ getGroupChatAround' db user g@GroupInfo {groupId} aroundId count search stats = [] -> pure $ NavigationInfo 0 0 cis -> getGroupNavInfo_ db user g (last cis) -getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatInitial_ db user g count = - liftIO (getGroupMinUnreadId_ db user g) >>= \case +getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatInitial_ db user g contentFilter count = + liftIO (getGroupMinUnreadId_ db user g contentFilter) >>= \case Just minUnreadItemId -> do - unreadCount <- liftIO $ getGroupUnreadCount_ db user g - let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False} - getGroupChatAround' db user g minUnreadItemId count "" stats - Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g count "" + unreadCount <- liftIO $ getGroupUnreadCount_ db user g Nothing + reportsCount <- liftIO $ getGroupReportsCount_ db user g False + archivedReportsCount <- liftIO $ getGroupReportsCount_ db user g True + let stats = ChatStats {unreadCount, reportsCount, archivedReportsCount, minUnreadItemId, unreadChat = False} + getGroupChatAround' db user g contentFilter minUnreadItemId count "" stats + Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g contentFilter count "" getGroupStats_ :: DB.Connection -> User -> GroupInfo -> IO ChatStats getGroupStats_ db user g = do - minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g - unreadCount <- getGroupUnreadCount_ db user g - pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g Nothing + unreadCount <- getGroupUnreadCount_ db user g Nothing + reportsCount <- getGroupReportsCount_ db user g False + archivedReportsCount <- getGroupReportsCount_ db user g True + pure ChatStats {unreadCount, reportsCount, archivedReportsCount, minUnreadItemId, unreadChat = False} -getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> IO (Maybe ChatItemId) -getGroupMinUnreadId_ db User {userId} GroupInfo {groupId} = +getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> IO (Maybe ChatItemId) +getGroupMinUnreadId_ db user g contentFilter = fmap join . maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? - ORDER BY item_ts ASC, chat_item_id ASC - LIMIT 1 - |] - (userId, groupId, CISRcvNew) + queryUnreadGroupItems db user g contentFilter baseQuery orderLimit + where + baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " + orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" -getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> IO Int -getGroupUnreadCount_ db User {userId} GroupInfo {groupId} = +getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> IO Int +getGroupUnreadCount_ db user g contentFilter = + fromOnly . head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" + where + baseQuery = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? " + +getGroupReportsCount_ :: DB.Connection -> User -> GroupInfo -> Bool -> IO Int +getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = fromOnly . head <$> DB.query db - [sql| - SELECT COUNT(1) - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? - |] - (userId, groupId, CISRcvNew) + "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" + (userId, groupId, MCReport_, BI archived) + +queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> Query -> Query -> IO [r] +queryUnreadGroupItems db User {userId} GroupInfo {groupId} contentFilter baseQuery orderLimit = + case contentFilter of + Just ContentFilter {mcTag, deleted} -> case deleted of + Just deleted' -> + DB.query + db + (baseQuery <> " AND msg_content_tag = ? AND item_deleted = ? AND item_status = ? " <> orderLimit) + (userId, groupId, mcTag, BI deleted', CISRcvNew) + Nothing -> + DB.query + db + (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) + (userId, groupId, mcTag, CISRcvNew) + Nothing -> + DB.query + db + (baseQuery <> " AND item_status = ? " <> orderLimit) + (userId, groupId, CISRcvNew) getGroupNavInfo_ :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO NavigationInfo getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do @@ -1390,52 +1358,47 @@ getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do getAfterUnreadCount :: IO Int getAfterUnreadCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND group_id = :group_id AND item_status = :rcv_new - AND item_ts > :item_ts + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND group_id = :group_id AND item_status = :rcv_new - AND item_ts = :item_ts AND chat_item_id > :item_id + WHERE user_id = ? AND group_id = ? AND item_status = ? + AND item_ts = ? AND chat_item_id > ? ) |] - [ ":user_id" := userId, - ":group_id" := groupId, - ":rcv_new" := CISRcvNew, - ":item_ts" := chatItemTs afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, groupId, CISRcvNew, chatItemTs afterCI) + :. (userId, groupId, CISRcvNew, chatItemTs afterCI, cChatItemId afterCI) + ) getAfterTotalCount :: IO Int getAfterTotalCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND group_id = :group_id - AND item_ts > :item_ts + WHERE user_id = ? AND group_id = ? + AND item_ts > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND group_id = :group_id - AND item_ts = :item_ts AND chat_item_id > :item_id + WHERE user_id = ? AND group_id = ? + AND item_ts = ? AND chat_item_id > ? ) |] - [ ":user_id" := userId, - ":group_id" := groupId, - ":item_ts" := chatItemTs afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, groupId, chatItemTs afterCI) + :. (userId, groupId, chatItemTs afterCI, cChatItemId afterCI) + ) getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChat db user folderId pagination search_ = do @@ -1452,11 +1415,10 @@ getLocalChat db user folderId pagination search_ = do getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) getLocalChatLast_ db user nf count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} ciIds <- getLocalChatItemIdsLast_ db user nf count search ts <- getCurrentTime cis <- mapM (safeGetLocalItem db user nf ts) ciIds - pure $ Chat (LocalChat nf) (reverse cis) stats + pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId] getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search = @@ -1500,12 +1462,11 @@ safeToLocalItem currentTs itemId = \case getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterCI <- getLocalChatItem db user noteFolderId afterId ciIds <- liftIO $ getLocalCIsAfter_ db user nf afterCI count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds - pure $ Chat (LocalChat nf) cis stats + pure $ Chat (LocalChat nf) cis emptyChatStats getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count search = @@ -1524,12 +1485,11 @@ getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count searc getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = do - let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeCI <- getLocalChatItem db user noteFolderId beforeId ciIds <- liftIO $ getLocalCIsBefore_ db user nf beforeCI count search ts <- liftIO getCurrentTime cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds - pure $ Chat (LocalChat nf) (reverse cis) stats + pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count search = @@ -1572,7 +1532,7 @@ getLocalChatInitial_ db user nf count = do liftIO (getLocalMinUnreadId_ db user nf) >>= \case Just minUnreadItemId -> do unreadCount <- liftIO $ getLocalUnreadCount_ db user nf - let stats = ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + let stats = emptyChatStats {unreadCount, minUnreadItemId} getLocalChatAround' db user nf minUnreadItemId count "" stats Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getLocalChatLast_ db user nf count "" @@ -1580,7 +1540,7 @@ getLocalStats_ :: DB.Connection -> User -> NoteFolder -> IO ChatStats getLocalStats_ db user nf = do minUnreadItemId <- fromMaybe 0 <$> getLocalMinUnreadId_ db user nf unreadCount <- getLocalUnreadCount_ db user nf - pure ChatStats {unreadCount, minUnreadItemId, unreadChat = False} + pure emptyChatStats {unreadCount, minUnreadItemId} getLocalMinUnreadId_ :: DB.Connection -> User -> NoteFolder -> IO (Maybe ChatItemId) getLocalMinUnreadId_ db User {userId} NoteFolder {noteFolderId} = @@ -1617,52 +1577,47 @@ getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do getAfterUnreadCount :: IO Int getAfterUnreadCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND note_folder_id = :note_folder_id AND item_status = :rcv_new - AND created_at > :created_at + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND note_folder_id = :note_folder_id AND item_status = :rcv_new - AND created_at = :created_at AND chat_item_id > :item_id + WHERE user_id = ? AND note_folder_id = ? AND item_status = ? + AND created_at = ? AND chat_item_id > ? ) |] - [ ":user_id" := userId, - ":note_folder_id" := noteFolderId, - ":rcv_new" := CISRcvNew, - ":created_at" := ciCreatedAt afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, noteFolderId, CISRcvNew, ciCreatedAt afterCI) + :. (userId, noteFolderId, CISRcvNew, ciCreatedAt afterCI, cChatItemId afterCI) + ) getAfterTotalCount :: IO Int getAfterTotalCount = fromOnly . head - <$> DB.queryNamed + <$> DB.query db [sql| SELECT COUNT(1) FROM ( SELECT 1 FROM chat_items - WHERE user_id = :user_id AND note_folder_id = :note_folder_id - AND created_at > :created_at + WHERE user_id = ? AND note_folder_id = ? + AND created_at > ? UNION ALL SELECT 1 FROM chat_items - WHERE user_id = :user_id AND note_folder_id = :note_folder_id - AND created_at = :created_at AND chat_item_id > :item_id + WHERE user_id = ? AND note_folder_id = ? + AND created_at = ? AND chat_item_id > ? ) |] - [ ":user_id" := userId, - ":note_folder_id" := noteFolderId, - ":created_at" := ciCreatedAt afterCI, - ":item_id" := cChatItemId afterCI - ] + ( (userId, noteFolderId, ciCreatedAt afterCI) + :. (userId, noteFolderId, ciCreatedAt afterCI, cChatItemId afterCI) + ) toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) toChatItemRef = \case @@ -2114,7 +2069,7 @@ createChatItemVersion db itemId itemVersionTs msgContent = INSERT INTO chat_item_versions (chat_item_id, msg_content, item_version_ts) VALUES (?,?,?) |] - (itemId, toMCText msgContent, itemVersionTs) + (itemId, MCText $ msgContentText msgContent, itemVersionTs) deleteDirectChatItem :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect d -> IO () deleteDirectChatItem db User {userId} Contact {contactId} ci = do @@ -2388,6 +2343,20 @@ markGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci@ChatItem {meta (DBCIBlockedByAdmin, deletedTs, deletedTs, userId, groupId, chatItemId' ci) pure ci {meta = meta {itemDeleted = Just $ CIBlockedByAdmin $ Just deletedTs, editable = False, deletable = False}} +markMessageReportsDeleted :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> GroupMember -> UTCTime -> IO [ChatItemId] +markMessageReportsDeleted db User {userId} GroupInfo {groupId} ChatItem {meta = CIMeta {itemSharedMsgId}} GroupMember {groupMemberId} deletedTs = do + currentTs <- liftIO getCurrentTime + map fromOnly + <$> DB.query + db + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND quoted_shared_msg_id = ? + RETURNING chat_item_id; + |] + (DBCIDeleted, deletedTs, groupMemberId, currentTs, userId, groupId, MCReport_, itemSharedMsgId) + getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do itemId <- diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index be4c129d07..a6cb562aa1 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -122,6 +122,7 @@ import Simplex.Chat.Migrations.M20241205_business_chat_members import Simplex.Chat.Migrations.M20241222_operator_conditions import Simplex.Chat.Migrations.M20241223_chat_tags import Simplex.Chat.Migrations.M20241230_reports +import Simplex.Chat.Migrations.M20250105_indexes import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -243,7 +244,8 @@ schemaMigrations = ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members), ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), - ("20241230_reports", m20241230_reports, Just down_m20241230_reports) + ("20241230_reports", m20241230_reports, Just down_m20241230_reports), + ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 04a3be148c..d363c95461 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -156,6 +156,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] -> ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView deletions' -> ttyUser u [sShow (length deletions') <> " messages deleted"] + CRGroupChatItemsDeleted u g ciIds byUser member_ -> ttyUser u [ttyGroup' g <> ": " <> sShow (length ciIds) <> " messages deleted by " <> if byUser then "user" else "member" <> maybe "" (\m -> " " <> ttyMember m) member_] CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRReactionMembers u memberReactions -> ttyUser u $ viewReactionMembers memberReactions CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 7f1a9d3e56..f14a041f67 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -6600,3 +6600,34 @@ testGroupMemberReports = bob <## " report content", (cath ("/_get chat #1 content=report count=100", chat, [(0, "report content")]) + alice #$> ("/_get chat #1 content=report deleted=off count=100", chat, [(0, "report content")]) + alice #$> ("/_get chat #1 content=report deleted=on count=100", chat, []) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content")]) + bob #$> ("/_get chat #1 content=report deleted=off count=100", chat, [(0, "report content")]) + bob #$> ("/_get chat #1 content=report deleted=on count=100", chat, []) + dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content")]) + dan #$> ("/_get chat #1 content=report deleted=off count=100", chat, [(1, "report content")]) + dan #$> ("/_get chat #1 content=report deleted=on count=100", chat, []) + alice ##> "\\\\ #jokes cath inappropriate joke" + concurrentlyN_ + [ do + alice <## "#jokes: 1 messages deleted by member alice" + alice <## "message marked deleted by you", + do + bob <# "#jokes cath> [marked deleted by alice] inappropriate joke" + bob <## "#jokes: 1 messages deleted by member alice", + cath <# "#jokes cath> [marked deleted by alice] inappropriate joke", + do + dan <# "#jokes cath> [marked deleted by alice] inappropriate joke" + dan <## "#jokes: 1 messages deleted by member alice" + ] + alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")]) + alice #$> ("/_get chat #1 content=report deleted=off count=100", chat, []) + alice #$> ("/_get chat #1 content=report deleted=on count=100", chat, [(0, "report content [marked deleted by you]")]) + bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by alice]")]) + bob #$> ("/_get chat #1 content=report deleted=off count=100", chat, []) + bob #$> ("/_get chat #1 content=report deleted=on count=100", chat, [(0, "report content [marked deleted by alice]")]) + dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content [marked deleted by alice]")]) + dan #$> ("/_get chat #1 content=report deleted=off count=100", chat, []) + dan #$> ("/_get chat #1 content=report deleted=on count=100", chat, [(1, "report content [marked deleted by alice]")]) From 7e344b3ee88ee84a2f85bddb288f5ea825e2ad7c Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 8 Jan 2025 18:28:45 +0000 Subject: [PATCH 47/95] ios: reports inline (#5466) * initial types * changes types * decode * possible mock for inline report * remove avatar * diff * updates * parser and display message * send messages and support placeholder * profile reports and all reports working * new api * check member support for receiving reports * report chat item text * moderator role * placeholder on text compose for report * rename method * remove need to have reported item in memory to action * archived reports * changes/fix * fix block member * delete and moderate * archive * report reason * context menu/moderation fixes * typo * not needed * report reason as caption, and change text * remove auto archive * move placeholder to match text * prefix red italic report * archive * apply mark deleted fix * Revert "apply mark deleted fix" This reverts commit b12f14c0f54af84d53a7d6c61e1fd316c950e085. * remove extra space * context menu rework * strings, icons * recheck items extra check on reports * simplify * simpler * reports: never show for own messages, disable attachments, hide when recording or live * style, allow local deletion --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 18 +- .../Views/Chat/ChatItem/FramedItemView.swift | 23 +- .../Chat/ChatItem/MarkedDeletedItemView.swift | 14 +- .../Views/Chat/ChatItem/MsgContentView.swift | 10 +- apps/ios/Shared/Views/Chat/ChatView.swift | 260 ++++++++++++++++-- .../Chat/ComposeMessage/ComposeView.swift | 93 ++++++- .../Chat/ComposeMessage/ContextItemView.swift | 5 +- .../ComposeMessage/NativeTextEditor.swift | 30 ++ .../Chat/ComposeMessage/SendMessageView.swift | 3 + .../Chat/Group/AddGroupMembersView.swift | 6 +- .../Chat/Group/GroupMemberInfoView.swift | 34 ++- .../Chat/SelectableChatItemToolbars.swift | 8 +- .../Views/ChatList/ChatPreviewView.swift | 23 +- apps/ios/SimpleXChat/APITypes.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 172 ++++++++++-- .../chat/simplex/common/model/ChatModel.kt | 6 +- .../chat/simplex/common/model/SimpleXAPI.kt | 5 +- 17 files changed, 617 insertions(+), 99 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 51be3191ec..ae9f0e3e19 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,12 +15,6 @@ import SimpleXChat private var chatController: chat_ctrl? -// currentChatVersion in core -public let CURRENT_CHAT_VERSION: Int = 2 - -// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION) - private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock") enum TerminalItem: Identifiable { @@ -418,6 +412,18 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage] return nil } +func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? { + let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText)) + if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } } + + logger.error("apiReportMessage error: \(String(describing: r))") + AlertManager.shared.showAlertMsg( + title: "Error creating report", + message: "Error: \(responseError(r))" + ) + return nil +} + private func sendMessageErrorAlert(_ r: ChatResponse) { logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 9b71e6c4a4..6da893d1d2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -30,7 +30,17 @@ struct FramedItemView: View { var body: some View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { - if let di = chatItem.meta.itemDeleted { + if chatItem.isReport { + if chatItem.meta.itemDeleted == nil { + let txt = chatItem.chatDir.sent ? + Text("Only you and moderators see it") : + Text("Only sender and moderators see it") + + framedItemHeader(icon: "flag", iconColor: .red, caption: txt.italic()) + } else { + framedItemHeader(icon: "flag", caption: Text("archived report").italic()) + } + } else if let di = chatItem.meta.itemDeleted { switch di { case let .moderated(_, byGroupMember): framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) @@ -144,6 +154,8 @@ struct FramedItemView: View { } case let .file(text): ciFileView(chatItem, text) + case let .report(text, reason): + ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red)) case let .link(_, preview): CILinkView(linkPreview: preview) ciMsgContentView(chatItem) @@ -159,13 +171,14 @@ struct FramedItemView: View { } } - @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View { + @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { let v = HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) + .foregroundColor(iconColor ?? theme.colors.secondary) } caption .font(.caption) @@ -228,7 +241,6 @@ struct FramedItemView: View { .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .frame(minWidth: msgWidth, alignment: .leading) .background(chatItemFrameContextColor(chatItem, theme)) - if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { @@ -281,7 +293,7 @@ struct FramedItemView: View { } } - @ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View { + @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let ft = text == "" ? [] : ci.formattedText @@ -291,7 +303,8 @@ struct FramedItemView: View { formattedText: ft, meta: ci.meta, rightToLeft: rtl, - showSecrets: showSecrets + showSecrets: showSecrets, + prefix: txtPrefix )) .multilineTextAlignment(rtl ? .trailing : .leading) .padding(.vertical, 6) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c2b4021edc..87a9b2ce61 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -67,11 +67,15 @@ 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)" - case .blocked: "blocked" - case .blockedByAdmin: "blocked by admin" - case .deleted, nil: "marked deleted" + if chatItem.meta.itemDeleted != nil, chatItem.isReport { + "archived report" + } else { + switch chatItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" + case .blocked: "blocked" + case .blockedByAdmin: "blocked by admin" + case .deleted, nil: "marked deleted" + } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 914f7c8a2f..e9b6d0ba84 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -34,6 +34,7 @@ struct MsgContentView: View { var meta: CIMeta? = nil var rightToLeft = false var showSecrets: Bool + var prefix: Text? = nil @State private var typingIdx = 0 @State private var timer: Timer? @@ -67,7 +68,7 @@ struct MsgContentView: View { } private func msgContentView() -> Text { - var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary) + var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) if let mt = meta { if mt.isLive { v = v + typingIndicator(mt.recent) @@ -89,9 +90,10 @@ struct MsgContentView: View { } } -func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text { +func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { let s = text var res: Text + if let ft = formattedText, ft.count > 0 && ft.count <= 200 { res = formatText(ft[0], preview, showSecret: showSecrets) var i = 1 @@ -106,6 +108,10 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St if let i = icon { res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res } + + if let p = prefix { + res = p + res + } if let s = sender { let t = Text(s) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 90c277ce76..285bdf2d98 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -917,6 +917,7 @@ struct ChatView: View { @State private var allowMenu: Bool = true @State private var markedRead = false + @State private var actionSheet: SomeActionSheet? = nil var revealed: Bool { chatItem == revealedChatItem } @@ -1001,6 +1002,7 @@ struct ChatView: View { } } } + .actionSheet(item: $actionSheet) { $0.actionSheet } } private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { @@ -1208,7 +1210,7 @@ struct ChatView: View { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal, moderate: false) } - if let di = deletingItem, di.meta.deletable && !di.localNote { + if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport { Button(broadcastDeleteButtonText(chat), role: .destructive) { deleteMessage(.cidmBroadcast, moderate: false) } @@ -1282,7 +1284,21 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { + if let groupInfo = chat.chatInfo.groupInfo, ci.isReport, ci.meta.itemDeleted == nil { + if ci.chatDir == .groupSnd { + deleteButton(ci) + } else { + archiveReportButton(ci) + if let qi = ci.quotedItem { + moderateReportedButton(qi, ci, groupInfo) + if let rMember = qi.memberToModerate(chat.chatInfo) { + if !rMember.blockedByAdmin, rMember.canBlockForAll(groupInfo: groupInfo) { + blockMemberButton(rMember, groupInfo, qi, ci) + } + } + } + } + } else if let mc = ci.content.msgContent, !ci.isReport, ci.meta.itemDeleted == nil || revealed { if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, availableReactions.count > 0 { reactionsGroup @@ -1332,8 +1348,12 @@ struct ChatView: View { if !live || !ci.meta.isLive { deleteButton(ci) } - if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd { - moderateButton(ci, groupInfo) + if ci.chatDir != .groupSnd { + if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { + moderateButton(ci, groupInfo) + } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole < .moderator, !live, composeState.voiceMessageRecordingState == .noRecording { + reportButton(ci) + } } } else if ci.meta.itemDeleted != nil { if revealed { @@ -1648,19 +1668,10 @@ struct ChatView: View { private func moderateButton(_ ci: ChatItem, _ groupInfo: GroupInfo) -> Button { Button(role: .destructive) { - AlertManager.shared.showAlert(Alert( - title: Text("Delete member message?"), - message: Text( - groupInfo.fullGroupPreferences.fullDelete.on - ? "The message will be deleted for all members." - : "The message will be marked as moderated for all members." - ), - primaryButton: .destructive(Text("Delete")) { - deletingItem = ci - deleteMessage(.cidmBroadcast, moderate: true) - }, - secondaryButton: .cancel() - )) + showModerateMessageAlert(groupInfo) { + deletingItem = ci + deleteMessage(.cidmBroadcast, moderate: true) + } } label: { Label( NSLocalizedString("Moderate", comment: "chat item action"), @@ -1668,6 +1679,112 @@ struct ChatView: View { ) } } + + private func moderateReportedButton(_ rItem: CIQuote, _ reportItem: ChatItem, _ groupInfo: GroupInfo) -> Button { + Button(role: .destructive) { + showModerateMessageAlert(groupInfo) { + Task { + let deleted = await deleteReportedMessage(rItem, reportItem.id, groupInfo) + if deleted != nil { + await MainActor.run { + deletingItem = reportItem + deleteMessage(.cidmInternalMark, moderate: false) + } + } + } + } + } label: { + Label( + NSLocalizedString("Moderate", comment: "chat item action"), + systemImage: "flag" + ) + } + } + + private func archiveReportButton(_ cItem: ChatItem) -> Button { + Button(role: .destructive) { + AlertManager.shared.showAlert( + Alert( + title: Text("Archive report?"), + message: Text("The report will be archived for all moderators and reporter."), + primaryButton: .destructive(Text("Archive")) { + deletingItem = cItem + deleteMessage(.cidmInternalMark, moderate: false) + }, + secondaryButton: .cancel() + ) + ) + } label: { + Label( + NSLocalizedString("Archive", comment: "chat item action"), + systemImage: "archivebox" + ) + } + } + + private func blockMemberButton(_ member: GroupMember, _ groupInfo: GroupInfo, _ rItem: CIQuote, _ report: ChatItem) -> Button { + Button(role: .destructive) { + actionSheet = SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Block and moderate?"), + buttons: [ + .destructive(Text("Block and moderate")) { + AlertManager.shared.showAlert( + Alert( + title: Text("Delete member message and block?"), + message: Text( + NSLocalizedString( + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members.\nAll new messages from \(member.chatViewName) will be hidden!" + : "The message will be marked as moderated for all members.\n All new messages from \(member.chatViewName) will be hidden!" + , comment: "block and moderate action" + ) + ), + primaryButton: .destructive(Text("Delete and block")) { + Task { + let deleted = await deleteReportedMessage(rItem, report.id, groupInfo) + if deleted != nil { + let blocked = await blockMemberForAll(groupInfo, member, true) + + if blocked != nil { + await MainActor.run { + deletingItem = report + deleteMessage(.cidmInternalMark, moderate: false) + } + } + } + } + }, + secondaryButton: .cancel() + ) + ) + }, + .destructive(Text("Only block")) { + Task { + if (await getLocalIdForReportedMessage(rItem, report.id, groupInfo)) != nil { + AlertManager.shared.showAlert( + blockForAllAlert(groupInfo, member) { + deletingItem = report + deleteMessage(.cidmInternalMark, moderate: false) + } + ) + } else { + showNoMessageMessageAlert() + } + } + }, + .cancel() + ] + ), + id: "blockMember" + ) + } label: { + Label( + NSLocalizedString("Block member", comment: "chat item action"), + systemImage: "hand.raised" + ) + } + } private func revealButton(_ ci: ChatItem) -> Button { Button { @@ -1707,7 +1824,38 @@ struct ChatView: View { ) } } - + + private func reportButton(_ ci: ChatItem) -> Button { + Button(role: .destructive) { + var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in + .default(Text(reason.text)) { + withAnimation { + if composeState.editing { + composeState = ComposeState(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason)) + } else { + composeState = composeState.copy(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason)) + } + } + } + } + + buttons.append(.cancel()) + + actionSheet = SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Report reason?"), + buttons: buttons + ), + id: "reportChatMessage" + ) + } label: { + Label ( + NSLocalizedString("Report", comment: "chat item action"), + systemImage: "flag" + ) + } + } + var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" @@ -1738,6 +1886,60 @@ struct ChatView: View { itemIds.forEach { selectedChatItems?.remove($0) } } } + + private func deleteReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> ChatItemDeletion? { + do { + let itemId = await getLocalIdForReportedMessage(rItem, reportId, groupInfo) + + if let itemId = itemId { + let deletedItem = try await apiDeleteMemberChatItems( + groupId: groupInfo.apiId, + itemIds: [itemId] + ).first + + if let di = deletedItem { + await MainActor.run { + if let toItem = di.toChatItem { + _ = m.upsertChatItem(chat.chatInfo, toItem.chatItem) + } else { + m.removeChatItem(chat.chatInfo, di.deletedChatItem.chatItem) + } + } + + return di + } + } else { + showNoMessageMessageAlert() + } + } catch { + logger.error("ChatView.deleteReportedMessage error: \(error)") + AlertManager.shared.showAlertMsg(title: LocalizedStringKey("Error"), message: LocalizedStringKey("Failed to delete reported message")) + } + + return nil + } + + private func getLocalIdForReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> Int64? { + do { + if let itemId = rItem.itemId { + return itemId + } else { + let reportItem = try await apiGetChatItems( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + pagination: .around(chatItemId: reportId, count: 0) + ).first + + if let itemId = reportItem?.quotedItem?.itemId { + return itemId + } + } + } catch { + logger.error("ChatView.getLocalIdForReportedMessage error: \(error)") + } + + return nil + } private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) { logger.debug("ChatView deleteMessage") @@ -1772,7 +1974,7 @@ struct ChatView: View { } } } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + logger.error("ChatView.deleteMessage error: \(error)") } } } @@ -1812,6 +2014,26 @@ struct ChatView: View { } } +private func showModerateMessageAlert(_ groupInfo: GroupInfo, _ onModerate: @escaping () -> Void) { + AlertManager.shared.showAlert(Alert( + title: Text("Delete member message?"), + message: Text( + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members." + : "The message will be marked as moderated for all members." + ), + primaryButton: .destructive(Text("Delete"), action: onModerate), + secondaryButton: .cancel() + )) +} + +private func showNoMessageMessageAlert() { + AlertManager.shared.showAlertMsg( + title: LocalizedStringKey("No message"), + message: LocalizedStringKey("This message was deleted or not received yet.") + ) +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 19e2b528f1..a68a4987a1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -24,6 +24,7 @@ enum ComposeContextItem { case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo) + case reportedItem(chatItem: ChatItem, reason: ReportReason) } enum VoiceMessageRecordingState { @@ -116,13 +117,31 @@ struct ComposeState { default: return false } } - + + var reporting: Bool { + switch contextItem { + case .reportedItem: return true + default: return false + } + } + + var submittingValidReport: Bool { + switch contextItem { + case let .reportedItem(_, reason): + switch reason { + case .other: return !message.isEmpty + default: return true + } + default: return false + } + } + var sendEnabled: Bool { switch preview { case let .mediaPreviews(media): return !media.isEmpty case .voicePreview: return voiceMessageRecordingState == .finished case .filePreview: return true - default: return !message.isEmpty || forwarding || liveMessage != nil + default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport } } @@ -175,7 +194,7 @@ struct ComposeState { } var attachmentDisabled: Bool { - if editing || forwarding || liveMessage != nil || inProgress { return true } + if editing || forwarding || liveMessage != nil || inProgress || reporting { return true } switch preview { case .noPreview: return false case .linkPreview: return false @@ -193,6 +212,15 @@ struct ComposeState { } } + var placeholder: String? { + switch contextItem { + case let .reportedItem(_, reason): + return reason.text + default: + return nil + } + } + var empty: Bool { message == "" && noPreview } @@ -297,6 +325,11 @@ struct ComposeView: View { ContextInvitingContactMemberView() Divider() } + + if case let .reportedItem(_, reason) = composeState.contextItem { + reportReasonView(reason) + Divider() + } // preference checks should match checks in forwarding list let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) @@ -686,6 +719,27 @@ struct ComposeView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(.thinMaterial) } + + + private func reportReasonView(_ reason: ReportReason) -> some View { + let reportText = switch reason { + case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason") + case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason") + case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason") + case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason") + case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason") + case .unknown: "" // Should never happen + } + + return Text(reportText) + .italic() + .font(.caption) + .padding(12) + .frame(minHeight: 44) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial) + } + @ViewBuilder private func contextItemView() -> some View { switch composeState.contextItem { @@ -715,6 +769,15 @@ struct ComposeView: View { cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) Divider() + case let .reportedItem(chatItem: reportedItem, _): + ContextItemView( + chat: chat, + contextItems: [reportedItem], + contextIcon: "flag", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, + contextIconForeground: Color.red + ) + Divider() } } @@ -746,6 +809,8 @@ struct ComposeView: View { sent = await updateMessage(ci, live: live) } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { sent = await updateMessage(liveMessage.chatItem, live: live) + } else if case let .reportedItem(chatItem, reason) = composeState.contextItem { + sent = await send(reason, chatItemId: chatItem.id) } else { var quoted: Int64? = nil if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem { @@ -872,6 +937,8 @@ struct ComposeView: View { return .voice(text: msgText, duration: duration) case .file: return .file(msgText) + case .report(_, let reason): + return .report(text: msgText, reason: reason) case .unknown(let type, _): return .unknown(type: type, text: msgText) } @@ -891,7 +958,25 @@ struct ComposeView: View { return nil } } - + + func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? { + if let chatItems = await apiReportMessage( + groupId: chat.chatInfo.apiId, + chatItemId: chatItemId, + reportReason: reportReason, + reportText: msgText + ) { + await MainActor.run { + for chatItem in chatItems { + chatModel.addChatItem(chat.chatInfo, chatItem) + } + } + return chatItems.first + } + + return nil + } + func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { await send( [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)], diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index fa999961fc..3cb747ec68 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -15,6 +15,7 @@ struct ContextItemView: View { let contextItems: [ChatItem] let contextIcon: String let cancelContextItem: () -> Void + var contextIconForeground: Color? = nil var showSender: Bool = true var body: some View { @@ -23,7 +24,7 @@ struct ContextItemView: View { .resizable() .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) - .foregroundColor(theme.colors.secondary) + .foregroundColor(contextIconForeground ?? theme.colors.secondary) if let singleItem = contextItems.first, contextItems.count == 1 { if showSender, let sender = singleItem.memberDisplayName { VStack(alignment: .leading, spacing: 4) { @@ -93,6 +94,6 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") - return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}) + return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index ad47b7351a..2fc122f249 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool + @Binding var placeholder: String? let onImagesAdded: ([UploadContent]) -> Void private let minHeight: CGFloat = 37 @@ -50,6 +51,7 @@ struct NativeTextEditor: UIViewRepresentable { field.setOnFocusChangedListener { focused = $0 } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + field.setPlaceholderView() updateFont(field) updateHeight(field) return field @@ -62,6 +64,11 @@ struct NativeTextEditor: UIViewRepresentable { updateFont(field) updateHeight(field) } + + let castedField = field as! CustomUITextField + if castedField.placeholder != placeholder { + castedField.placeholder = placeholder + } } private func updateHeight(_ field: UITextView) { @@ -97,11 +104,18 @@ private class CustomUITextField: UITextView, UITextViewDelegate { var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } + private let placeholderLabel: UILabel = UILabel() + init(height: Binding) { self.height = height super.init(frame: .zero, textContainer: nil) } + var placeholder: String? { + get { placeholderLabel.text } + set { placeholderLabel.text = newValue } + } + required init?(coder: NSCoder) { fatalError("Not implemented") } @@ -124,6 +138,20 @@ private class CustomUITextField: UITextView, UITextViewDelegate { func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } + + func setPlaceholderView() { + placeholderLabel.textColor = .lightGray + placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body) + placeholderLabel.isHidden = !text.isEmpty + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(placeholderLabel) + + NSLayoutConstraint.activate([ + placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7), + placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7), + placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8) + ]) + } func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged @@ -172,6 +200,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate { } func textViewDidChange(_ textView: UITextView) { + placeholderLabel.isHidden = !text.isEmpty if textView.markedTextRange == nil { var images: [UploadContent] = [] var rangeDiff = 0 @@ -217,6 +246,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), + placeholder: Binding.constant("Placeholder"), onImagesAdded: { _ in } ) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 8880023e02..fb69dfdd17 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -61,6 +61,7 @@ struct SendMessageView: View { disableEditing: $composeState.inProgress, height: $teHeight, focused: $keyboardVisible, + placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), onImagesAdded: onMediaAdded ) .allowsTightening(false) @@ -105,6 +106,8 @@ struct SendMessageView: View { let vmrs = composeState.voiceMessageRecordingState if nextSendGrpInv { inviteMemberContactButton() + } else if case .reportedItem = composeState.contextItem { + sendMessageButton() } else if showVoiceMessageButton && composeState.message.isEmpty && !composeState.editing diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index bdef8d0a62..66fe67a29e 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -175,10 +175,8 @@ struct AddGroupMembersViewCommon: View { private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { - ForEach(GroupMemberRole.allCases) { role in - if role <= groupInfo.membership.memberRole && role != .author { - Text(role.text) - } + ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in + Text(role.text) } } .frame(height: 36) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index a18de1b349..58e22e63a2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -296,7 +296,7 @@ struct GroupMemberInfoView: View { } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if let contactId = member.memberContactId { newDirectChatButton(contactId, width: buttonWidth) - } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { + } else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION { createMemberContactButton(width: buttonWidth) } InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert() @@ -764,12 +764,18 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet } } -func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { +func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember, _ onBlocked: (() -> Void)? = nil) -> Alert { Alert( title: Text("Block member for all?"), message: Text("All new messages from \(mem.chatViewName) will be hidden!"), primaryButton: .destructive(Text("Block for all")) { - blockMemberForAll(gInfo, mem, true) + Task { + let uMember = await blockMemberForAll(gInfo, mem, true) + + if uMember != nil { + onBlocked?() + } + } }, secondaryButton: .cancel() ) @@ -780,23 +786,25 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { title: Text("Unblock member for all?"), message: Text("Messages from \(mem.chatViewName) will be shown!"), primaryButton: .default(Text("Unblock for all")) { - blockMemberForAll(gInfo, mem, false) + Task { + await blockMemberForAll(gInfo, mem, false) + } }, secondaryButton: .cancel() ) } -func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { - Task { - do { - let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) - await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) - } - } catch let error { - logger.error("apiBlockMemberForAll error: \(responseError(error))") +func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) async -> GroupMember? { + do { + let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) } + return updatedMember + } catch let error { + logger.error("apiBlockMemberForAll error: \(responseError(error))") } + return nil } struct GroupMemberInfoView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 7b185d8211..81498ee497 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -116,10 +116,10 @@ struct SelectedItemsBottomToolbar: View { if selected.contains(ci.id) { var (de, dee, me, onlyOwnGroupItems, fe, sel) = r de = de && ci.canBeDeletedForSelf - dee = dee && ci.meta.deletable && !ci.localNote - onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd - me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil - fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy + dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport + onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport + me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport + fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list return (de, dee, me, onlyOwnGroupItems, fe, sel) } else { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 13701a40a2..a311db7d50 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -248,16 +248,20 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> 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: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary) + return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) // 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") + if cItem.meta.itemDeleted != nil, cItem.isReport { + "archived report" + } else { + 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") + } } } @@ -270,6 +274,13 @@ struct ChatPreviewView: View { default: return nil } } + + func prefix() -> Text { + switch cItem.content.msgContent { + case let .report(text, reason): return Text(!text.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) + default: return Text("") + } + } } @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ae7e67b32f..71a5132d12 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -45,6 +45,7 @@ public enum ChatCommand { case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) + case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) @@ -209,6 +210,8 @@ public enum ChatCommand { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" + case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): + return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" @@ -372,6 +375,7 @@ public enum ChatCommand { case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiSendMessages: return "apiSendMessages" case .apiCreateChatItems: return "apiCreateChatItems" + case .apiReportMessage: return "apiReportMessage" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" @@ -1162,12 +1166,14 @@ public enum ChatPagination { case last(count: Int) case after(chatItemId: Int64, count: Int) case before(chatItemId: Int64, count: Int) + case around(chatItemId: Int64, count: Int) var cmdString: String { switch self { case let .last(count): return "count=\(count)" case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)" case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)" + case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)" } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f8711ff779..2638c56776 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,6 +9,12 @@ import Foundation import SwiftUI +// version to establishing direct connection with a group member (xGrpDirectInvVRange in core) +public let CREATE_MEMBER_CONTACT_VERSION = 2 + +// version to receive reports (MCReport) +public let REPORTS_VERSION = 12 + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -1678,7 +1684,7 @@ public struct Connection: Decodable, Hashable { static let sampleData = Connection( connId: 1, agentConnId: "abc", - peerChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + peerChatVRange: VersionRange(1, 1), connStatus: .ready, connLevel: 0, viaGroupLink: false, @@ -1690,17 +1696,13 @@ public struct Connection: Decodable, Hashable { } public struct VersionRange: Decodable, Hashable { - public init(minVersion: Int, maxVersion: Int) { + public init(_ minVersion: Int, _ maxVersion: Int) { self.minVersion = minVersion self.maxVersion = maxVersion } public var minVersion: Int public var maxVersion: Int - - public func isCompatibleRange(_ vRange: VersionRange) -> Bool { - self.minVersion <= vRange.maxVersion && vRange.minVersion <= self.maxVersion - } } public struct SecurityCode: Decodable, Equatable, Hashable { @@ -1752,7 +1754,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable { public static let sampleData = UserContactRequest( contactRequestId: 1, userContactLinkId: 1, - cReqChatVRange: VersionRange(minVersion: 1, maxVersion: 1), + cReqChatVRange: VersionRange(1, 1), localDisplayName: "alice", profile: Profile.sampleData, createdAt: .now, @@ -1989,6 +1991,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberContactId: Int64? public var memberContactProfileId: Int64 public var activeConn: Connection? + public var memberChatVRange: VersionRange public var id: String { "#\(groupId) @\(groupMemberId)" } public var displayName: String { @@ -2083,7 +2086,19 @@ public struct GroupMember: Identifiable, Decodable, Hashable { return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .admin && userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive } + + public var canReceiveReports: Bool { + memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION + } + public var versionRange: VersionRange { + if let activeConn { + activeConn.peerChatVRange + } else { + memberChatVRange + } + } + public var memberIncognito: Bool { memberProfile.profileId != memberContactProfileId } @@ -2102,7 +2117,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable { memberProfile: LocalProfile.sampleData, memberContactId: 1, memberContactProfileId: 1, - activeConn: Connection.sampleData + activeConn: Connection.sampleData, + memberChatVRange: VersionRange(2, 12) ) } @@ -2121,19 +2137,23 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { - case observer = "observer" - case author = "author" - case member = "member" - case admin = "admin" - case owner = "owner" + case observer + case author + case member + case moderator + case admin + case owner public var id: Self { self } + public static var supportedRoles: [GroupMemberRole] = [.observer, .member, .admin, .owner] + public var text: String { switch self { case .observer: return NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") + case .moderator: return NSLocalizedString("moderator", comment: "member role") case .admin: return NSLocalizedString("admin", comment: "member role") case .owner: return NSLocalizedString("owner", comment: "member role") } @@ -2141,11 +2161,12 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod private var comparisonValue: Int { switch self { - case .observer: return 0 - case .author: return 1 - case .member: return 2 - case .admin: return 3 - case .owner: return 4 + case .observer: 0 + case .author: 1 + case .member: 2 + case .moderator: 3 + case .admin: 4 + case .owner: 5 } } @@ -2551,6 +2572,17 @@ public struct ChatItem: Identifiable, Decodable, Hashable { default: return true } } + + public var isReport: Bool { + switch content { + case let .sndMsgContent(msgContent), let .rcvMsgContent(msgContent): + switch msgContent { + case .report: true + default: false + } + default: false + } + } public var canBeDeletedForSelf: Bool { (content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete @@ -2636,6 +2668,34 @@ public struct ChatItem: Identifiable, Decodable, Hashable { file: nil ) } + + public static func getReportSample(text: String, reason: ReportReason, item: ChatItem, sender: GroupMember? = nil) -> ChatItem { + let chatDir = if let sender = sender { + CIDirection.groupRcv(groupMember: sender) + } else { + CIDirection.groupSnd + } + + return ChatItem( + chatDir: chatDir, + meta: CIMeta( + itemId: -2, + itemTs: .now, + itemText: "", + itemStatus: .rcvRead, + createdAt: .now, + updatedAt: .now, + itemDeleted: nil, + itemEdited: false, + itemLive: false, + deletable: false, + editable: false + ), + content: .sndMsgContent(msgContent: .report(text: text, reason: reason)), + quotedItem: CIQuote.getSample(item.id, item.meta.createdAt, item.text, chatDir: item.chatDir), + file: nil + ) + } public static func deletedItemDummy() -> ChatItem { ChatItem( @@ -3250,14 +3310,12 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? - public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) default: return content.text } } - public func getSender(_ membership: GroupMember?) -> String? { switch (chatDir) { case .directSnd: return "you" @@ -3279,6 +3337,17 @@ public struct CIQuote: Decodable, ItemContent, Hashable { } return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } + + public func memberToModerate(_ chatInfo: ChatInfo) -> GroupMember? { + switch (chatInfo, chatDir) { + case let (.group(groupInfo), .groupRcv(groupMember)): + let m = groupInfo.membership + return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole + ? groupMember + : nil + default: return nil + } + } } public struct CIReactionCount: Decodable, Hashable { @@ -3620,6 +3689,7 @@ public enum MsgContent: Equatable, Hashable { case video(text: String, image: String, duration: Int) case voice(text: String, duration: Int) case file(String) + case report(text: String, reason: ReportReason) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -3631,6 +3701,7 @@ public enum MsgContent: Equatable, Hashable { case let .video(text, _, _): return text case let .voice(text, _): return text case let .file(text): return text + case let .report(text, _): return text case let .unknown(_, text): return text } } @@ -3690,6 +3761,7 @@ public enum MsgContent: Equatable, Hashable { case preview case image case duration + case reason } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { @@ -3700,6 +3772,7 @@ public enum MsgContent: Equatable, Hashable { case let (.video(lt, li, ld), .video(rt, ri, rd)): return lt == rt && li == ri && ld == rd case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd case let (.file(lf), .file(rf)): return lf == rf + case let (.report(lt, lr), .report(rt, rr)): return lt == rt && lr == rr case let (.unknown(lType, lt), .unknown(rType, rt)): return lType == rType && lt == rt default: return false } @@ -3735,6 +3808,10 @@ extension MsgContent: Decodable { case "file": let text = try container.decode(String.self, forKey: CodingKeys.text) self = .file(text) + case "report": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let reason = try container.decode(ReportReason.self, forKey: CodingKeys.reason) + self = .report(text: text, reason: reason) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -3772,6 +3849,10 @@ extension MsgContent: Encodable { case let .file(text): try container.encode("file", forKey: .type) try container.encode(text, forKey: .text) + case let .report(text, reason): + try container.encode("report", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(reason, forKey: .reason) // TODO use original JSON and type case let .unknown(_, text): try container.encode("text", forKey: .type) @@ -3851,6 +3932,57 @@ public enum FormatColor: String, Decodable, Hashable { } } +public enum ReportReason: Hashable { + case spam + case illegal + case community + case profile + case other + case unknown(type: String) + + public static var supportedReasons: [ReportReason] = [.spam, .illegal, .community, .profile, .other] + + public var text: String { + switch self { + case .spam: return NSLocalizedString("Spam", comment: "report reason") + case .illegal: return NSLocalizedString("Inappropriate content", comment: "report reason") + case .community: return NSLocalizedString("Community guidelines violation", comment: "report reason") + case .profile: return NSLocalizedString("Inappropriate profile", comment: "report reason") + case .other: return NSLocalizedString("Another reason", comment: "report reason") + case let .unknown(type): return type + } + } +} + +extension ReportReason: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .spam: try container.encode("spam") + case .illegal: try container.encode("illegal") + case .community: try container.encode("community") + case .profile: try container.encode("profile") + case .other: try container.encode("other") + case let .unknown(type): try container.encode(type) + } + } +} + +extension ReportReason: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let type = try container.decode(String.self) + switch type { + case "spam": self = .spam + case "illegal": self = .illegal + case "community": self = .community + case "profile": self = .profile + case "other": self = .other + default: self = .unknown(type: type) + } + } +} + // Struct to use with simplex API public struct LinkPreview: Codable, Equatable, Hashable { public init(uri: URL, title: String, description: String = "", image: String) { 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 ac33917588..2ff5bb157d 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 @@ -1383,11 +1383,7 @@ data class Connection( } @Serializable -data class VersionRange(val minVersion: Int, val maxVersion: Int) { - - fun isCompatibleRange(vRange: VersionRange): Boolean = - this.minVersion <= vRange.maxVersion && vRange.minVersion <= this.maxVersion -} +data class VersionRange(val minVersion: Int, val maxVersion: Int) @Serializable data class SecurityCode(val securityCode: String, val verifiedAt: Instant) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index e78472d2d4..0594bd9711 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -46,11 +46,8 @@ import java.util.Date typealias ChatCtrl = Long -// currentChatVersion in core -const val CURRENT_CHAT_VERSION: Int = 2 - // version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core) -val CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion = 2, maxVersion = CURRENT_CHAT_VERSION) +val CREATE_MEMBER_CONTACT_VERSION = 2 enum class CallOnLockScreen { DISABLE, From 7281255480a2f94751ad7b7b114278f7606bc214 Mon Sep 17 00:00:00 2001 From: Diogo Date: Wed, 8 Jan 2025 20:07:32 +0000 Subject: [PATCH 48/95] android, desktop: inline reports (#5485) * simple send and receive * fix sending reason enum via api * trim "" * report preview and msg display * adding support for moderator (not active) * disable all bulk actions for reports * progress on context menu * make delete messages and block fn suspend * block and moderate * fixes and code cleanup * never show report on own messages * minor code improvements * supportedRoles -> selectableRoles * remove paddings on msg not allowed and other overlapping views, change color * reports: disables attachments, cleans previews and stops lives * disable report on lives * refactor * reports - enable delete for self on bulk actions * text * select report context menu * ios: text --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 40 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- .../simplex/common/views/chat/ChatView.kt | 72 ++-- .../simplex/common/views/chat/ComposeView.kt | 74 ++++- .../common/views/chat/ContextItemView.kt | 7 +- .../views/chat/SelectableChatItemToolbars.kt | 8 +- .../simplex/common/views/chat/SendMsgView.kt | 5 +- .../views/chat/group/AddGroupMembersView.kt | 4 +- .../views/chat/group/GroupMemberInfoView.kt | 16 +- .../common/views/chat/item/ChatItemView.kt | 312 ++++++++++++++++-- .../common/views/chat/item/FramedItemView.kt | 28 +- .../views/chat/item/MarkedDeletedItemView.kt | 11 +- .../common/views/chat/item/TextItemView.kt | 5 +- .../common/views/chatlist/ChatPreviewView.kt | 14 +- .../commonMain/resources/MR/base/strings.xml | 28 ++ 16 files changed, 534 insertions(+), 94 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 285bdf2d98..b74cbfbc81 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1706,7 +1706,7 @@ struct ChatView: View { AlertManager.shared.showAlert( Alert( title: Text("Archive report?"), - message: Text("The report will be archived for all moderators and reporter."), + message: Text("The report will be archived for you."), primaryButton: .destructive(Text("Archive")) { deletingItem = cItem deleteMessage(.cidmInternalMark, moderate: false) 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 2ff5bb157d..5b05d033c8 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 @@ -1638,7 +1638,7 @@ data class GroupMember ( fun canChangeRoleTo(groupInfo: GroupInfo): List? = if (!canBeRemoved(groupInfo)) null else groupInfo.membership.memberRole.let { userRole -> - GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Author } + GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { @@ -1689,13 +1689,19 @@ enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons @SerialName("author") Author("author"), @SerialName("member") Member("member"), + @SerialName("moderator") Moderator("moderator"), @SerialName("admin") Admin("admin"), @SerialName("owner") Owner("owner"); + companion object { + val selectableRoles: List = listOf(Observer, Member, Admin, Owner) + } + val text: String get() = when (this) { Observer -> generalGetString(MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) + Moderator -> generalGetString(MR.strings.group_member_role_moderator) Admin -> generalGetString(MR.strings.group_member_role_admin) Owner -> generalGetString(MR.strings.group_member_role_owner) } @@ -2116,6 +2122,12 @@ data class ChatItem ( else -> true } + val isReport: Boolean get() = when (content) { + is CIContent.SndMsgContent, is CIContent.RcvMsgContent -> + content.msgContent is MsgContent.MCReport + else -> false + } + val canBeDeletedForSelf: Boolean get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete @@ -2946,6 +2958,19 @@ class CIQuote ( null -> null } + fun memberToModerate(chatInfo: ChatInfo): GroupMember? { + return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { + val m = chatInfo.groupInfo.membership + if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole) { + chatDir.groupMember + } else { + null + } + } else { + null + } + } + companion object { fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote = CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text)) @@ -3589,6 +3614,19 @@ sealed class ReportReason { @Serializable @SerialName("profile") object Profile: ReportReason() @Serializable @SerialName("other") object Other: ReportReason() @Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason() + + companion object { + val supportedReasons: List = listOf(Spam, Illegal, Community, Profile, Other) + } + + val text: String get() = when (this) { + Spam -> generalGetString(MR.strings.report_reason_spam) + Illegal -> generalGetString(MR.strings.report_reason_illegal) + Community -> generalGetString(MR.strings.report_reason_community) + Profile -> generalGetString(MR.strings.report_reason_profile) + Other -> generalGetString(MR.strings.report_reason_other) + is Unknown -> type + } } object ReportReasonSerializer : KSerializer { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 0594bd9711..6400d2d2d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3330,7 +3330,7 @@ sealed class CC { val msgs = json.encodeToString(composedMessages) "/_create *$noteFolderId json $msgs" } - is ApiReportMessage -> "/_report #$groupId $chatItemId reason=$reportReason $reportText" + is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" 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 3c0f1f7769..5ab6906558 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 @@ -301,41 +301,41 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } }, deleteMessage = { itemId, mode -> - withBGApi { - val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } - val toModerate = toDeleteItem?.memberToModerate(chatInfo) - val groupInfo = toModerate?.first - val groupMember = toModerate?.second - val deletedChatItem: ChatItem? - val toChatItem: ChatItem? - val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { - chatModel.controller.apiDeleteMemberChatItems( - chatRh, - groupId = groupInfo.groupId, - itemIds = listOf(itemId) - ) - } else { - chatModel.controller.apiDeleteChatItems( - chatRh, - type = chatInfo.chatType, - id = chatInfo.apiId, - itemIds = listOf(itemId), - mode = mode - ) - } - val deleted = r?.firstOrNull() - if (deleted != null) { - deletedChatItem = deleted.deletedChatItem.chatItem - toChatItem = deleted.toChatItem?.chatItem - withChats { - if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) - } + val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } + val toModerate = toDeleteItem?.memberToModerate(chatInfo) + val groupInfo = toModerate?.first + val groupMember = toModerate?.second + val deletedChatItem: ChatItem? + val toChatItem: ChatItem? + val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { + chatModel.controller.apiDeleteMemberChatItems( + chatRh, + groupId = groupInfo.groupId, + itemIds = listOf(itemId) + ) + } else { + chatModel.controller.apiDeleteChatItems( + chatRh, + type = chatInfo.chatType, + id = chatInfo.apiId, + itemIds = listOf(itemId), + mode = mode + ) + } + val deleted = r?.firstOrNull() + if (deleted != null) { + deletedChatItem = deleted.deletedChatItem.chatItem + toChatItem = deleted.toChatItem?.chatItem + withChats { + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, deletedChatItem) } } } + + deleted }, deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, receiveFile = { fileId -> @@ -599,7 +599,7 @@ fun ChatLayout( info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -946,7 +946,7 @@ fun BoxScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -2434,7 +2434,7 @@ fun PreviewChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -2507,7 +2507,7 @@ fun PreviewGroupChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = {}, receiveFile = { _ -> }, cancelFile = {}, 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 2247a615df..01c6faa573 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 @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onSizeChanged @@ -51,6 +52,7 @@ sealed class ComposeContextItem { @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo): ComposeContextItem() + @Serializable class ReportedItem(val chatItem: ChatItem, val reason: ReportReason): ComposeContextItem() } @Serializable @@ -89,13 +91,28 @@ data class ComposeState( is ComposeContextItem.ForwardingItems -> true else -> false } + val reporting: Boolean + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> true + else -> false + } + val submittingValidReport: Boolean + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> { + when (contextItem.reason) { + is ReportReason.Other -> message.isNotEmpty() + else -> true + } + } + else -> false + } val sendEnabled: () -> Boolean get() = { val hasContent = when (preview) { is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || forwarding || liveMessage != null + else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } @@ -119,7 +136,7 @@ data class ComposeState( val attachmentDisabled: Boolean get() { - if (editing || forwarding || liveMessage != null || inProgress) return true + if (editing || forwarding || liveMessage != null || inProgress || reporting) return true return when (preview) { ComposePreview.NoPreview -> false is ComposePreview.CLinkPreview -> false @@ -136,6 +153,12 @@ data class ComposeState( is ComposePreview.FilePreview -> true } + val placeholder: String + get() = when (contextItem) { + is ComposeContextItem.ReportedItem -> contextItem.reason.text + else -> generalGetString(MR.strings.compose_message_placeholder) + } + val empty: Boolean get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem @@ -489,6 +512,19 @@ fun ComposeView( } } + suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List? { + val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText) + if (cItems != null) { + withChats { + cItems.forEach { chatItem -> + addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) + } + } + } + + return cItems?.map { it.chatItem } + } + suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) @@ -554,6 +590,8 @@ fun ComposeView( } else if (liveMessage != null && liveMessage.sent) { val updatedMessage = updateMessage(liveMessage.chatItem, chat, live) sent = if (updatedMessage != null) listOf(updatedMessage) else null + } else if (cs.contextItem is ComposeContextItem.ReportedItem) { + sent = sendReport(cs.contextItem.reason, cs.contextItem.chatItem.id) } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() @@ -835,14 +873,33 @@ fun ComposeView( @Composable fun MsgNotAllowedView(reason: String, icon: Painter) { - val color = MaterialTheme.appColors.receivedMessage - Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + val color = MaterialTheme.appColors.receivedQuote + Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null, tint = MaterialTheme.colors.secondary) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Text(reason, fontStyle = FontStyle.Italic) } } + @Composable + fun ReportReasonView(reason: ReportReason) { + val reportText = when (reason) { + is ReportReason.Spam -> generalGetString(MR.strings.report_compose_reason_header_spam) + is ReportReason.Illegal -> generalGetString(MR.strings.report_compose_reason_header_illegal) + is ReportReason.Profile -> generalGetString(MR.strings.report_compose_reason_header_profile) + is ReportReason.Community -> generalGetString(MR.strings.report_compose_reason_header_community) + is ReportReason.Other -> generalGetString(MR.strings.report_compose_reason_header_other) + is ReportReason.Unknown -> null // should never happen + } + + if (reportText != null) { + val color = MaterialTheme.appColors.receivedQuote + Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + Text(reportText, fontStyle = FontStyle.Italic, fontSize = 12.sp) + } + } + } + @Composable fun contextItemView() { when (val contextItem = composeState.value.contextItem) { @@ -856,6 +913,9 @@ fun ComposeView( is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } + is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } } } @@ -893,6 +953,10 @@ fun ComposeView( if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } + val ctx = composeState.value.contextItem + if (ctx is ComposeContextItem.ReportedItem) { + ReportReasonView(ctx.reason) + } val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) @@ -1050,7 +1114,7 @@ fun ComposeView( sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = stringResource(MR.strings.compose_message_placeholder), + placeholder = composeState.value.placeholder, sendMessage = { ttl -> sendMessage(ttl) resetLinkPreview() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 5850f0b7ec..1657a1f0b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -12,6 +12,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp @@ -31,6 +32,7 @@ fun ContextItemView( contextIcon: Painter, showSender: Boolean = true, chatType: ChatType, + contextIconColor: Color = MaterialTheme.colors.secondary, cancelContextItem: () -> Unit, ) { val sentColor = MaterialTheme.appColors.sentMessage @@ -85,7 +87,6 @@ fun ContextItemView( Row( Modifier - .padding(top = 8.dp) .background(if (sent) sentColor else receivedColor), verticalAlignment = Alignment.CenterVertically ) { @@ -103,8 +104,8 @@ fun ContextItemView( .height(20.dp) .width(20.dp), contentDescription = stringResource(MR.strings.icon_descr_context), - tint = MaterialTheme.colors.secondary, - ) + tint = contextIconColor, + ) if (contextItems.count() == 1) { val contextItem = contextItems[0] diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 838398c503..582a981443 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -138,10 +138,10 @@ private fun recheckItems(chatInfo: ChatInfo, for (ci in chatItems) { if (selected.contains(ci.id)) { rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf - rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote - rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd - rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null - rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy + rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport + rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport + rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport + rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list } } 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 d912f8e030..e2b44478af 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 @@ -74,7 +74,7 @@ fun SendMsgView( } } val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && - !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) + !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || @@ -125,6 +125,9 @@ fun SendMsgView( } when { progressByTimeout -> ProgressIndicator() + cs.contextItem is ComposeContextItem.ReportedItem -> { + SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) + } showVoiceButton && sendMsgEnabled -> { Row(verticalAlignment = Alignment.CenterVertically) { val stopRecOnNextClick = remember { mutableStateOf(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 25661f00a0..c8b61e4179 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 @@ -209,8 +209,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState Unit = { withBGApi { blockMemberForAll(rhId, gInfo, mem, true) } }) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.block_for_all_question), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, true) + blockMember() }, destructive = true, ) @@ -765,17 +765,15 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { - blockMemberForAll(rhId, gInfo, mem, false) + withBGApi { blockMemberForAll(rhId, gInfo, mem, false) } }, ) } -fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { - withBGApi { - val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) - withChats { - upsertGroupMember(rhId, gInfo, updatedMember) - } +suspend fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { + val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) + withChats { + upsertGroupMember(rhId, gInfo, updatedMember) } } 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 647c74da06..1a094f613a 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 @@ -1,5 +1,6 @@ package chat.simplex.common.views.chat.item +import SectionItemView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.interaction.HoverInteraction @@ -20,14 +21,18 @@ import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.currentUser +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.group.blockForAllAlert +import chat.simplex.common.views.chat.group.blockMemberForAll import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -72,7 +77,7 @@ fun ChatItemView( selectedChatItems: MutableState?>, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -108,6 +113,12 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value + val deleteMessageAsync: (Long, CIDeleteMode) -> Unit = { id, mode -> + withBGApi { + deleteMessage(id, mode) + } + } + Box( modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, contentAlignment = alignment, @@ -282,7 +293,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -295,7 +306,36 @@ fun ChatItemView( val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) when { // 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 -> { + cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + DefaultDropdownMenu(showMenu) { + if (cItem.chatDir is CIDirection.GroupSnd) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + } else { + ArchiveReportItemAction(cItem, showMenu, deleteMessageAsync) + val qItem = cItem.quotedItem + if (qItem != null) { + ModerateReportItemAction(rhId, cInfo, cItem, qItem, showMenu, deleteMessage) + val rMember = qItem.memberToModerate(cInfo) + if (rMember != null && !rMember.blockedByAdmin && rMember.canBlockForAll(cInfo.groupInfo)) { + BlockMemberAction( + rhId, + chatInfo = cInfo, + groupInfo = cInfo.groupInfo, + cItem = cItem, + reportedItem = qItem, + member = rMember, + showMenu = showMenu, + deleteMessage = deleteMessage + ) + } + } + + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { DefaultDropdownMenu(showMenu) { if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { MsgReactionsMenu() @@ -381,11 +421,15 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null && cItem.chatDir !is CIDirection.GroupSnd) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + if (cItem.chatDir !is CIDirection.GroupSnd) { + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessageAsync) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole < GroupMemberRole.Moderator && !live) { + ReportItemAction(cItem, composeState, showMenu) + } } if (cItem.canBeDeletedForSelf) { Divider() @@ -403,7 +447,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -413,7 +457,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -427,7 +471,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -436,7 +480,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -453,7 +497,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -487,7 +531,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -544,7 +588,7 @@ fun ChatItemView( MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessageAsync, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -778,7 +822,7 @@ fun ModerateItemAction( painterResource(MR.images.ic_flag), onClick = { showMenu.value = false - moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + moderateMessageAlertDialog(cItem.id, questionText, deleteMessage = deleteMessage) }, color = Color.Red ) @@ -847,6 +891,183 @@ private fun ShrinkItemAction(revealed: State, showMenu: MutableState, + showMenu: MutableState, +) { + ItemAction( + stringResource(MR.strings.report_verb), + painterResource(MR.images.ic_flag), + onClick = { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.report_reason_alert_title), + buttons = { + ReportReason.supportedReasons.forEach { reason -> + SectionItemView({ + if (composeState.value.editing) { + composeState.value = ComposeState( + contextItem = ComposeContextItem.ReportedItem(cItem, reason), + useLinkPreviews = false, + preview = ComposePreview.NoPreview, + ) + } else { + composeState.value = composeState.value.copy( + contextItem = ComposeContextItem.ReportedItem(cItem, reason), + useLinkPreviews = false, + preview = ComposePreview.NoPreview, + ) + } + AlertManager.shared.hideAlert() + }) { + Text(reason.text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + ) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun ModerateReportItemAction( + rhId: Long?, + chatInfo: ChatInfo, + cItem: ChatItem, + reportedItem: CIQuote, + showMenu: MutableState, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + ItemAction( + stringResource(MR.strings.moderate_verb), + painterResource(MR.images.ic_flag), + onClick = { + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + moderateMessageAlertDialog( + reportedMessageId, + questionText = moderateMessageQuestionText(chatInfo.featureEnabled(ChatFeature.FullDelete), 1), + deleteMessage = { id, m -> + withApi { + val deleted = deleteMessage(id, m) + if (deleted != null) { + deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) + } + } + }, + ) + } + } + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun BlockMemberAction( + rhId: Long?, + chatInfo: ChatInfo, + groupInfo: GroupInfo, + cItem: ChatItem, + reportedItem: CIQuote, + member: GroupMember, + showMenu: MutableState, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + ItemAction( + stringResource(MR.strings.block_member_button), + painterResource(MR.images.ic_back_hand), + onClick = { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.report_block_and_moderate_title), + buttons = { + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + blockAndModerateAlertDialog( + rhId, + reportedMessageId = reportedMessageId, + reportId = cItem.id, + gInfo = groupInfo, + mem = member, + deleteMessage = deleteMessage, + ) + } + } + }) { + Text(generalGetString(MR.strings.report_block_and_moderate_block_and_moderate_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { + val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) + if (reportedMessageId != null) { + blockForAllAlert(rhId, gInfo = groupInfo, mem = member, blockMember = { + withBGApi { + try { + blockMemberForAll( + rhId, + gInfo = groupInfo, + member = member, + blocked = true + ) + deleteMessage(reportedMessageId, CIDeleteMode.cidmInternalMark) + } catch (ex: Exception) { + Log.e(TAG, "BlockMemberAction block and moderate ${ex.message}") + } + } + }) + } + } + }) { + Text(generalGetString(MR.strings.report_block_and_moderate_only_block_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + ) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { + ItemAction( + stringResource(MR.strings.archive_verb), + painterResource(MR.images.ic_inventory_2), + onClick = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_archive_alert_title), + text = generalGetString(MR.strings.report_archive_alert_desc), + onConfirm = { + deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) + }, + destructive = true, + confirmText = generalGetString(MR.strings.archive_verb), + ) + showMenu.value = false + }, + color = Color.Red + ) +} + @Composable fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) { val finalColor = if (color == Color.Unspecified) { @@ -1133,7 +1354,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } - if (chatItem.meta.deletable && !chatItem.localNote) { + if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) @@ -1180,14 +1401,14 @@ fun moderateMessageQuestionText(fullDeleteAllowed: Boolean, count: Int): String } } -fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { +fun moderateMessageAlertDialog(chatItemId: Long, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), text = questionText, confirmText = generalGetString(MR.strings.delete_verb), destructive = true, onConfirm = { - deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) + deleteMessage(chatItemId, CIDeleteMode.cidmBroadcast) } ) } @@ -1202,8 +1423,59 @@ fun moderateMessagesAlertDialog(itemIds: List, questionText: String, delet ) } +private fun blockAndModerateAlertDialog( + rhId: Long?, + reportedMessageId: Long, + reportId: Long, + gInfo: GroupInfo, + mem: GroupMember, + deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? +) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.report_block_and_moderate_confirmation_title), + text = generalGetString( + if (gInfo.fullGroupPreferences.fullDelete.on) MR.strings.report_block_and_moderate_confirmation_desc_full_delete else MR.strings.report_block_and_moderate_confirmation_desc_full_delete).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.report_block_and_moderate_confirmation_ok), + onConfirm = { + withBGApi { + try { + val deleted = deleteMessage(reportedMessageId, CIDeleteMode.cidmBroadcast) + if (deleted != null) { + blockMemberForAll(rhId, gInfo, mem, true) + deleteMessage(reportId, CIDeleteMode.cidmInternalMark) + } + } catch (ex: Exception) { + Log.e(TAG, "blockAndModerateAlertDialog block and moderate ${ex.message}") + } + } + }, + destructive = true, + ) +} + expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) +private suspend fun getLocalIdForReportedMessage( + rhId: Long?, + chatInfo: ChatInfo, + reportedItem: CIQuote, + itemId: Long): Long? { + if (reportedItem.itemId != null) { + return reportedItem.itemId + } + val item = apiLoadSingleMessage(rhId, chatInfo.chatType, chatInfo.apiId, itemId) + + if (item?.quotedItem?.itemId != null) { + withChats { + updateChatItem(chatInfo, item) + } + return item.quotedItem.itemId + } else { + showQuotedItemDoesNotExistAlert() + return null + } +} + @Preview @Composable fun PreviewChatItemView( @@ -1221,7 +1493,7 @@ fun PreviewChatItemView( range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -1267,7 +1539,7 @@ fun PreviewChatItemViewDeletedContent() { range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> }, + deleteMessage = { _, _ -> null }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index e955428031..2827a649b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -88,7 +88,7 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) { + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { val sentColor = MaterialTheme.appColors.sentQuote val receivedColor = MaterialTheme.appColors.receivedQuote Row( @@ -104,7 +104,7 @@ fun FramedItemView( icon, caption, Modifier.size(18.dp), - tint = if (isInDarkTheme()) FileDark else FileLight + tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } Text( @@ -216,7 +216,18 @@ fun FramedItemView( .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - if (ci.meta.itemDeleted != null) { + if (ci.isReport) { + if (ci.meta.itemDeleted == null) { + FramedItemHeader( + stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), + true, + painterResource(MR.images.ic_flag), + iconColor = Color.Red + ) + } else { + FramedItemHeader(stringResource(MR.strings.report_item_archived), true, painterResource(MR.images.ic_flag)) + } + } else if (ci.meta.itemDeleted != null) { when (ci.meta.itemDeleted) { is CIDeleted.Moderated -> { FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) @@ -288,6 +299,14 @@ fun FramedItemView( CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } + is MsgContent.MCReport -> { + val prefix = buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + } else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } @@ -315,13 +334,14 @@ fun CIMarkdownText( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean, showTimestamp: Boolean, + prefix: AnnotatedString? = null ) { Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix ) } } 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 d2e19a37d6..410372fe96 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 @@ -67,7 +67,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State } val total = moderated + blocked + blockedByAdmin + deleted if (total <= 1) - markedDeletedText(chatItem.meta) + markedDeletedText(chatItem) else if (total == moderated) stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) else if (total == blockedByAdmin) @@ -77,7 +77,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State else stringResource(MR.strings.marked_deleted_items_description).format(total) } else { - markedDeletedText(chatItem.meta) + markedDeletedText(chatItem) } Text( @@ -91,10 +91,11 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State ) } -fun markedDeletedText(meta: CIMeta): String = - when (meta.itemDeleted) { +fun markedDeletedText(cItem: ChatItem): String = + if (cItem.meta.itemDeleted != null && cItem.isReport) generalGetString(MR.strings.report_item_archived) + else when (cItem.meta.itemDeleted) { is CIDeleted.Moderated -> - String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) + String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName) is CIDeleted.Blocked -> generalGetString(MR.strings.blocked_item_description) is CIDeleted.BlockedByAdmin -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 434cde608a..257ede7d4a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -71,7 +71,8 @@ fun MarkdownText ( inlineContent: Pair Unit, Map>? = null, onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, - showTimestamp: Boolean = true + showTimestamp: Boolean = true, + prefix: AnnotatedString? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -123,6 +124,7 @@ fun MarkdownText ( val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) if (meta?.isLive == true) { @@ -136,6 +138,7 @@ fun MarkdownText ( val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) + if (prefix != null) append(prefix) for ((i, ft) in formattedText.withIndex()) { if (ft.format == null) append(ft.text) else if (toggleSecrets && ft.format is Format.Secret) { 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 0e0c3e74f4..d0e9b003e2 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 @@ -21,6 +21,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* @@ -174,13 +175,23 @@ fun ChatPreviewView( val (text: CharSequence, inlineTextContent) = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null - else -> markedDeletedText(ci.meta) to null + else -> markedDeletedText(ci) to null } val formattedText = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> null ci.meta.itemDeleted == null -> ci.formattedText else -> null } + val prefix = when (val mc = ci.content.msgContent) { + is MsgContent.MCReport -> + buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ") + } + } + else -> null + } + MarkdownText( text, formattedText, @@ -202,6 +213,7 @@ fun ChatPreviewView( ), inlineContent = inlineTextContent, modifier = Modifier.fillMaxWidth(), + prefix = prefix ) } } else { 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 22ad02a52e..860975a414 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -37,6 +37,9 @@ %d messages marked deleted moderated by %s %1$d messages moderated by %2$s + Only you and moderators see it + Only sender and moderators see it + archived report blocked blocked by admin %d messages blocked @@ -94,6 +97,13 @@ Via browser Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red. + + Spam + Inappropriate content + Community guidelines violation + Inappropriate profile + Another reason + Error saving SMP servers Error saving XFTP servers @@ -292,6 +302,16 @@ Most likely this contact has deleted the connection with you. No message This message was deleted or not received yet. + Report reason? + Archive report? + The report will be archived for you. + Block and moderate? + Block and moderate + Only block + Delete member message and block? + The message will be deleted for all members.\nAll new messages from %1$s will be hidden! + The message will be marked as moderated for all members.\nAll new messages from %1$s will be hidden! + Delete and block Error: %1$s @@ -317,6 +337,7 @@ Edit Info Search + Archive Sent message Received message History @@ -334,6 +355,7 @@ Hide Allow Moderate + Report Select Expand Delete message? @@ -448,6 +470,11 @@ Please reduce the message size and send again. Please reduce the message size or remove media and send again. You can copy and reduce the message size to send it. + Report spam: only group moderators will see it. + Report member profile: only group moderators will see it. + Report violation: only group moderators will see it. + Report content: only group moderators will see it. + Report other: only group moderators will see it. Image @@ -1529,6 +1556,7 @@ observer author member + moderator admin owner From bcb7c8bd7b101aedcb470e8739d4b220b59464d3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 8 Jan 2025 22:13:43 +0000 Subject: [PATCH 49/95] core: do not include reports in group history (#5491) --- src/Simplex/Chat/Store/Messages.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 01ae7b7c28..83cac97b18 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -3051,9 +3051,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ? WHERE i.user_id = ? AND i.group_id = ? AND i.item_content_tag IN (?,?) + AND i.msg_content_tag NOT IN (?) AND i.item_deleted = 0 AND s.group_snd_item_status_id IS NULL ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] - (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, count) + (groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count) From 3cfc74e0fd66cf2ddbfb3ea9aad3d6593483504e Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 9 Jan 2025 13:58:41 +0700 Subject: [PATCH 50/95] android, desktop: fixed loading items when one was deleted (#5472) * android, desktop: fixed loading items when one was deleted * optimization * removed comment --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 5ab6906558..a488b66c8b 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 @@ -992,6 +992,10 @@ fun BoxScope.ChatItemsList( val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } + LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { + if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) + ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) + } if (!loadingMoreItems.value) { PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value) return@PreloadItems false @@ -1543,7 +1547,7 @@ private fun PreloadItemsBefore( val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) val items = chatModel.chatItems.value - if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining && items.size >= ChatPagination.INITIAL_COUNT) { + if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { lastIndexToLoadFrom = items.lastIndex } if (allowLoad.value && lastIndexToLoadFrom != null) { From c25d0ea224077cf00e453383a546b01d9ea6e759 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 9 Jan 2025 15:58:47 +0000 Subject: [PATCH 51/95] directory: option to run service as CLI (#5494) * directory: option to run service as CLI * support muting groups when joining * fix test --- apps/simplex-directory-service/Main.hs | 6 +- .../src/Directory/Options.hs | 7 ++ .../src/Directory/Service.hs | 73 ++++++++++++------- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 16 ++-- tests/Bots/DirectoryTests.hs | 1 + 6 files changed, 70 insertions(+), 37 deletions(-) diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index af9c9dd252..0c6464dbfe 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -10,6 +10,8 @@ import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do - opts@DirectoryOpts {directoryLog} <- welcomeGetOpts + opts@DirectoryOpts {directoryLog, runCLI} <- welcomeGetOpts st <- restoreDirectoryStore directoryLog - simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts + if runCLI + then directoryServiceCLI st opts + else simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 7f02a580e6..70135e4ccf 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -23,6 +23,7 @@ data DirectoryOpts = DirectoryOpts superUsers :: [KnownContact], directoryLog :: Maybe FilePath, serviceName :: T.Text, + runCLI :: Bool, searchResults :: Int, testing :: Bool } @@ -58,6 +59,11 @@ directoryOpts appDir defaultDbFileName = do <> help "The display name of the directory service bot, without *'s and spaces (SimpleX-Directory)" <> value "SimpleX-Directory" ) + runCLI <- + switch + ( long "run-cli" + <> help "Run directory service as CLI" + ) pure DirectoryOpts { coreOptions, @@ -65,6 +71,7 @@ directoryOpts appDir defaultDbFileName = do superUsers, directoryLog, serviceName = T.pack serviceName, + runCLI, searchResults = 10, testing = False } diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index d2016ff1f5..5b96603f68 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -9,6 +9,7 @@ module Directory.Service ( welcomeGetOpts, directoryService, + directoryServiceCLI, ) where @@ -36,6 +37,8 @@ import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Store.Shared (StoreError (..)) +import Simplex.Chat.Terminal (terminalChatConfig) +import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) @@ -77,33 +80,51 @@ welcomeGetOpts = do putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" pure opts +directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () +directoryServiceCLI st opts = do + env <- newServiceState + eventQ <- newTQueueIO + let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) + race_ + (simplexChatCLI' terminalChatConfig {chatHooks = defaultChatHooks {eventHook}} (mkChatOpts opts) Nothing) + (processEvents eventQ env) + where + processEvents eventQ env = forever $ do + (cc, resp) <- atomically $ readTQueue eventQ + u_ <- readTVarIO (currentUser cc) + forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp + directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO () -directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchResults, testing} user@User {userId} cc = do +directoryService st opts@DirectoryOpts {testing} user cc = do initializeBotAddress' (not testing) cc env <- newServiceState race_ (forever $ void getLine) . forever $ do (_, _, resp) <- atomically . readTBQueue $ outputQ cc - forM_ (crDirectoryEvent resp) $ \case - DEContactConnected ct -> deContactConnected ct - DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole - DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner - DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup - DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role - DEServiceRoleChanged g role -> deServiceRoleChanged g role - DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g - DEContactLeftGroup ctId g -> deContactLeftGroup ctId g - DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g - DEGroupDeleted _g -> pure () - DEUnsupportedMessage _ct _ciId -> pure () - DEItemEditIgnored _ct -> pure () - DEItemDeleteIgnored _ct -> pure () - DEContactCommand ct ciId (ADC sUser cmd) -> do - logInfo $ "command received " <> directoryCmdTag cmd - case sUser of - SDRUser -> deUserCommand env ct ciId cmd - SDRAdmin -> deAdminCommand ct ciId cmd - SDRSuperUser -> deSuperUserCommand ct ciId cmd - DELogChatResponse r -> logInfo r + directoryServiceEvent st opts env user cc resp + +directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () +directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, searchResults} ServiceState {searchRequests} user@User {userId} cc event = + forM_ (crDirectoryEvent event) $ \case + DEContactConnected ct -> deContactConnected ct + DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole + DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner + DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup + DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role + DEServiceRoleChanged g role -> deServiceRoleChanged g role + DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g + DEContactLeftGroup ctId g -> deContactLeftGroup ctId g + DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g + DEGroupDeleted _g -> pure () + DEUnsupportedMessage _ct _ciId -> pure () + DEItemEditIgnored _ct -> pure () + DEItemDeleteIgnored _ct -> pure () + DEContactCommand ct ciId (ADC sUser cmd) -> do + logInfo $ "command received " <> directoryCmdTag cmd + case sUser of + SDRUser -> deUserCommand ct ciId cmd + SDRAdmin -> deAdminCommand ct ciId cmd + SDRSuperUser -> deSuperUserCommand ct ciId cmd + DELogChatResponse r -> logInfo r where withAdminUsers action = void . forkIO $ do forM_ superUsers $ \KnownContact {contactId} -> action contactId @@ -153,7 +174,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe processInvitation :: Contact -> GroupInfo -> IO () processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do void $ addGroupReg st ct g GRSProposed - r <- sendChatCmd cc $ APIJoinGroup groupId + r <- sendChatCmd cc $ APIJoinGroup groupId MFNone sendMessage cc ct $ case r of CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" @@ -417,8 +438,8 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." - deUserCommand :: ServiceState -> Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () - deUserCommand env@ServiceState {searchRequests} ct ciId = \case + deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () + deUserCommand ct ciId = \case DCHelp -> sendMessage cc ct $ "You must be the owner to add the group to the directory:\n\ @@ -446,7 +467,7 @@ directoryService st DirectoryOpts {adminUsers, superUsers, serviceName, searchRe STRecent -> withFoundListedGroups Nothing $ sendNextSearchResults takeRecent search Nothing -> showAllGroups where - showAllGroups = deUserCommand env ct ciId DCAllGroups + showAllGroups = deUserCommand ct ciId DCAllGroups DCAllGroups -> withFoundListedGroups Nothing $ sendAllGroups takeTop "top" STAll DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent DCSubmitGroup _link -> pure () diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index f6f7416bb1..3854b0662e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -349,7 +349,7 @@ data ChatCommand | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} | APIAddMember GroupId ContactId GroupMemberRole - | APIJoinGroup GroupId + | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIMemberRole GroupId GroupMemberId GroupMemberRole | APIBlockMemberForAll GroupId GroupMemberId Bool | APIRemoveMember GroupId GroupMemberId @@ -467,7 +467,7 @@ data ChatCommand | APINewGroup UserId IncognitoEnabled GroupProfile | NewGroup IncognitoEnabled GroupProfile | AddMember GroupName ContactName GroupMemberRole - | JoinGroup GroupName + | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} | MemberRole GroupName ContactName GroupMemberRole | BlockForAll GroupName ContactName Bool | RemoveMember GroupName ContactName diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d25444a358..85dd765e7a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1924,12 +1924,12 @@ processChatCommand' vr = \case pure $ CRSentGroupInvitation user gInfo contact member {memberRole = memRole} Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName - APIJoinGroup groupId -> withUser $ \user@User {userId} -> do + APIJoinGroup groupId enableNtfs -> withUser $ \user@User {userId} -> do withGroupLock "joinGroup" groupId . procCmd $ do (invitation, ct) <- withFastStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db vr user groupId (inv,) <$> getContactViaMember db vr user fromMember - let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership, chatSettings}} = invitation GroupMember {memberId = membershipMemId} = membership Contact {activeConn} = ct case activeConn of @@ -1946,7 +1946,9 @@ processChatCommand' vr = \case withFastStore' $ \db -> do updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted - void (withAgent $ \a -> joinConnection a (aUserId user) agentConnId True connRequest dm PQSupportOff subMode) + -- MFAll is default for new groups + unless (enableNtfs == MFAll) $ updateGroupSettings db user groupId chatSettings {enableNtfs} + void (withAgent $ \a -> joinConnection a (aUserId user) agentConnId (enableNtfs /= MFNone) connRequest dm PQSupportOff subMode) `catchChatError` \e -> do withFastStore' $ \db -> do updateGroupMemberStatus db userId fromMember GSMemInvited @@ -2043,9 +2045,9 @@ processChatCommand' vr = \case AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withFastStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole - JoinGroup gName -> withUser $ \user -> do + JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIJoinGroup groupId + processChatCommand $ APIJoinGroup groupId enableNtfs MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMemberRole gId gMemberId memRole BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMemberForAll gId gMemberId blocked RemoveMember gName gMemberName -> withMemberName gName gMemberName APIRemoveMember @@ -3630,7 +3632,7 @@ chatCommandP = "/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP), "/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), - "/_join #" *> (APIJoinGroup <$> A.decimal), + "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI "/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_block #" *> (APIBlockMemberForAll <$> A.decimal <* A.space <*> A.decimal <* A.space <* "blocked=" <*> onOffP), "/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal), @@ -3712,7 +3714,7 @@ chatCommandP = ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRMember)), - ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), + ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName <*> (" mute" $> MFNone <|> pure MFAll)), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), "/block for all #" *> (BlockForAll <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), "/unblock for all #" *> (BlockForAll <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index c50bb8b02d..9775dddd5f 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -72,6 +72,7 @@ mkDirectoryOpts tmp superUsers = superUsers, directoryLog = Just $ tmp "directory_service.log", serviceName = "SimpleX-Directory", + runCLI = False, searchResults = 3, testing = True } From cd9eb66ebb1e604633cbb05484810227b985af7c Mon Sep 17 00:00:00 2001 From: Diogo Date: Thu, 9 Jan 2025 22:28:29 +0000 Subject: [PATCH 52/95] ui: remove support for inline moderation (#5495) * android: remove support for inline moderation * ios: emove support for inline moderation * fix prefix on preview for ios * unused * final pass * ios: should not be able to assign moderator * button label --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatView.swift | 205 ++------------- .../Chat/Group/GroupMemberInfoView.swift | 32 +-- .../Views/ChatList/ChatPreviewView.swift | 2 +- apps/ios/SimpleXChat/ChatTypes.swift | 13 +- .../chat/simplex/common/model/ChatModel.kt | 13 - .../simplex/common/views/chat/ChatView.kt | 72 +++--- .../views/chat/group/GroupMemberInfoView.kt | 16 +- .../common/views/chat/item/ChatItemView.kt | 238 ++---------------- .../commonMain/resources/MR/base/strings.xml | 9 +- 9 files changed, 106 insertions(+), 494 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index b74cbfbc81..3444fd0723 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1284,20 +1284,11 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if let groupInfo = chat.chatInfo.groupInfo, ci.isReport, ci.meta.itemDeleted == nil { - if ci.chatDir == .groupSnd { - deleteButton(ci) - } else { + if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { + if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator { archiveReportButton(ci) - if let qi = ci.quotedItem { - moderateReportedButton(qi, ci, groupInfo) - if let rMember = qi.memberToModerate(chat.chatInfo) { - if !rMember.blockedByAdmin, rMember.canBlockForAll(groupInfo: groupInfo) { - blockMemberButton(rMember, groupInfo, qi, ci) - } - } - } } + deleteButton(ci, label: "Delete report") } else if let mc = ci.content.msgContent, !ci.isReport, ci.meta.itemDeleted == nil || revealed { if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, availableReactions.count > 0 { @@ -1351,7 +1342,7 @@ struct ChatView: View { if ci.chatDir != .groupSnd { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) - } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole < .moderator, !live, composeState.voiceMessageRecordingState == .noRecording { + } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { reportButton(ci) } } @@ -1627,7 +1618,7 @@ struct ChatView: View { } } - private func deleteButton(_ ci: ChatItem) -> Button { + private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button { Button(role: .destructive) { if !revealed, let currIndex = m.getChatItemIndex(ci), @@ -1649,10 +1640,7 @@ struct ChatView: View { deletingItem = ci } } label: { - Label( - NSLocalizedString("Delete", comment: "chat item action"), - systemImage: "trash" - ) + Label(label, systemImage: "trash") } } @@ -1668,31 +1656,19 @@ struct ChatView: View { private func moderateButton(_ ci: ChatItem, _ groupInfo: GroupInfo) -> Button { Button(role: .destructive) { - showModerateMessageAlert(groupInfo) { - deletingItem = ci - deleteMessage(.cidmBroadcast, moderate: true) - } - } label: { - Label( - NSLocalizedString("Moderate", comment: "chat item action"), - systemImage: "flag" - ) - } - } - - private func moderateReportedButton(_ rItem: CIQuote, _ reportItem: ChatItem, _ groupInfo: GroupInfo) -> Button { - Button(role: .destructive) { - showModerateMessageAlert(groupInfo) { - Task { - let deleted = await deleteReportedMessage(rItem, reportItem.id, groupInfo) - if deleted != nil { - await MainActor.run { - deletingItem = reportItem - deleteMessage(.cidmInternalMark, moderate: false) - } - } - } - } + AlertManager.shared.showAlert(Alert( + title: Text("Delete member message?"), + message: Text( + groupInfo.fullGroupPreferences.fullDelete.on + ? "The message will be deleted for all members." + : "The message will be marked as moderated for all members." + ), + primaryButton: .destructive(Text("Delete")) { + deletingItem = ci + deleteMessage(.cidmBroadcast, moderate: true) + }, + secondaryButton: .cancel() + )) } label: { Label( NSLocalizedString("Moderate", comment: "chat item action"), @@ -1715,74 +1691,7 @@ struct ChatView: View { ) ) } label: { - Label( - NSLocalizedString("Archive", comment: "chat item action"), - systemImage: "archivebox" - ) - } - } - - private func blockMemberButton(_ member: GroupMember, _ groupInfo: GroupInfo, _ rItem: CIQuote, _ report: ChatItem) -> Button { - Button(role: .destructive) { - actionSheet = SomeActionSheet( - actionSheet: ActionSheet( - title: Text("Block and moderate?"), - buttons: [ - .destructive(Text("Block and moderate")) { - AlertManager.shared.showAlert( - Alert( - title: Text("Delete member message and block?"), - message: Text( - NSLocalizedString( - groupInfo.fullGroupPreferences.fullDelete.on - ? "The message will be deleted for all members.\nAll new messages from \(member.chatViewName) will be hidden!" - : "The message will be marked as moderated for all members.\n All new messages from \(member.chatViewName) will be hidden!" - , comment: "block and moderate action" - ) - ), - primaryButton: .destructive(Text("Delete and block")) { - Task { - let deleted = await deleteReportedMessage(rItem, report.id, groupInfo) - if deleted != nil { - let blocked = await blockMemberForAll(groupInfo, member, true) - - if blocked != nil { - await MainActor.run { - deletingItem = report - deleteMessage(.cidmInternalMark, moderate: false) - } - } - } - } - }, - secondaryButton: .cancel() - ) - ) - }, - .destructive(Text("Only block")) { - Task { - if (await getLocalIdForReportedMessage(rItem, report.id, groupInfo)) != nil { - AlertManager.shared.showAlert( - blockForAllAlert(groupInfo, member) { - deletingItem = report - deleteMessage(.cidmInternalMark, moderate: false) - } - ) - } else { - showNoMessageMessageAlert() - } - } - }, - .cancel() - ] - ), - id: "blockMember" - ) - } label: { - Label( - NSLocalizedString("Block member", comment: "chat item action"), - systemImage: "hand.raised" - ) + Label("Archive report", systemImage: "archivebox") } } @@ -1886,60 +1795,6 @@ struct ChatView: View { itemIds.forEach { selectedChatItems?.remove($0) } } } - - private func deleteReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> ChatItemDeletion? { - do { - let itemId = await getLocalIdForReportedMessage(rItem, reportId, groupInfo) - - if let itemId = itemId { - let deletedItem = try await apiDeleteMemberChatItems( - groupId: groupInfo.apiId, - itemIds: [itemId] - ).first - - if let di = deletedItem { - await MainActor.run { - if let toItem = di.toChatItem { - _ = m.upsertChatItem(chat.chatInfo, toItem.chatItem) - } else { - m.removeChatItem(chat.chatInfo, di.deletedChatItem.chatItem) - } - } - - return di - } - } else { - showNoMessageMessageAlert() - } - } catch { - logger.error("ChatView.deleteReportedMessage error: \(error)") - AlertManager.shared.showAlertMsg(title: LocalizedStringKey("Error"), message: LocalizedStringKey("Failed to delete reported message")) - } - - return nil - } - - private func getLocalIdForReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> Int64? { - do { - if let itemId = rItem.itemId { - return itemId - } else { - let reportItem = try await apiGetChatItems( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - pagination: .around(chatItemId: reportId, count: 0) - ).first - - if let itemId = reportItem?.quotedItem?.itemId { - return itemId - } - } - } catch { - logger.error("ChatView.getLocalIdForReportedMessage error: \(error)") - } - - return nil - } private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) { logger.debug("ChatView deleteMessage") @@ -2014,26 +1869,6 @@ struct ChatView: View { } } -private func showModerateMessageAlert(_ groupInfo: GroupInfo, _ onModerate: @escaping () -> Void) { - AlertManager.shared.showAlert(Alert( - title: Text("Delete member message?"), - message: Text( - groupInfo.fullGroupPreferences.fullDelete.on - ? "The message will be deleted for all members." - : "The message will be marked as moderated for all members." - ), - primaryButton: .destructive(Text("Delete"), action: onModerate), - secondaryButton: .cancel() - )) -} - -private func showNoMessageMessageAlert() { - AlertManager.shared.showAlertMsg( - title: LocalizedStringKey("No message"), - message: LocalizedStringKey("This message was deleted or not received yet.") - ) -} - private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 58e22e63a2..78ea394caf 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -764,18 +764,12 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet } } -func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember, _ onBlocked: (() -> Void)? = nil) -> Alert { +func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { Alert( title: Text("Block member for all?"), message: Text("All new messages from \(mem.chatViewName) will be hidden!"), primaryButton: .destructive(Text("Block for all")) { - Task { - let uMember = await blockMemberForAll(gInfo, mem, true) - - if uMember != nil { - onBlocked?() - } - } + blockMemberForAll(gInfo, mem, true) }, secondaryButton: .cancel() ) @@ -786,25 +780,23 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { title: Text("Unblock member for all?"), message: Text("Messages from \(mem.chatViewName) will be shown!"), primaryButton: .default(Text("Unblock for all")) { - Task { - await blockMemberForAll(gInfo, mem, false) - } + blockMemberForAll(gInfo, mem, false) }, secondaryButton: .cancel() ) } -func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) async -> GroupMember? { - do { - let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) - await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) +func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { + Task { + do { + let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + } + } catch let error { + logger.error("apiBlockMemberForAll error: \(responseError(error))") } - return updatedMember - } catch let error { - logger.error("apiBlockMemberForAll error: \(responseError(error))") } - return nil } struct GroupMemberInfoView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index a311db7d50..ff5fb2986b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -277,7 +277,7 @@ struct ChatPreviewView: View { func prefix() -> Text { switch cItem.content.msgContent { - case let .report(text, reason): return Text(!text.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) + case let .report(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) default: return Text("") } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 2638c56776..f64a1076a5 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2078,7 +2078,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { if !canBeRemoved(groupInfo: groupInfo) { return nil } let userRole = groupInfo.membership.memberRole - return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .author } + return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { @@ -3337,17 +3337,6 @@ public struct CIQuote: Decodable, ItemContent, Hashable { } return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } - - public func memberToModerate(_ chatInfo: ChatInfo) -> GroupMember? { - switch (chatInfo, chatDir) { - case let (.group(groupInfo), .groupRcv(groupMember)): - let m = groupInfo.membership - return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole - ? groupMember - : nil - default: return nil - } - } } public struct CIReactionCount: Decodable, Hashable { 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 5b05d033c8..96f12b9ce9 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 @@ -2958,19 +2958,6 @@ class CIQuote ( null -> null } - fun memberToModerate(chatInfo: ChatInfo): GroupMember? { - return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { - val m = chatInfo.groupInfo.membership - if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole) { - chatDir.groupMember - } else { - null - } - } else { - null - } - } - companion object { fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote = CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text)) 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 a488b66c8b..64b7cfe9a1 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 @@ -301,41 +301,41 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } }, deleteMessage = { itemId, mode -> - val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } - val toModerate = toDeleteItem?.memberToModerate(chatInfo) - val groupInfo = toModerate?.first - val groupMember = toModerate?.second - val deletedChatItem: ChatItem? - val toChatItem: ChatItem? - val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { - chatModel.controller.apiDeleteMemberChatItems( - chatRh, - groupId = groupInfo.groupId, - itemIds = listOf(itemId) - ) - } else { - chatModel.controller.apiDeleteChatItems( - chatRh, - type = chatInfo.chatType, - id = chatInfo.apiId, - itemIds = listOf(itemId), - mode = mode - ) - } - val deleted = r?.firstOrNull() - if (deleted != null) { - deletedChatItem = deleted.deletedChatItem.chatItem - toChatItem = deleted.toChatItem?.chatItem - withChats { - if (toChatItem != null) { - upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - removeChatItem(chatRh, chatInfo, deletedChatItem) + withBGApi { + val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } + val toModerate = toDeleteItem?.memberToModerate(chatInfo) + val groupInfo = toModerate?.first + val groupMember = toModerate?.second + val deletedChatItem: ChatItem? + val toChatItem: ChatItem? + val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { + chatModel.controller.apiDeleteMemberChatItems( + chatRh, + groupId = groupInfo.groupId, + itemIds = listOf(itemId) + ) + } else { + chatModel.controller.apiDeleteChatItems( + chatRh, + type = chatInfo.chatType, + id = chatInfo.apiId, + itemIds = listOf(itemId), + mode = mode + ) + } + val deleted = r?.firstOrNull() + if (deleted != null) { + deletedChatItem = deleted.deletedChatItem.chatItem + toChatItem = deleted.toChatItem?.chatItem + withChats { + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, deletedChatItem) + } } } } - - deleted }, deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) }, receiveFile = { fileId -> @@ -599,7 +599,7 @@ fun ChatLayout( info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, + deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -946,7 +946,7 @@ fun BoxScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, + deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -2438,7 +2438,7 @@ fun PreviewChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> null }, + deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -2511,7 +2511,7 @@ fun PreviewGroupChatLayout() { info = {}, showMemberInfo = { _, _ -> }, loadMessages = { _, _, _, _ -> }, - deleteMessage = { _, _ -> null }, + deleteMessage = { _, _ -> }, deleteMessages = {}, receiveFile = { _ -> }, cancelFile = {}, 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 a064058533..760f340851 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 @@ -747,13 +747,13 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem } } -fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember, blockMember: () -> Unit = { withBGApi { blockMemberForAll(rhId, gInfo, mem, true) } }) { +fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.block_for_all_question), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { - blockMember() + blockMemberForAll(rhId, gInfo, mem, true) }, destructive = true, ) @@ -765,15 +765,17 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { - withBGApi { blockMemberForAll(rhId, gInfo, mem, false) } + blockMemberForAll(rhId, gInfo, mem, false) }, ) } -suspend fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { - val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) - withChats { - upsertGroupMember(rhId, gInfo, updatedMember) +fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { + withBGApi { + val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) + withChats { + upsertGroupMember(rhId, gInfo, updatedMember) + } } } 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 1a094f613a..58e4a31840 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 @@ -27,12 +27,9 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.currentUser -import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* -import chat.simplex.common.views.chat.group.blockForAllAlert -import chat.simplex.common.views.chat.group.blockMemberForAll import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -77,7 +74,7 @@ fun ChatItemView( selectedChatItems: MutableState?>, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?, + deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, @@ -113,12 +110,6 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value - val deleteMessageAsync: (Long, CIDeleteMode) -> Unit = { id, mode -> - withBGApi { - deleteMessage(id, mode) - } - } - Box( modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, contentAlignment = alignment, @@ -293,7 +284,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -308,31 +299,12 @@ fun ChatItemView( // cItem.id check is a special case for live message chat item which has negative ID while not sent yet cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { DefaultDropdownMenu(showMenu) { - if (cItem.chatDir is CIDirection.GroupSnd) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) - } else { - ArchiveReportItemAction(cItem, showMenu, deleteMessageAsync) - val qItem = cItem.quotedItem - if (qItem != null) { - ModerateReportItemAction(rhId, cInfo, cItem, qItem, showMenu, deleteMessage) - val rMember = qItem.memberToModerate(cInfo) - if (rMember != null && !rMember.blockedByAdmin && rMember.canBlockForAll(cInfo.groupInfo)) { - BlockMemberAction( - rhId, - chatInfo = cInfo, - groupInfo = cInfo.groupInfo, - cItem = cItem, - reportedItem = qItem, - member = rMember, - showMenu = showMenu, - deleteMessage = deleteMessage - ) - } - } - - Divider() - SelectItemAction(showMenu, selectChatItem) + if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveReportItemAction(cItem, showMenu, deleteMessage) } + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + Divider() + SelectItemAction(showMenu, selectChatItem) } } cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { @@ -421,13 +393,13 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } if (cItem.chatDir !is CIDirection.GroupSnd) { val groupInfo = cItem.memberToModerate(cInfo)?.first if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessageAsync) - } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole < GroupMemberRole.Moderator && !live) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { ReportItemAction(cItem, composeState, showMenu) } } @@ -447,7 +419,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -457,7 +429,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -471,7 +443,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -480,7 +452,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -497,7 +469,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -531,7 +503,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -588,7 +560,7 @@ fun ChatItemView( MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessageAsync, deleteMessages) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -772,9 +744,10 @@ fun DeleteItemAction( questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, + buttonText: String = stringResource(MR.strings.delete_verb), ) { ItemAction( - stringResource(MR.strings.delete_verb), + buttonText, painterResource(MR.images.ic_delete), onClick = { showMenu.value = false @@ -822,7 +795,7 @@ fun ModerateItemAction( painterResource(MR.images.ic_flag), onClick = { showMenu.value = false - moderateMessageAlertDialog(cItem.id, questionText, deleteMessage = deleteMessage) + moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) }, color = Color.Red ) @@ -937,120 +910,10 @@ private fun ReportItemAction( ) } -@Composable -private fun ModerateReportItemAction( - rhId: Long?, - chatInfo: ChatInfo, - cItem: ChatItem, - reportedItem: CIQuote, - showMenu: MutableState, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? -) { - ItemAction( - stringResource(MR.strings.moderate_verb), - painterResource(MR.images.ic_flag), - onClick = { - withBGApi { - val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) - if (reportedMessageId != null) { - moderateMessageAlertDialog( - reportedMessageId, - questionText = moderateMessageQuestionText(chatInfo.featureEnabled(ChatFeature.FullDelete), 1), - deleteMessage = { id, m -> - withApi { - val deleted = deleteMessage(id, m) - if (deleted != null) { - deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark) - } - } - }, - ) - } - } - showMenu.value = false - }, - color = Color.Red - ) -} - -@Composable -private fun BlockMemberAction( - rhId: Long?, - chatInfo: ChatInfo, - groupInfo: GroupInfo, - cItem: ChatItem, - reportedItem: CIQuote, - member: GroupMember, - showMenu: MutableState, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? -) { - ItemAction( - stringResource(MR.strings.block_member_button), - painterResource(MR.images.ic_back_hand), - onClick = { - AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.report_block_and_moderate_title), - buttons = { - SectionItemView({ - AlertManager.shared.hideAlert() - withBGApi { - val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) - if (reportedMessageId != null) { - blockAndModerateAlertDialog( - rhId, - reportedMessageId = reportedMessageId, - reportId = cItem.id, - gInfo = groupInfo, - mem = member, - deleteMessage = deleteMessage, - ) - } - } - }) { - Text(generalGetString(MR.strings.report_block_and_moderate_block_and_moderate_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - SectionItemView({ - AlertManager.shared.hideAlert() - withBGApi { - val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id) - if (reportedMessageId != null) { - blockForAllAlert(rhId, gInfo = groupInfo, mem = member, blockMember = { - withBGApi { - try { - blockMemberForAll( - rhId, - gInfo = groupInfo, - member = member, - blocked = true - ) - deleteMessage(reportedMessageId, CIDeleteMode.cidmInternalMark) - } catch (ex: Exception) { - Log.e(TAG, "BlockMemberAction block and moderate ${ex.message}") - } - } - }) - } - } - }) { - Text(generalGetString(MR.strings.report_block_and_moderate_only_block_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - ) - showMenu.value = false - }, - color = Color.Red - ) -} - @Composable private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState, deleteMessage: (Long, CIDeleteMode) -> Unit) { ItemAction( - stringResource(MR.strings.archive_verb), + stringResource(MR.strings.archive_report), painterResource(MR.images.ic_inventory_2), onClick = { AlertManager.shared.showAlertDialog( @@ -1401,14 +1264,14 @@ fun moderateMessageQuestionText(fullDeleteAllowed: Boolean, count: Int): String } } -fun moderateMessageAlertDialog(chatItemId: Long, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { +fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), text = questionText, confirmText = generalGetString(MR.strings.delete_verb), destructive = true, onConfirm = { - deleteMessage(chatItemId, CIDeleteMode.cidmBroadcast) + deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) } ) } @@ -1423,59 +1286,8 @@ fun moderateMessagesAlertDialog(itemIds: List, questionText: String, delet ) } -private fun blockAndModerateAlertDialog( - rhId: Long?, - reportedMessageId: Long, - reportId: Long, - gInfo: GroupInfo, - mem: GroupMember, - deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion? -) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.report_block_and_moderate_confirmation_title), - text = generalGetString( - if (gInfo.fullGroupPreferences.fullDelete.on) MR.strings.report_block_and_moderate_confirmation_desc_full_delete else MR.strings.report_block_and_moderate_confirmation_desc_full_delete).format(mem.chatViewName), - confirmText = generalGetString(MR.strings.report_block_and_moderate_confirmation_ok), - onConfirm = { - withBGApi { - try { - val deleted = deleteMessage(reportedMessageId, CIDeleteMode.cidmBroadcast) - if (deleted != null) { - blockMemberForAll(rhId, gInfo, mem, true) - deleteMessage(reportId, CIDeleteMode.cidmInternalMark) - } - } catch (ex: Exception) { - Log.e(TAG, "blockAndModerateAlertDialog block and moderate ${ex.message}") - } - } - }, - destructive = true, - ) -} - expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) -private suspend fun getLocalIdForReportedMessage( - rhId: Long?, - chatInfo: ChatInfo, - reportedItem: CIQuote, - itemId: Long): Long? { - if (reportedItem.itemId != null) { - return reportedItem.itemId - } - val item = apiLoadSingleMessage(rhId, chatInfo.chatType, chatInfo.apiId, itemId) - - if (item?.quotedItem?.itemId != null) { - withChats { - updateChatItem(chatInfo, item) - } - return item.quotedItem.itemId - } else { - showQuotedItemDoesNotExistAlert() - return null - } -} - @Preview @Composable fun PreviewChatItemView( @@ -1493,7 +1305,7 @@ fun PreviewChatItemView( range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> null }, + deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, @@ -1539,7 +1351,7 @@ fun PreviewChatItemViewDeletedContent() { range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, selectChatItem = {}, - deleteMessage = { _, _ -> null }, + deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, cancelFile = {}, 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 860975a414..c9c0f00555 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -305,13 +305,6 @@ Report reason? Archive report? The report will be archived for you. - Block and moderate? - Block and moderate - Only block - Delete member message and block? - The message will be deleted for all members.\nAll new messages from %1$s will be hidden! - The message will be marked as moderated for all members.\nAll new messages from %1$s will be hidden! - Delete and block Error: %1$s @@ -338,6 +331,8 @@ Info Search Archive + Archive report + Delete report Sent message Received message History From e05a35e26ef975ced39cc45c435ad4cf9f3c9695 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 10 Jan 2025 15:27:29 +0400 Subject: [PATCH 53/95] core: support postgres backend (#5403) * postgres: modules structure (#5401) * postgres: schema, field conversions (#5430) * postgres: rework chat list pagination query (#5441) * prepare cabal for merge * restore cabal changes * simplexmq * postgres: implementation wip (tests don't pass) (#5481) * restore ios file * postgres: implementation - tests pass (#5487) * refactor DB options * refactor * line * style * style * refactor * $ * update simplexmq * constraintError * handleDBErrors * fix * remove param * Ok * case * case * case * comment --------- Co-authored-by: Evgeny Poberezkin --- apps/simplex-bot-advanced/Main.hs | 4 +- apps/simplex-bot/Main.hs | 4 +- .../src/Broadcast/Bot.hs | 4 +- .../src/Broadcast/Options.hs | 8 +- .../src/Directory/Options.hs | 10 +- .../src/Directory/Service.hs | 4 +- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 299 ++--- src/Simplex/Chat.hs | 25 +- src/Simplex/Chat/Archive.hs | 3 +- src/Simplex/Chat/Call.hs | 21 +- src/Simplex/Chat/Controller.hs | 46 +- src/Simplex/Chat/Core.hs | 6 +- src/Simplex/Chat/Library/Commands.hs | 47 +- src/Simplex/Chat/Library/Internal.hs | 2 +- src/Simplex/Chat/Library/Subscriber.hs | 4 +- src/Simplex/Chat/Messages.hs | 10 +- src/Simplex/Chat/Messages/CIContent.hs | 10 +- src/Simplex/Chat/Mobile.hs | 14 +- src/Simplex/Chat/Operators.hs | 25 +- src/Simplex/Chat/Options.hs | 53 +- src/Simplex/Chat/Options/DB.hs | 14 + src/Simplex/Chat/Options/Postgres.hs | 37 + src/Simplex/Chat/Options/SQLite.hs | 44 + src/Simplex/Chat/Protocol.hs | 32 +- src/Simplex/Chat/Remote.hs | 8 +- src/Simplex/Chat/Store.hs | 34 +- src/Simplex/Chat/Store/AppSettings.hs | 9 +- src/Simplex/Chat/Store/Connections.hs | 73 +- src/Simplex/Chat/Store/Direct.hs | 184 +-- src/Simplex/Chat/Store/Files.hs | 54 +- src/Simplex/Chat/Store/Groups.hs | 342 +++--- src/Simplex/Chat/Store/Messages.hs | 328 +++--- src/Simplex/Chat/Store/NoteFolders.hs | 17 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 19 + .../Postgres/Migrations/M20241220_initial.hs | 1012 +++++++++++++++++ src/Simplex/Chat/Store/Profiles.hs | 109 +- src/Simplex/Chat/Store/Remote.hs | 19 +- .../Chat/Store/{ => SQLite}/Migrations.hs | 240 ++-- .../SQLite}/Migrations/M20220101_initial.hs | 2 +- .../SQLite}/Migrations/M20220122_v1_1.hs | 2 +- .../Migrations/M20220205_chat_item_status.hs | 2 +- .../M20220210_deduplicate_contact_requests.hs | 2 +- .../Migrations/M20220224_messages_fks.hs | 2 +- .../Migrations/M20220301_smp_servers.hs | 2 +- .../Migrations/M20220302_profile_images.hs | 2 +- .../Migrations/M20220304_msg_quotes.hs | 2 +- .../Migrations/M20220321_chat_item_edited.hs | 2 +- .../M20220404_files_status_fields.hs | 2 +- .../Migrations/M20220514_profiles_user_id.hs | 2 +- .../Migrations/M20220626_auto_reply.hs | 2 +- .../SQLite}/Migrations/M20220702_calls.hs | 2 +- .../M20220715_groups_chat_item_id.hs | 2 +- .../M20220811_chat_items_indices.hs | 2 +- .../M20220812_incognito_profiles.hs | 2 +- .../M20220818_chat_notifications.hs | 2 +- ...groups_host_conn_custom_user_profile_id.hs | 2 +- ...23_delete_broken_group_event_chat_items.hs | 2 +- .../M20220824_profiles_local_alias.hs | 2 +- .../SQLite}/Migrations/M20220909_commands.hs | 2 +- .../Migrations/M20220926_connection_alias.hs | 2 +- .../SQLite}/Migrations/M20220928_settings.hs | 2 +- .../M20221001_shared_msg_id_indices.hs | 2 +- ...elete_broken_integrity_error_chat_items.hs | 2 +- ...M20221004_idx_msg_deliveries_message_id.hs | 2 +- .../M20221011_user_contact_links_group_id.hs | 2 +- .../Migrations/M20221012_inline_files.hs | 2 +- .../Migrations/M20221019_unread_chat.hs | 2 +- .../M20221021_auto_accept__group_links.hs | 2 +- .../Migrations/M20221024_contact_used.hs | 2 +- .../Migrations/M20221025_chat_settings.hs | 2 +- .../Migrations/M20221029_group_link_id.hs | 2 +- .../Migrations/M20221112_server_password.hs | 2 +- .../Migrations/M20221115_server_cfg.hs | 2 +- .../M20221129_delete_group_feature_items.hs | 2 +- .../M20221130_delete_item_deleted.hs | 2 +- .../M20221209_verified_connection.hs | 2 +- .../SQLite}/Migrations/M20221210_idxs.hs | 2 +- .../Migrations/M20221211_group_description.hs | 2 +- .../Migrations/M20221212_chat_items_timed.hs | 2 +- .../Migrations/M20221214_live_message.hs | 2 +- .../SQLite}/Migrations/M20221222_chat_ts.hs | 2 +- .../M20221223_idx_chat_items_item_status.hs | 2 +- .../SQLite}/Migrations/M20221230_idxs.hs | 2 +- .../M20230107_connections_auth_err_counter.hs | 2 +- .../M20230111_users_agent_user_id.hs | 2 +- .../Migrations/M20230117_fkey_indexes.hs | 2 +- .../M20230118_recreate_smp_servers.hs | 2 +- .../M20230129_drop_chat_items_group_idx.hs | 2 +- ...0230206_item_deleted_by_group_member_id.hs | 2 +- .../Migrations/M20230303_group_link_role.hs | 2 +- .../Migrations/M20230317_hidden_profiles.hs | 2 +- .../Migrations/M20230318_file_description.hs | 2 +- .../M20230321_agent_file_deleted.hs | 2 +- .../Migrations/M20230328_files_protocol.hs | 2 +- .../Migrations/M20230402_protocol_servers.hs | 2 +- .../M20230411_extra_xftp_file_descriptions.hs | 2 +- .../M20230420_rcv_files_to_receive.hs | 2 +- .../M20230422_profile_contact_links.hs | 2 +- ...te_msg_delivery_events_cleanup_messages.hs | 2 +- .../M20230505_chat_item_versions.hs | 2 +- .../SQLite}/Migrations/M20230511_reactions.hs | 2 +- .../Migrations/M20230519_item_deleted_ts.hs | 2 +- .../SQLite}/Migrations/M20230526_indexes.hs | 2 +- .../SQLite}/Migrations/M20230529_indexes.hs | 2 +- .../Migrations/M20230608_deleted_contacts.hs | 2 +- .../Migrations/M20230618_favorite_chats.hs | 2 +- .../M20230621_chat_item_moderations.hs | 2 +- .../Migrations/M20230705_delivery_receipts.hs | 2 +- .../M20230721_group_snd_item_statuses.hs | 2 +- .../SQLite}/Migrations/M20230814_indexes.hs | 2 +- .../Migrations/M20230827_file_encryption.hs | 2 +- .../M20230829_connections_chat_vrange.hs | 2 +- .../M20230903_connections_to_subscribe.hs | 2 +- .../Migrations/M20230913_member_contacts.hs | 2 +- .../Migrations/M20230914_member_probes.hs | 2 +- .../Migrations/M20230926_contact_status.hs | 2 +- .../Migrations/M20231002_conn_initiated.hs | 2 +- .../M20231009_via_group_link_uri_hash.hs | 2 +- .../Migrations/M20231010_member_settings.hs | 2 +- .../SQLite}/Migrations/M20231019_indexes.hs | 2 +- .../M20231030_xgrplinkmem_received.hs | 2 +- .../SQLite}/Migrations/M20231107_indexes.hs | 2 +- .../Migrations/M20231113_group_forward.hs | 2 +- .../Migrations/M20231114_remote_control.hs | 2 +- .../M20231126_remote_ctrl_address.hs | 2 +- .../M20231207_chat_list_pagination.hs | 2 +- .../Migrations/M20231214_item_content_tag.hs | 2 +- .../M20231215_recreate_msg_deliveries.hs | 2 +- .../Migrations/M20240102_note_folders.hs | 2 +- .../M20240104_members_profile_update.hs | 2 +- .../M20240115_block_member_for_all.hs | 2 +- .../SQLite}/Migrations/M20240122_indexes.hs | 2 +- .../Migrations/M20240214_redirect_file_id.hs | 2 +- .../Migrations/M20240222_app_settings.hs | 2 +- .../Migrations/M20240226_users_restrict.hs | 2 +- .../SQLite}/Migrations/M20240228_pq.hs | 2 +- .../M20240313_drop_agent_ack_cmd_id.hs | 2 +- .../Migrations/M20240324_custom_data.hs | 2 +- .../Migrations/M20240402_item_forwarded.hs | 2 +- .../SQLite}/Migrations/M20240430_ui_theme.hs | 2 +- .../Migrations/M20240501_chat_deleted.hs | 2 +- .../M20240510_chat_items_via_proxy.hs | 2 +- ...20240515_rcv_files_user_approved_relays.hs | 2 +- .../Migrations/M20240528_quota_err_counter.hs | 2 +- .../Migrations/M20240827_calls_uuid.hs | 2 +- .../Migrations/M20240920_user_order.hs | 2 +- .../SQLite}/Migrations/M20241008_indexes.hs | 2 +- .../M20241010_contact_requests_contact_id.hs | 2 +- .../M20241023_chat_item_autoincrement_id.hs | 2 +- .../Migrations/M20241027_server_operators.hs | 2 +- .../SQLite}/Migrations/M20241125_indexes.hs | 2 +- .../Migrations/M20241128_business_chats.hs | 2 +- .../M20241205_business_chat_members.hs | 2 +- .../M20241222_operator_conditions.hs | 2 +- .../SQLite}/Migrations/M20241223_chat_tags.hs | 2 +- .../SQLite}/Migrations/M20241230_reports.hs | 2 +- .../SQLite}/Migrations/M20250105_indexes.hs | 2 +- .../SQLite}/Migrations/chat_lint.sql | 0 .../SQLite}/Migrations/chat_schema.sql | 0 src/Simplex/Chat/Store/Shared.hs | 92 +- src/Simplex/Chat/Terminal.hs | 30 +- src/Simplex/Chat/Terminal/Input.hs | 17 +- src/Simplex/Chat/Terminal/Main.hs | 5 +- src/Simplex/Chat/Types.hs | 97 +- src/Simplex/Chat/Types/Preferences.hs | 17 +- src/Simplex/Chat/Types/Shared.hs | 14 +- src/Simplex/Chat/Types/UITheme.hs | 10 +- src/Simplex/Chat/Types/Util.hs | 16 - src/Simplex/Chat/View.hs | 14 +- tests/Bots/BroadcastTests.hs | 18 +- tests/Bots/DirectoryTests.hs | 14 +- tests/ChatClient.hs | 83 +- tests/ChatTests/ChatList.hs | 4 +- tests/ChatTests/Direct.hs | 23 +- tests/ChatTests/Forward.hs | 6 +- tests/ChatTests/Groups.hs | 18 +- tests/ChatTests/Local.hs | 20 +- tests/ChatTests/Profiles.hs | 16 +- tests/ChatTests/Utils.hs | 16 +- tests/JSONFixtures.hs | 62 + tests/JSONTests.hs | 2 +- tests/MobileTests.hs | 58 +- tests/RemoteTests.hs | 6 +- tests/SchemaDump.hs | 6 +- tests/Test.hs | 58 +- 187 files changed, 2847 insertions(+), 1291 deletions(-) create mode 100644 src/Simplex/Chat/Options/DB.hs create mode 100644 src/Simplex/Chat/Options/Postgres.hs create mode 100644 src/Simplex/Chat/Options/SQLite.hs create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations.hs create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs rename src/Simplex/Chat/Store/{ => SQLite}/Migrations.hs (56%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220101_initial.hs (99%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220122_v1_1.hs (99%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220205_chat_item_status.hs (86%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220210_deduplicate_contact_requests.hs (91%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220224_messages_fks.hs (84%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220301_smp_servers.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220302_profile_images.hs (79%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220304_msg_quotes.hs (94%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220321_chat_item_edited.hs (76%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220404_files_status_fields.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220514_profiles_user_id.hs (88%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220626_auto_reply.hs (85%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220702_calls.hs (91%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220715_groups_chat_item_id.hs (78%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220811_chat_items_indices.hs (82%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220812_incognito_profiles.hs (89%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220818_chat_notifications.hs (78%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs (80%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220823_delete_broken_group_event_chat_items.hs (80%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220824_profiles_local_alias.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220909_commands.hs (91%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220926_connection_alias.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20220928_settings.hs (86%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221001_shared_msg_id_indices.hs (85%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs (75%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221004_idx_msg_deliveries_message_id.hs (76%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221011_user_contact_links_group_id.hs (81%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221012_inline_files.hs (92%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221019_unread_chat.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221021_auto_accept__group_links.hs (88%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221024_contact_used.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221025_chat_settings.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221029_group_link_id.hs (80%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221112_server_password.hs (75%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221115_server_cfg.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221129_delete_group_feature_items.hs (80%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221130_delete_item_deleted.hs (78%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221209_verified_connection.hs (80%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221210_idxs.hs (85%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221211_group_description.hs (76%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221212_chat_items_timed.hs (82%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221214_live_message.hs (75%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221222_chat_ts.hs (81%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221223_idx_chat_items_item_status.hs (75%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20221230_idxs.hs (82%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230107_connections_auth_err_counter.hs (82%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230111_users_agent_user_id.hs (82%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230117_fkey_indexes.hs (98%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230118_recreate_smp_servers.hs (93%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230129_drop_chat_items_group_idx.hs (73%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230206_item_deleted_by_group_member_id.hs (82%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230303_group_link_role.hs (78%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230317_hidden_profiles.hs (88%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230318_file_description.hs (95%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230321_agent_file_deleted.hs (90%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230328_files_protocol.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230402_protocol_servers.hs (86%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230411_extra_xftp_file_descriptions.hs (92%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230420_rcv_files_to_receive.hs (82%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230422_profile_contact_links.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs (92%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230505_chat_item_versions.hs (91%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230511_reactions.hs (96%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230519_item_deleted_ts.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230526_indexes.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230529_indexes.hs (91%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230608_deleted_contacts.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230618_favorite_chats.hs (86%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230621_chat_item_moderations.hs (95%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230705_delivery_receipts.hs (90%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230721_group_snd_item_statuses.hs (92%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230814_indexes.hs (84%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230827_file_encryption.hs (86%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230829_connections_chat_vrange.hs (91%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230903_connections_to_subscribe.hs (86%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230913_member_contacts.hs (90%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230914_member_probes.hs (98%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20230926_contact_status.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231002_conn_initiated.hs (91%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231009_via_group_link_uri_hash.hs (88%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231010_member_settings.hs (84%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231019_indexes.hs (93%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231030_xgrplinkmem_received.hs (84%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231107_indexes.hs (84%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231113_group_forward.hs (97%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231114_remote_control.hs (95%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231126_remote_ctrl_address.hs (88%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231207_chat_list_pagination.hs (92%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231214_item_content_tag.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20231215_recreate_msg_deliveries.hs (98%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240102_note_folders.hs (94%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240104_members_profile_update.hs (86%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240115_block_member_for_all.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240122_indexes.hs (93%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240214_redirect_file_id.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240222_app_settings.hs (82%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240226_users_restrict.hs (89%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240228_pq.hs (93%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240313_drop_agent_ack_cmd_id.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240324_custom_data.hs (85%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240402_item_forwarded.hs (95%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240430_ui_theme.hs (88%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240501_chat_deleted.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240510_chat_items_via_proxy.hs (86%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240515_rcv_files_user_approved_relays.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240528_quota_err_counter.hs (84%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240827_calls_uuid.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20240920_user_order.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241008_indexes.hs (84%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241010_contact_requests_contact_id.hs (87%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241023_chat_item_autoincrement_id.hs (90%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241027_server_operators.hs (96%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241125_indexes.hs (95%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241128_business_chats.hs (91%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241205_business_chat_members.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241222_operator_conditions.hs (84%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241223_chat_tags.hs (95%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20241230_reports.hs (83%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/M20250105_indexes.hs (90%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/chat_lint.sql (100%) rename src/Simplex/Chat/{ => Store/SQLite}/Migrations/chat_schema.sql (100%) create mode 100644 tests/JSONFixtures.hs diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index cedbd4fe34..6c3d8240e4 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -31,9 +31,9 @@ main = do welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getChatOpts appDir "simplex_bot" + opts@ChatOpts {coreOptions} <- getChatOpts appDir "simplex_bot" putStrLn $ "SimpleX Chat Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + printDbOpts coreOptions pure opts welcomeMessage :: Text diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index c24f9c251f..290e6286b1 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -25,7 +25,7 @@ welcomeMessage = "Hello! I am a simple squaring bot.\nIf you send me a number, I welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getChatOpts appDir "simplex_bot" + opts@ChatOpts {coreOptions} <- getChatOpts appDir "simplex_bot" putStrLn $ "SimpleX Chat Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + printDbOpts coreOptions pure opts diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs index c526d64886..9dc927af9e 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -27,9 +27,9 @@ import System.Directory (getAppUserDataDirectory) welcomeGetOpts :: IO BroadcastBotOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@BroadcastBotOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getBroadcastBotOpts appDir "simplex_status_bot" + opts@BroadcastBotOpts {coreOptions} <- getBroadcastBotOpts appDir "simplex_status_bot" putStrLn $ "SimpleX Chat Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + printDbOpts coreOptions pure opts broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 5bc4ffef25..e695b5069d 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -27,8 +27,8 @@ defaultProhibitedMessage :: [KnownContact] -> Text defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> knownContactNames ps <> ". Your message is deleted." broadcastBotOpts :: FilePath -> FilePath -> Parser BroadcastBotOpts -broadcastBotOpts appDir defaultDbFileName = do - coreOptions <- coreChatOptsP appDir defaultDbFileName +broadcastBotOpts appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName publishers <- option parseKnownContacts @@ -61,10 +61,10 @@ broadcastBotOpts appDir defaultDbFileName = do } getBroadcastBotOpts :: FilePath -> FilePath -> IO BroadcastBotOpts -getBroadcastBotOpts appDir defaultDbFileName = +getBroadcastBotOpts appDir defaultDbName = execParser $ info - (helper <*> versionOption <*> broadcastBotOpts appDir defaultDbFileName) + (helper <*> versionOption <*> broadcastBotOpts appDir defaultDbName) (header versionStr <> fullDesc <> progDesc "Start chat bot with DB_FILE file and use SERVER as SMP server") where versionStr = versionString versionNumber diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 70135e4ccf..3017f82c8c 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -15,7 +15,7 @@ import qualified Data.Text as T import Options.Applicative import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) -import Simplex.Chat.Options (ChatOpts (..), ChatCmdLog (..), CoreChatOpts, coreChatOptsP) +import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, coreChatOptsP) data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, @@ -29,8 +29,8 @@ data DirectoryOpts = DirectoryOpts } directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts -directoryOpts appDir defaultDbFileName = do - coreOptions <- coreChatOptsP appDir defaultDbFileName +directoryOpts appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName adminUsers <- option parseKnownContacts @@ -77,10 +77,10 @@ directoryOpts appDir defaultDbFileName = do } getDirectoryOpts :: FilePath -> FilePath -> IO DirectoryOpts -getDirectoryOpts appDir defaultDbFileName = +getDirectoryOpts appDir defaultDbName = execParser $ info - (helper <*> versionOption <*> directoryOpts appDir defaultDbFileName) + (helper <*> versionOption <*> directoryOpts appDir defaultDbName) (header versionStr <> fullDesc <> progDesc "Start SimpleX Directory Service with DB_FILE, DIRECTORY_FILE and SUPER_USERS options") where versionStr = versionString versionNumber diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 5b96603f68..102fe4cf7e 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -74,10 +74,10 @@ newServiceState = do welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@DirectoryOpts {coreOptions = CoreChatOpts {dbFilePrefix}, testing} <- getDirectoryOpts appDir "simplex_directory_service" + opts@DirectoryOpts {coreOptions, testing} <- getDirectoryOpts appDir "simplex_directory_service" unless testing $ do putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + printDbOpts coreOptions pure opts directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () diff --git a/cabal.project b/cabal.project index 5fbfede730..bcbf01d365 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 992b42e92224ec663684923aaa40ed1f9a683f61 + tag: 9d9ec8cd0b171b2058c59c4e7292ccafa96b6e2b source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index bae65f4c17..7c00706d33 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."992b42e92224ec663684923aaa40ed1f9a683f61" = "08bhkqm2hvgql63hrayas7izvxbv99pdzwvn3kj6z0j02pnwng6d"; + "https://github.com/simplex-chat/simplexmq.git"."9d9ec8cd0b171b2058c59c4e7292ccafa96b6e2b" = "0mvg9yrwb835vf2kz8k0ac4i7vzjpvbpcwg895n3kcfdkdcnxh14"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 66eecf141d..81858fe9b2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -24,11 +24,15 @@ flag swift manual: True default: False +flag client_postgres + description: Build with PostgreSQL instead of SQLite. + manual: True + default: False + library exposed-modules: Simplex.Chat Simplex.Chat.AppSettings - Simplex.Chat.Archive Simplex.Chat.Bot Simplex.Chat.Bot.KnownContacts Simplex.Chat.Call @@ -44,132 +48,12 @@ library Simplex.Chat.Messages.Batch Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events - Simplex.Chat.Migrations.M20220101_initial - Simplex.Chat.Migrations.M20220122_v1_1 - Simplex.Chat.Migrations.M20220205_chat_item_status - Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests - Simplex.Chat.Migrations.M20220224_messages_fks - Simplex.Chat.Migrations.M20220301_smp_servers - Simplex.Chat.Migrations.M20220302_profile_images - Simplex.Chat.Migrations.M20220304_msg_quotes - Simplex.Chat.Migrations.M20220321_chat_item_edited - Simplex.Chat.Migrations.M20220404_files_status_fields - Simplex.Chat.Migrations.M20220514_profiles_user_id - Simplex.Chat.Migrations.M20220626_auto_reply - Simplex.Chat.Migrations.M20220702_calls - Simplex.Chat.Migrations.M20220715_groups_chat_item_id - Simplex.Chat.Migrations.M20220811_chat_items_indices - Simplex.Chat.Migrations.M20220812_incognito_profiles - Simplex.Chat.Migrations.M20220818_chat_notifications - Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id - Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items - Simplex.Chat.Migrations.M20220824_profiles_local_alias - Simplex.Chat.Migrations.M20220909_commands - Simplex.Chat.Migrations.M20220926_connection_alias - Simplex.Chat.Migrations.M20220928_settings - Simplex.Chat.Migrations.M20221001_shared_msg_id_indices - Simplex.Chat.Migrations.M20221003_delete_broken_integrity_error_chat_items - Simplex.Chat.Migrations.M20221004_idx_msg_deliveries_message_id - Simplex.Chat.Migrations.M20221011_user_contact_links_group_id - Simplex.Chat.Migrations.M20221012_inline_files - Simplex.Chat.Migrations.M20221019_unread_chat - Simplex.Chat.Migrations.M20221021_auto_accept__group_links - Simplex.Chat.Migrations.M20221024_contact_used - Simplex.Chat.Migrations.M20221025_chat_settings - Simplex.Chat.Migrations.M20221029_group_link_id - Simplex.Chat.Migrations.M20221112_server_password - Simplex.Chat.Migrations.M20221115_server_cfg - Simplex.Chat.Migrations.M20221129_delete_group_feature_items - Simplex.Chat.Migrations.M20221130_delete_item_deleted - Simplex.Chat.Migrations.M20221209_verified_connection - Simplex.Chat.Migrations.M20221210_idxs - Simplex.Chat.Migrations.M20221211_group_description - Simplex.Chat.Migrations.M20221212_chat_items_timed - Simplex.Chat.Migrations.M20221214_live_message - Simplex.Chat.Migrations.M20221222_chat_ts - Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status - Simplex.Chat.Migrations.M20221230_idxs - Simplex.Chat.Migrations.M20230107_connections_auth_err_counter - Simplex.Chat.Migrations.M20230111_users_agent_user_id - Simplex.Chat.Migrations.M20230117_fkey_indexes - Simplex.Chat.Migrations.M20230118_recreate_smp_servers - Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx - Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id - Simplex.Chat.Migrations.M20230303_group_link_role - Simplex.Chat.Migrations.M20230317_hidden_profiles - Simplex.Chat.Migrations.M20230318_file_description - Simplex.Chat.Migrations.M20230321_agent_file_deleted - Simplex.Chat.Migrations.M20230328_files_protocol - Simplex.Chat.Migrations.M20230402_protocol_servers - Simplex.Chat.Migrations.M20230411_extra_xftp_file_descriptions - Simplex.Chat.Migrations.M20230420_rcv_files_to_receive - Simplex.Chat.Migrations.M20230422_profile_contact_links - Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages - Simplex.Chat.Migrations.M20230505_chat_item_versions - Simplex.Chat.Migrations.M20230511_reactions - Simplex.Chat.Migrations.M20230519_item_deleted_ts - Simplex.Chat.Migrations.M20230526_indexes - Simplex.Chat.Migrations.M20230529_indexes - Simplex.Chat.Migrations.M20230608_deleted_contacts - Simplex.Chat.Migrations.M20230618_favorite_chats - Simplex.Chat.Migrations.M20230621_chat_item_moderations - Simplex.Chat.Migrations.M20230705_delivery_receipts - Simplex.Chat.Migrations.M20230721_group_snd_item_statuses - Simplex.Chat.Migrations.M20230814_indexes - Simplex.Chat.Migrations.M20230827_file_encryption - Simplex.Chat.Migrations.M20230829_connections_chat_vrange - Simplex.Chat.Migrations.M20230903_connections_to_subscribe - Simplex.Chat.Migrations.M20230913_member_contacts - Simplex.Chat.Migrations.M20230914_member_probes - Simplex.Chat.Migrations.M20230926_contact_status - Simplex.Chat.Migrations.M20231002_conn_initiated - Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash - Simplex.Chat.Migrations.M20231010_member_settings - Simplex.Chat.Migrations.M20231019_indexes - Simplex.Chat.Migrations.M20231030_xgrplinkmem_received - Simplex.Chat.Migrations.M20231107_indexes - Simplex.Chat.Migrations.M20231113_group_forward - Simplex.Chat.Migrations.M20231114_remote_control - Simplex.Chat.Migrations.M20231126_remote_ctrl_address - Simplex.Chat.Migrations.M20231207_chat_list_pagination - Simplex.Chat.Migrations.M20231214_item_content_tag - Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries - Simplex.Chat.Migrations.M20240102_note_folders - Simplex.Chat.Migrations.M20240104_members_profile_update - Simplex.Chat.Migrations.M20240115_block_member_for_all - Simplex.Chat.Migrations.M20240122_indexes - Simplex.Chat.Migrations.M20240214_redirect_file_id - Simplex.Chat.Migrations.M20240222_app_settings - Simplex.Chat.Migrations.M20240226_users_restrict - Simplex.Chat.Migrations.M20240228_pq - Simplex.Chat.Migrations.M20240313_drop_agent_ack_cmd_id - Simplex.Chat.Migrations.M20240324_custom_data - Simplex.Chat.Migrations.M20240402_item_forwarded - Simplex.Chat.Migrations.M20240430_ui_theme - Simplex.Chat.Migrations.M20240501_chat_deleted - Simplex.Chat.Migrations.M20240510_chat_items_via_proxy - Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays - Simplex.Chat.Migrations.M20240528_quota_err_counter - Simplex.Chat.Migrations.M20240827_calls_uuid - Simplex.Chat.Migrations.M20240920_user_order - Simplex.Chat.Migrations.M20241008_indexes - Simplex.Chat.Migrations.M20241010_contact_requests_contact_id - Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id - Simplex.Chat.Migrations.M20241027_server_operators - Simplex.Chat.Migrations.M20241125_indexes - Simplex.Chat.Migrations.M20241128_business_chats - Simplex.Chat.Migrations.M20241205_business_chat_members - Simplex.Chat.Migrations.M20241222_operator_conditions - Simplex.Chat.Migrations.M20241223_chat_tags - Simplex.Chat.Migrations.M20241230_reports - Simplex.Chat.Migrations.M20250105_indexes - Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared - Simplex.Chat.Mobile.WebRTC Simplex.Chat.Operators Simplex.Chat.Operators.Conditions Simplex.Chat.Options + Simplex.Chat.Options.DB Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote @@ -187,7 +71,6 @@ library Simplex.Chat.Store.Files Simplex.Chat.Store.Groups Simplex.Chat.Store.Messages - Simplex.Chat.Store.Migrations Simplex.Chat.Store.NoteFolders Simplex.Chat.Store.Profiles Simplex.Chat.Store.Remote @@ -205,6 +88,137 @@ library Simplex.Chat.Types.Util Simplex.Chat.Util Simplex.Chat.View + if flag(client_postgres) + exposed-modules: + Simplex.Chat.Options.Postgres + Simplex.Chat.Store.Postgres.Migrations + Simplex.Chat.Store.Postgres.Migrations.M20241220_initial + else + exposed-modules: + Simplex.Chat.Archive + Simplex.Chat.Mobile + Simplex.Chat.Mobile.WebRTC + Simplex.Chat.Options.SQLite + Simplex.Chat.Store.SQLite.Migrations + Simplex.Chat.Store.SQLite.Migrations.M20220101_initial + Simplex.Chat.Store.SQLite.Migrations.M20220122_v1_1 + Simplex.Chat.Store.SQLite.Migrations.M20220205_chat_item_status + Simplex.Chat.Store.SQLite.Migrations.M20220210_deduplicate_contact_requests + Simplex.Chat.Store.SQLite.Migrations.M20220224_messages_fks + Simplex.Chat.Store.SQLite.Migrations.M20220301_smp_servers + Simplex.Chat.Store.SQLite.Migrations.M20220302_profile_images + Simplex.Chat.Store.SQLite.Migrations.M20220304_msg_quotes + Simplex.Chat.Store.SQLite.Migrations.M20220321_chat_item_edited + Simplex.Chat.Store.SQLite.Migrations.M20220404_files_status_fields + Simplex.Chat.Store.SQLite.Migrations.M20220514_profiles_user_id + Simplex.Chat.Store.SQLite.Migrations.M20220626_auto_reply + Simplex.Chat.Store.SQLite.Migrations.M20220702_calls + Simplex.Chat.Store.SQLite.Migrations.M20220715_groups_chat_item_id + Simplex.Chat.Store.SQLite.Migrations.M20220811_chat_items_indices + Simplex.Chat.Store.SQLite.Migrations.M20220812_incognito_profiles + Simplex.Chat.Store.SQLite.Migrations.M20220818_chat_notifications + Simplex.Chat.Store.SQLite.Migrations.M20220822_groups_host_conn_custom_user_profile_id + Simplex.Chat.Store.SQLite.Migrations.M20220823_delete_broken_group_event_chat_items + Simplex.Chat.Store.SQLite.Migrations.M20220824_profiles_local_alias + Simplex.Chat.Store.SQLite.Migrations.M20220909_commands + Simplex.Chat.Store.SQLite.Migrations.M20220926_connection_alias + Simplex.Chat.Store.SQLite.Migrations.M20220928_settings + Simplex.Chat.Store.SQLite.Migrations.M20221001_shared_msg_id_indices + Simplex.Chat.Store.SQLite.Migrations.M20221003_delete_broken_integrity_error_chat_items + Simplex.Chat.Store.SQLite.Migrations.M20221004_idx_msg_deliveries_message_id + Simplex.Chat.Store.SQLite.Migrations.M20221011_user_contact_links_group_id + Simplex.Chat.Store.SQLite.Migrations.M20221012_inline_files + Simplex.Chat.Store.SQLite.Migrations.M20221019_unread_chat + Simplex.Chat.Store.SQLite.Migrations.M20221021_auto_accept__group_links + Simplex.Chat.Store.SQLite.Migrations.M20221024_contact_used + Simplex.Chat.Store.SQLite.Migrations.M20221025_chat_settings + Simplex.Chat.Store.SQLite.Migrations.M20221029_group_link_id + Simplex.Chat.Store.SQLite.Migrations.M20221112_server_password + Simplex.Chat.Store.SQLite.Migrations.M20221115_server_cfg + Simplex.Chat.Store.SQLite.Migrations.M20221129_delete_group_feature_items + Simplex.Chat.Store.SQLite.Migrations.M20221130_delete_item_deleted + Simplex.Chat.Store.SQLite.Migrations.M20221209_verified_connection + Simplex.Chat.Store.SQLite.Migrations.M20221210_idxs + Simplex.Chat.Store.SQLite.Migrations.M20221211_group_description + Simplex.Chat.Store.SQLite.Migrations.M20221212_chat_items_timed + Simplex.Chat.Store.SQLite.Migrations.M20221214_live_message + Simplex.Chat.Store.SQLite.Migrations.M20221222_chat_ts + Simplex.Chat.Store.SQLite.Migrations.M20221223_idx_chat_items_item_status + Simplex.Chat.Store.SQLite.Migrations.M20221230_idxs + Simplex.Chat.Store.SQLite.Migrations.M20230107_connections_auth_err_counter + Simplex.Chat.Store.SQLite.Migrations.M20230111_users_agent_user_id + Simplex.Chat.Store.SQLite.Migrations.M20230117_fkey_indexes + Simplex.Chat.Store.SQLite.Migrations.M20230118_recreate_smp_servers + Simplex.Chat.Store.SQLite.Migrations.M20230129_drop_chat_items_group_idx + Simplex.Chat.Store.SQLite.Migrations.M20230206_item_deleted_by_group_member_id + Simplex.Chat.Store.SQLite.Migrations.M20230303_group_link_role + Simplex.Chat.Store.SQLite.Migrations.M20230317_hidden_profiles + Simplex.Chat.Store.SQLite.Migrations.M20230318_file_description + Simplex.Chat.Store.SQLite.Migrations.M20230321_agent_file_deleted + Simplex.Chat.Store.SQLite.Migrations.M20230328_files_protocol + Simplex.Chat.Store.SQLite.Migrations.M20230402_protocol_servers + Simplex.Chat.Store.SQLite.Migrations.M20230411_extra_xftp_file_descriptions + Simplex.Chat.Store.SQLite.Migrations.M20230420_rcv_files_to_receive + Simplex.Chat.Store.SQLite.Migrations.M20230422_profile_contact_links + Simplex.Chat.Store.SQLite.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages + Simplex.Chat.Store.SQLite.Migrations.M20230505_chat_item_versions + Simplex.Chat.Store.SQLite.Migrations.M20230511_reactions + Simplex.Chat.Store.SQLite.Migrations.M20230519_item_deleted_ts + Simplex.Chat.Store.SQLite.Migrations.M20230526_indexes + Simplex.Chat.Store.SQLite.Migrations.M20230529_indexes + Simplex.Chat.Store.SQLite.Migrations.M20230608_deleted_contacts + Simplex.Chat.Store.SQLite.Migrations.M20230618_favorite_chats + Simplex.Chat.Store.SQLite.Migrations.M20230621_chat_item_moderations + Simplex.Chat.Store.SQLite.Migrations.M20230705_delivery_receipts + Simplex.Chat.Store.SQLite.Migrations.M20230721_group_snd_item_statuses + Simplex.Chat.Store.SQLite.Migrations.M20230814_indexes + Simplex.Chat.Store.SQLite.Migrations.M20230827_file_encryption + Simplex.Chat.Store.SQLite.Migrations.M20230829_connections_chat_vrange + Simplex.Chat.Store.SQLite.Migrations.M20230903_connections_to_subscribe + Simplex.Chat.Store.SQLite.Migrations.M20230913_member_contacts + Simplex.Chat.Store.SQLite.Migrations.M20230914_member_probes + Simplex.Chat.Store.SQLite.Migrations.M20230926_contact_status + Simplex.Chat.Store.SQLite.Migrations.M20231002_conn_initiated + Simplex.Chat.Store.SQLite.Migrations.M20231009_via_group_link_uri_hash + Simplex.Chat.Store.SQLite.Migrations.M20231010_member_settings + Simplex.Chat.Store.SQLite.Migrations.M20231019_indexes + Simplex.Chat.Store.SQLite.Migrations.M20231030_xgrplinkmem_received + Simplex.Chat.Store.SQLite.Migrations.M20231107_indexes + Simplex.Chat.Store.SQLite.Migrations.M20231113_group_forward + Simplex.Chat.Store.SQLite.Migrations.M20231114_remote_control + Simplex.Chat.Store.SQLite.Migrations.M20231126_remote_ctrl_address + Simplex.Chat.Store.SQLite.Migrations.M20231207_chat_list_pagination + Simplex.Chat.Store.SQLite.Migrations.M20231214_item_content_tag + Simplex.Chat.Store.SQLite.Migrations.M20231215_recreate_msg_deliveries + Simplex.Chat.Store.SQLite.Migrations.M20240102_note_folders + Simplex.Chat.Store.SQLite.Migrations.M20240104_members_profile_update + Simplex.Chat.Store.SQLite.Migrations.M20240115_block_member_for_all + Simplex.Chat.Store.SQLite.Migrations.M20240122_indexes + Simplex.Chat.Store.SQLite.Migrations.M20240214_redirect_file_id + Simplex.Chat.Store.SQLite.Migrations.M20240222_app_settings + Simplex.Chat.Store.SQLite.Migrations.M20240226_users_restrict + Simplex.Chat.Store.SQLite.Migrations.M20240228_pq + Simplex.Chat.Store.SQLite.Migrations.M20240313_drop_agent_ack_cmd_id + Simplex.Chat.Store.SQLite.Migrations.M20240324_custom_data + Simplex.Chat.Store.SQLite.Migrations.M20240402_item_forwarded + Simplex.Chat.Store.SQLite.Migrations.M20240430_ui_theme + Simplex.Chat.Store.SQLite.Migrations.M20240501_chat_deleted + Simplex.Chat.Store.SQLite.Migrations.M20240510_chat_items_via_proxy + Simplex.Chat.Store.SQLite.Migrations.M20240515_rcv_files_user_approved_relays + Simplex.Chat.Store.SQLite.Migrations.M20240528_quota_err_counter + Simplex.Chat.Store.SQLite.Migrations.M20240827_calls_uuid + Simplex.Chat.Store.SQLite.Migrations.M20240920_user_order + Simplex.Chat.Store.SQLite.Migrations.M20241008_indexes + Simplex.Chat.Store.SQLite.Migrations.M20241010_contact_requests_contact_id + Simplex.Chat.Store.SQLite.Migrations.M20241023_chat_item_autoincrement_id + Simplex.Chat.Store.SQLite.Migrations.M20241027_server_operators + Simplex.Chat.Store.SQLite.Migrations.M20241125_indexes + Simplex.Chat.Store.SQLite.Migrations.M20241128_business_chats + Simplex.Chat.Store.SQLite.Migrations.M20241205_business_chat_members + Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions + Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags + Simplex.Chat.Store.SQLite.Migrations.M20241230_reports + Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes other-modules: Paths_simplex_chat hs-source-dirs: @@ -224,7 +238,6 @@ library , containers ==0.6.* , crypton ==0.34.* , data-default ==0.7.* - , direct-sqlcipher ==2.3.* , directory ==1.3.* , email-validate ==2.3.* , exceptions ==0.10.* @@ -243,7 +256,6 @@ library , simple-logger ==0.1.* , simplexmq >=6.3 , socks ==0.6.* - , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , time ==1.12.* @@ -255,6 +267,16 @@ library default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON + if flag(client_postgres) + build-depends: + postgresql-libpq >=0.10.0.0 + , postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* + cpp-options: -DdbPostgres + else + build-depends: + direct-sqlcipher ==2.3.* + , sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* @@ -282,6 +304,8 @@ executable simplex-bot , directory ==1.3.* , simplex-chat default-language: Haskell2010 + if flag(client_postgres) + cpp-options: -DdbPostgres executable simplex-bot-advanced main-is: Main.hs @@ -300,6 +324,8 @@ executable simplex-bot-advanced , simplexmq >=6.3 , stm ==2.5.* default-language: Haskell2010 + if flag(client_postgres) + cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) build-depends: text >=2.0.1 && <2.2 @@ -328,6 +354,8 @@ executable simplex-broadcast-bot , simplexmq >=6.3 , stm ==2.5.* default-language: Haskell2010 + if flag(client_postgres) + cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) build-depends: text >=2.0.1 && <2.2 @@ -357,6 +385,8 @@ executable simplex-chat , unliftio ==0.2.* , websockets ==0.12.* default-language: Haskell2010 + if flag(client_postgres) + cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) build-depends: text >=2.0.1 && <2.2 @@ -393,6 +423,8 @@ executable simplex-directory-service , stm ==2.5.* , time ==1.12.* default-language: Haskell2010 + if flag(client_postgres) + cpp-options: -DdbPostgres if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* @@ -418,18 +450,16 @@ test-suite simplex-chat-test ChatTests.Local ChatTests.Profiles ChatTests.Utils + JSONFixtures JSONTests MarkdownTests MessageBatching - MobileTests OperatorTests ProtocolTests RandomServers RemoteTests - SchemaDump ValidNames ViewTests - WebRTCTests Broadcast.Bot Broadcast.Options Directory.Events @@ -438,6 +468,11 @@ test-suite simplex-chat-test Directory.Service Directory.Store Paths_simplex_chat + if !flag(client_postgres) + other-modules: + MobileTests + SchemaDump + WebRTCTests hs-source-dirs: tests apps/simplex-broadcast-bot/src @@ -469,12 +504,18 @@ test-suite simplex-chat-test , simple-logger ==0.1.* , simplex-chat , simplexmq >=6.3 - , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* , time ==1.12.* , unliftio ==0.2.* default-language: Haskell2010 + if flag(client_postgres) + build-depends: + postgresql-simple ==0.7.* + cpp-options: -DdbPostgres + else + build-depends: + sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f3be6cbbdb..ce15d29022 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -20,7 +21,6 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Data.Bifunctor (bimap, second) -import Data.ByteArray (ScrubbedBytes) import Data.List (partition, sortOn) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L @@ -32,6 +32,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Operators import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Profiles @@ -42,7 +43,7 @@ import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), ServerRoles (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore (dbNew)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C @@ -50,6 +51,9 @@ import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), import qualified Simplex.Messaging.TMap as TM import qualified UnliftIO.Exception as E import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) +#endif operatorSimpleXChat :: NewServerOperator operatorSimpleXChat = @@ -183,11 +187,20 @@ fluxXFTPServers = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -createChatDatabase :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> Bool -> IO (Either MigrationError ChatDatabase) -createChatDatabase filePrefix key keepKey confirmMigrations vacuum = runExceptT $ do - chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key keepKey confirmMigrations vacuum - agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key keepKey confirmMigrations vacuum +createChatDatabase :: ChatDbOpts -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) +createChatDatabase dbOpts confirmMigrations = runExceptT $ do +#if defined(dbPostgres) + let ChatDbOpts {dbName, dbUser, dbSchemaPrefix} = dbOpts + connectInfo = defaultConnectInfo {connectUser = dbUser, connectDatabase = dbName} + chatStore <- ExceptT $ createChatStore connectInfo (chatSchema dbSchemaPrefix) confirmMigrations + agentStore <- ExceptT $ createAgentStore connectInfo (agentSchema dbSchemaPrefix) confirmMigrations pure ChatDatabase {chatStore, agentStore} +#else + let ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} = dbOpts + chatStore <- ExceptT $ createChatStore (chatStoreFile dbFilePrefix) dbKey False confirmMigrations vacuumOnMigration + agentStore <- ExceptT $ createAgentStore (agentStoreFile dbFilePrefix) dbKey False confirmMigrations vacuumOnMigration + pure ChatDatabase {chatStore, agentStore} +#endif newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController newChatController diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 693e2fe29f..2cbc941b44 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -11,7 +11,6 @@ module Simplex.Chat.Archive deleteStorage, sqlCipherExport, sqlCipherTestKey, - archiveFilesFolder, ) where @@ -112,7 +111,7 @@ copyValidDirectoryFiles isFileError fromDir toDir = do Nothing -> (copyDirectoryFile f $> fileErrs) `E.catch` \(e :: E.SomeException) -> addErr $ show e - Just e -> addErr e + Just e -> addErr e where addErr e = pure $ AEFileError f e : fileErrs copyDirectoryFile f = do diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 882ec8ccd0..3fd52e8493 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,9 +1,13 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} @@ -18,13 +22,19 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Types (Contact, ContactId, User) +import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data Call = Call { contactId :: ContactId, @@ -90,6 +100,9 @@ data CallState newtype CallId = CallId ByteString deriving (Eq, Show) + deriving newtype (FromField) + +instance ToField CallId where toField (CallId m) = toField $ Binary m instance StrEncoding CallId where strEncode (CallId m) = strEncode m @@ -103,10 +116,6 @@ instance ToJSON CallId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField CallId where fromField f = CallId <$> fromField f - -instance ToField CallId where toField (CallId m) = toField m - data RcvCallInvitation = RcvCallInvitation { user :: User, contact :: Contact, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 3854b0662e..ff534f252f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,8 +46,6 @@ import Data.Time (NominalDiffTime, UTCTime) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) -import Database.SQLite.Simple (SQLError) -import qualified Database.SQLite.Simple as SQL import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -73,10 +71,9 @@ import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWo import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore, withTransaction, withTransactionPriority) -import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction, withTransactionPriority) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, UpMigration) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -97,6 +94,11 @@ import System.IO (Handle) import System.Mem.Weak (Weak) import qualified UnliftIO.Exception as E import UnliftIO.STM +#if !defined(dbPostgres) +import Database.SQLite.Simple (SQLError) +import qualified Database.SQLite.Simple as SQL +import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +#endif versionNumber :: String versionNumber = showVersion SC.version @@ -284,17 +286,19 @@ data ChatCommand | APISetAppFilePaths AppFilePathsConfig | APISetEncryptLocalFiles Bool | SetContactMergeEnabled Bool +#if !defined(dbPostgres) | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig - | APISaveAppSettings AppSettings - | APIGetAppSettings (Maybe AppSettings) | APIDeleteStorage | APIStorageEncryption DBEncryptionConfig | TestStorageEncryption DBEncryptionKey + | SlowSQLQueries +#endif | ExecChatStoreSQL Text | ExecAgentStoreSQL Text - | SlowSQLQueries + | APISaveAppSettings AppSettings + | APIGetAppSettings (Maybe AppSettings) | APIGetChatTags UserId | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} | APIGetChat ChatRef (Maybe ContentFilter) ChatPagination (Maybe String) @@ -559,11 +563,14 @@ allowRemoteCommand = \case SetFilesFolder _ -> False SetRemoteHostsFolder _ -> False APISetEncryptLocalFiles _ -> False +#if !defined(dbPostgres) APIExportArchive _ -> False APIImportArchive _ -> False ExportArchive -> False APIDeleteStorage -> False APIStorageEncryption _ -> False + SlowSQLQueries -> False +#endif APISetNetworkConfig _ -> False APIGetNetworkConfig -> False SetLocalDeviceName _ -> False @@ -583,7 +590,6 @@ allowRemoteCommand = \case DeleteRemoteCtrl _ -> False ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False - SlowSQLQueries -> False _ -> True data ChatResponse @@ -798,7 +804,11 @@ data ChatResponse | CRRemoteCtrlStopped {rcsState :: RemoteCtrlSessionState, rcStopReason :: RemoteCtrlStopReason} | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CRSQLResult {rows :: [Text]} +#if !defined(dbPostgres) + | CRArchiveExported {archiveErrors :: [ArchiveError]} + | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} +#endif | CRDebugLocks {chatLockName :: Maybe String, chatEntityLocks :: Map String String, agentLocks :: AgentLocks} | CRAgentSubsTotal {user :: User, subsTotal :: SMPServerSubs, hasSession :: Bool} | CRAgentServersSummary {user :: User, serversSummary :: PresentedServersSummary} @@ -817,8 +827,6 @@ data ChatResponse | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} - | CRArchiveExported {archiveErrors :: [ArchiveError]} - | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRAppSettings {appSettings :: AppSettings} | CRTimedAction {action :: String, durationMilliseconds :: Int64} | CRCustomChatResponse {user_ :: Maybe User, response :: Text} @@ -846,7 +854,9 @@ allowRemoteEvent = \case CRRemoteCtrlConnected _ -> False CRRemoteCtrlStopped {} -> False CRSQLResult _ -> False +#if !defined(dbPostgres) CRSlowSQLQueries {} -> False +#endif _ -> True logResponseToFile :: ChatResponse -> Bool @@ -1181,11 +1191,13 @@ data CoreVersionInfo = CoreVersionInfo } deriving (Show) +#if !defined(dbPostgres) data SlowSQLQuery = SlowSQLQuery { query :: Text, queryStats :: SlowQueryStats } deriving (Show) +#endif data ChatError = ChatError {errorType :: ChatErrorType} @@ -1512,13 +1524,17 @@ withStoreBatch actions = do ChatController {chatStore} <- ask liftIO $ withTransaction chatStore $ mapM (`E.catches` handleDBErrors) . actions +-- TODO [postgres] postgres specific error handling handleDBErrors :: [E.Handler IO (Either ChatError a)] handleDBErrors = - [ E.Handler $ \(e :: SQLError) -> +#if !defined(dbPostgres) + ( E.Handler $ \(e :: SQLError) -> let se = SQL.sqlError e busy = se == SQL.ErrorBusy || se == SQL.ErrorLocked - in pure . Left . ChatErrorStore $ if busy then SEDBBusyError $ show se else SEDBException $ show e, - E.Handler $ \(E.SomeException e) -> pure . Left . ChatErrorStore . SEDBException $ show e + in pure . Left . ChatErrorStore $ if busy then SEDBBusyError $ show se else SEDBException $ show e + ) : +#endif + [ E.Handler $ \(E.SomeException e) -> pure . Left . ChatErrorStore . SEDBException $ show e ] withStoreBatch' :: Traversable t => (DB.Connection -> t (IO a)) -> CM' (t (Either ChatError a)) @@ -1591,7 +1607,9 @@ $(JQ.deriveJSON defaultJSON ''ChatItemDeletion) $(JQ.deriveJSON defaultJSON ''CoreVersionInfo) +#if !defined(dbPostgres) $(JQ.deriveJSON defaultJSON ''SlowSQLQuery) +#endif -- instance ProtocolTypeI p => FromJSON (ProtoServersConfig p) where -- parseJSON = $(JQ.mkParseJSON defaultJSON ''ProtoServersConfig) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 8e40469d84..0dbee1542e 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -26,22 +26,22 @@ import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Chat.View (serializeChatResponse) -import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore (..), withTransaction) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) import System.Exit (exitFailure) import System.IO (hFlush, stdout) import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent, yesToUpMigrations, vacuumOnMigration}} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}} chat = case logAgent of Just level -> do setLogLevel level withGlobalLogging logCfg initRun _ -> initRun where - initRun = createChatDatabase dbFilePrefix dbKey False confirm' vacuumOnMigration >>= either exit run + initRun = createChatDatabase dbOptions confirm' >>= either exit run confirm' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations exit e = do putStrLn $ "Error opening database: " <> show e diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 85dd765e7a..fa79db81d1 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -26,8 +27,6 @@ import Control.Monad.Reader import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.Bifunctor (bimap, first, second) -import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B @@ -47,14 +46,11 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMayb import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, getCurrentTime, nominalDay) import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 -import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Library.Subscriber -import Simplex.Chat.Archive import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Files @@ -87,15 +83,12 @@ import Simplex.Chat.Util (liftIOEither) import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) import Simplex.Messaging.Agent as Agent -import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (execSQL) -import Simplex.Messaging.Agent.Store.SQLite.Common (withConnection) -import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Agent.Store.Shared (upMigration) +import Simplex.Messaging.Agent.Store (execSQL) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import qualified Simplex.Messaging.Agent.Store.Migrations as Migrations import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (SMAlways), textToHostMode) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) @@ -122,6 +115,20 @@ import UnliftIO.Directory import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose) import UnliftIO.STM +#if defined(dbPostgres) +import Data.Bifunctor (bimap, second) +import Data.Time (NominalDiffTime, addUTCTime) +import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +#else +import Data.Bifunctor (bimap, first, second) +import qualified Data.ByteArray as BA +import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) +import qualified Database.SQLite.Simple as SQL +import Simplex.Chat.Archive +import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +import Simplex.Messaging.Agent.Store.Common (withConnection) +import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +#endif _defaultNtfServers :: [NtfServer] _defaultNtfServers = @@ -446,6 +453,7 @@ processChatCommand' vr = \case chatWriteVar sel $ Just f APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ SetContactMergeEnabled onOff -> chatWriteVar contactMergeEnabled onOff >> ok_ +#if !defined(dbPostgres) APIExportArchive cfg -> checkChatStopped $ CRArchiveExported <$> lift (exportArchive cfg) ExportArchive -> do ts <- liftIO getCurrentTime @@ -455,13 +463,9 @@ processChatCommand' vr = \case fileErrs <- lift $ importArchive cfg setStoreChanged pure $ CRArchiveImported fileErrs - APISaveAppSettings as -> withFastStore' (`saveAppSettings` as) >> ok_ - APIGetAppSettings platformDefaults -> CRAppSettings <$> withFastStore' (`getAppSettings` platformDefaults) APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg TestStorageEncryption key -> sqlCipherTestKey key >> ok_ - ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) - ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) SlowSQLQueries -> do ChatController {chatStore, smpAgent} <- ask chatQueries <- slowQueries chatStore @@ -474,6 +478,11 @@ processChatCommand' vr = \case . sortOn (timeAvg . snd) . M.assocs <$> withConnection st (readTVarIO . DB.slow) +#endif + ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) + ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) + APISaveAppSettings as -> withFastStore' (`saveAppSettings` as) >> ok_ + APIGetAppSettings platformDefaults -> CRAppSettings <$> withFastStore' (`getAppSettings` platformDefaults) APIGetChatTags userId -> withUserId' userId $ \user -> do tags <- withFastStore' (`getUserChatTags` user) pure $ CRChatTags user tags @@ -2421,12 +2430,14 @@ processChatCommand' vr = \case | name == "" -> withFastStore (`getUserNoteFolderId` user) | otherwise -> throwChatError $ CECommandError "not supported" _ -> throwChatError $ CECommandError "not supported" +#if !defined(dbPostgres) checkChatStopped :: CM ChatResponse -> CM ChatResponse checkChatStopped a = asks agentAsync >>= readTVarIO >>= maybe a (const $ throwChatError CEChatNotStopped) setStoreChanged :: CM () setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True) withStoreChanged :: CM () -> CM ChatResponse withStoreChanged a = checkChatStopped $ a >> setStoreChanged >> ok_ +#endif checkStoreNotChanged :: CM ChatResponse -> CM ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) withUserName :: UserName -> (UserId -> ChatCommand) -> CM ChatResponse @@ -3558,6 +3569,7 @@ chatCommandP = "/set file paths " *> (APISetAppFilePaths <$> jsonP), "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), +#if !defined(dbPostgres) "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), @@ -3567,11 +3579,12 @@ chatCommandP = "/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), "/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP), "/db test key " *> (TestStorageEncryption <$> dbKeyP), + "/sql slow" $> SlowSQLQueries, +#endif "/_save app settings" *> (APISaveAppSettings <$> jsonP), "/_get app settings" *> (APIGetAppSettings <$> optional (A.space *> jsonP)), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), - "/sql slow" $> SlowSQLQueries, "/_get tags " *> (APIGetChatTags <$> A.decimal), "/_get chats " *> ( APIGetChats @@ -4005,9 +4018,11 @@ chatCommandP = logTLSErrors <- " log=" *> onOffP <|> pure False let tcpTimeout_ = (1000000 *) <$> t_ pure $ SimpleNetCfg {socksProxy, socksMode, hostMode, requiredHostMode, smpProxyMode_, smpProxyFallback_, smpWebPort, tcpTimeout_, logTLSErrors} +#if !defined(dbPostgres) dbKeyP = nonEmptyKey <$?> strP nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} +#endif autoAcceptP = ifM onOffP (Just <$> (businessAA <|> addressAA)) (pure Nothing) where addressAA = AutoAccept False <$> (" incognito=" *> onOffP <|> pure False) <*> autoReply diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 9414675924..1bcbf9f20d 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -79,7 +79,7 @@ import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..)) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (NetworkConfig (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index b47855a18f..e7ca4ed2e9 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -43,12 +43,12 @@ import qualified Data.UUID.V4 as V4 import Data.Word (Word32) import Simplex.Chat.Call import Simplex.Chat.Controller +import Simplex.Chat.Library.Internal import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol -import Simplex.Chat.Library.Internal import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct @@ -70,7 +70,7 @@ import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProxyClientError (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 25bea24e74..3fd927daff 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -38,8 +39,6 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, nominalDay) import Data.Type.Equality import Data.Typeable (Typeable) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import GHC.TypeLits (ErrorMessage (ShowType, type (:<>:)), TypeError) import qualified GHC.TypeLits as Type import Simplex.Chat.Markdown @@ -55,6 +54,13 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 96a4f9f6c8..16bd749f30 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -23,8 +24,6 @@ import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Type.Equality import Data.Word (Word32) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol import Simplex.Chat.Types @@ -35,6 +34,13 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOff, pattern import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Util (encodeJSON, safeDecodeUtf8, tshow, (<$?>)) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data MsgDirection = MDRcv | MDSnd deriving (Eq, Show) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index f8b044676a..8c332f9902 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -42,6 +42,7 @@ import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Remote.Types import Simplex.Chat.Store import Simplex.Chat.Store.Profiles @@ -189,8 +190,12 @@ mobileChatOpts dbFilePrefix = ChatOpts { coreOptions = CoreChatOpts - { dbFilePrefix, - dbKey = "", -- for API database is already opened, and the key in options is not used + { dbOptions = + ChatDbOpts + { dbFilePrefix, + dbKey = "", -- for API database is already opened, and the key in options is not used + vacuumOnMigration = True + }, smpServers = [], xftpServers = [], simpleNetCfg = defaultSimpleNetCfg, @@ -201,8 +206,7 @@ mobileChatOpts dbFilePrefix = logFile = Nothing, tbqSize = 1024, highlyAvailable = False, - yesToUpMigrations = False, - vacuumOnMigration = True + yesToUpMigrations = False }, deviceName = Nothing, chatCmd = "", @@ -247,7 +251,7 @@ chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExcept newChatController db user_ defaultMobileConfig opts backgroundMode migrate createStore dbFile confirmMigrations = ExceptT $ - (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations (vacuumOnMigration $ coreOptions opts)) + (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations (vacuumOnMigration $ dbOptions $ coreOptions opts)) `catch` (pure . checkDBError) `catchAll` (pure . dbError) where diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 07225856b6..6441d651da 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -43,8 +44,6 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions import Simplex.Chat.Types (User) @@ -55,6 +54,13 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTy import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif usageConditionsCommit :: Text usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" @@ -119,7 +125,14 @@ instance TextEncoding OperatorTag where -- this and other types only define instances of serialization for known DB IDs only, -- entities without IDs cannot be serialized to JSON -instance FromField DBEntityId where fromField f = DBEntityId <$> fromField f +instance FromField DBEntityId +#if defined(dbPostgres) + where + fromField f dat = DBEntityId <$> fromField f dat +#else + where + fromField f = DBEntityId <$> fromField f +#endif instance ToField DBEntityId where toField (DBEntityId i) = toField i @@ -338,7 +351,7 @@ updatedServerOperators presetOps storedOps = <> map (\op -> (Nothing, Just $ ASO SDBStored op)) (filter (isNothing . operatorTag) storedOps) where -- TODO remove domains of preset operators from custom - addPreset op = ((Just op, storedOp' <$> pOperator op) :) + addPreset op = ((Just op, storedOp' <$> pOperator op) :) where storedOp' presetOp = case find ((operatorTag presetOp ==) . operatorTag) storedOps of Just ServerOperator {operatorId, conditionsAcceptance, enabled, smpRoles, xftpRoles} -> @@ -427,7 +440,7 @@ groupByOperator_ (ops, smpSrvs, xftpSrvs) = do where mkUS op = UserOperatorServers op [] [] addServer :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () - addServer ss custom add srv = + addServer ss custom add srv = let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss in atomicModifyIORef'_ v (add srv <$>) addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers} @@ -445,7 +458,7 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others where currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr otherUserErrs (user, uss) = noServersErrs SPSMP (Just user) uss <> noServersErrs SPXFTP (Just user) uss - noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] + noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] noServersErrs p user uss | noServers opEnabled = [USENoServers p' user] | otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)] diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index e034bd03f0..c58c792819 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -14,12 +14,12 @@ module Simplex.Chat.Options getChatOpts, protocolServersP, defaultHostMode, + printDbOpts, ) where import Control.Logger.Simple (LogLevel (..)) import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.ByteArray (ScrubbedBytes) import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe) import Data.Text (Text) @@ -34,7 +34,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI, SMPServerWithAuth, XFTPServerWithAuth) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth (..), SocksAuth (..), defaultSocksProxyWithAuth) -import System.FilePath (combine) +import Simplex.Chat.Options.DB data ChatOpts = ChatOpts { coreOptions :: CoreChatOpts, @@ -54,8 +54,7 @@ data ChatOpts = ChatOpts } data CoreChatOpts = CoreChatOpts - { dbFilePrefix :: String, - dbKey :: ScrubbedBytes, + { dbOptions :: ChatDbOpts, smpServers :: [SMPServerWithAuth], xftpServers :: [XFTPServerWithAuth], simpleNetCfg :: SimpleNetCfg, @@ -66,8 +65,7 @@ data CoreChatOpts = CoreChatOpts logFile :: Maybe FilePath, tbqSize :: Natural, highlyAvailable :: Bool, - yesToUpMigrations :: Bool, - vacuumOnMigration :: Bool + yesToUpMigrations :: Bool } data ChatCmdLog = CCLAll | CCLMessages | CCLNone @@ -82,24 +80,8 @@ agentLogLevel = \case CLLImportant -> LogInfo coreChatOptsP :: FilePath -> FilePath -> Parser CoreChatOpts -coreChatOptsP appDir defaultDbFileName = do - dbFilePrefix <- - strOption - ( long "database" - <> short 'd' - <> metavar "DB_FILE" - <> help "Path prefix to chat and agent database files" - <> value defaultDbFilePath - <> showDefault - ) - dbKey <- - strOption - ( long "key" - <> short 'k' - <> metavar "KEY" - <> help "Database encryption key/pass-phrase" - <> value "" - ) +coreChatOptsP appDir defaultDbName = do + dbOptions <- chatDbOptsP appDir defaultDbName smpServers <- option parseProtocolServers @@ -241,15 +223,9 @@ coreChatOptsP appDir defaultDbFileName = do <> short 'y' <> help "Automatically confirm \"up\" database migrations" ) - disableVacuum <- - switch - ( long "disable-vacuum" - <> help "Do not vacuum database after migrations" - ) pure CoreChatOpts - { dbFilePrefix, - dbKey, + { dbOptions, smpServers, xftpServers, simpleNetCfg = @@ -271,12 +247,10 @@ coreChatOptsP appDir defaultDbFileName = do logFile, tbqSize, highlyAvailable, - yesToUpMigrations, - vacuumOnMigration = not disableVacuum + yesToUpMigrations } where useTcpTimeout p t = 1000000 * if t > 0 then t else maybe 7 (const 15) p - defaultDbFilePath = combine appDir defaultDbFileName defaultHostMode :: Maybe SocksProxyWithAuth -> HostMode defaultHostMode = \case @@ -284,8 +258,8 @@ defaultHostMode = \case _ -> HMPublic chatOptsP :: FilePath -> FilePath -> Parser ChatOpts -chatOptsP appDir defaultDbFileName = do - coreOptions <- coreChatOptsP appDir defaultDbFileName +chatOptsP appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName deviceName <- optional $ strOption @@ -432,12 +406,15 @@ parseChatCmdLog = eitherReader $ \case _ -> Left "Invalid chat command log level" getChatOpts :: FilePath -> FilePath -> IO ChatOpts -getChatOpts appDir defaultDbFileName = +getChatOpts appDir defaultDbName = execParser $ info - (helper <*> versionOption <*> chatOptsP appDir defaultDbFileName) + (helper <*> versionOption <*> chatOptsP appDir defaultDbName) (header versionStr <> fullDesc <> progDesc "Start chat with DB_FILE file and use SERVER as SMP server") where versionStr = versionString versionNumber versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version") versionAndUpdate = versionStr <> "\n" <> updateStr + +printDbOpts :: CoreChatOpts -> IO () +printDbOpts opts = putStrLn $ "db: " <> dbString (dbOptions opts) diff --git a/src/Simplex/Chat/Options/DB.hs b/src/Simplex/Chat/Options/DB.hs new file mode 100644 index 0000000000..1796baa5db --- /dev/null +++ b/src/Simplex/Chat/Options/DB.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE CPP #-} + +module Simplex.Chat.Options.DB +#if defined(dbPostgres) + ( module Simplex.Chat.Options.Postgres, + ) + where +import Simplex.Chat.Options.Postgres +#else + ( module Simplex.Chat.Options.SQLite, + ) + where +import Simplex.Chat.Options.SQLite +#endif diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs new file mode 100644 index 0000000000..635223152c --- /dev/null +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Simplex.Chat.Options.Postgres where + +import Options.Applicative + +data ChatDbOpts = ChatDbOpts + { dbName :: String, + dbUser :: String, + dbSchemaPrefix :: String + } + +chatDbOptsP :: FilePath -> String -> Parser ChatDbOpts +chatDbOptsP _appDir defaultDbName = do + dbName <- + strOption + ( long "database" + <> short 'd' + <> metavar "DB_NAME" + <> help "Database name" + <> value defaultDbName + <> showDefault + ) + dbUser <- + strOption + ( long "database-user" + <> short 'u' + <> metavar "DB_USER" + <> help "Database user" + <> value "simplex" + <> showDefault + ) + pure ChatDbOpts {dbName, dbUser, dbSchemaPrefix = ""} + +dbString :: ChatDbOpts -> String +dbString ChatDbOpts {dbName} = dbName diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs new file mode 100644 index 0000000000..dc81356784 --- /dev/null +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Options.SQLite where + +import Data.ByteArray (ScrubbedBytes) +import Options.Applicative +import System.FilePath (combine) + +data ChatDbOpts = ChatDbOpts + { dbFilePrefix :: String, + dbKey :: ScrubbedBytes, + vacuumOnMigration :: Bool + } + +chatDbOptsP :: FilePath -> FilePath -> Parser ChatDbOpts +chatDbOptsP appDir defaultDbName = do + dbFilePrefix <- + strOption + ( long "database" + <> short 'd' + <> metavar "DB_FILE" + <> help "Path prefix to chat and agent database files" + <> value (combine appDir defaultDbName) + <> showDefault + ) + dbKey <- + strOption + ( long "key" + <> short 'k' + <> metavar "KEY" + <> help "Database encryption key/pass-phrase" + <> value "" + ) + disableVacuum <- + switch + ( long "disable-vacuum" + <> help "Do not vacuum database after migrations" + ) + pure ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = not disableVacuum} + +dbString :: ChatDbOpts -> String +dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index cda8cdf04c..f2f1b912a0 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,8 +1,11 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE KindSignatures #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -42,21 +45,26 @@ import Data.Time.Clock (UTCTime) import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word32) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif -- Chat version history: -- 1 - support chat versions in connections (9/1/2023) @@ -217,10 +225,9 @@ instance StrEncoding AppMessageBinary where newtype SharedMsgId = SharedMsgId ByteString deriving (Eq, Show) + deriving newtype (FromField) -instance FromField SharedMsgId where fromField f = SharedMsgId <$> fromField f - -instance ToField SharedMsgId where toField (SharedMsgId m) = toField m +instance ToField SharedMsgId where toField (SharedMsgId m) = toField $ DB.Binary m instance StrEncoding SharedMsgId where strEncode (SharedMsgId m) = strEncode m @@ -253,7 +260,7 @@ data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknow deriving (Eq, Show) data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRUnknown Text - deriving (Eq, Show) + deriving (Eq, Show) $(pure []) @@ -515,7 +522,7 @@ instance ToJSON MsgContentTag where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField MsgContentTag where fromField = fromBlobField_ strDecode +instance FromField MsgContentTag where fromField = blobFieldDecoder strDecode instance ToField MsgContentTag where toField = toField . strEncode @@ -570,9 +577,10 @@ durationText duration = | otherwise = show n msgContentHasText :: MsgContent -> Bool -msgContentHasText = not . T.null . \case - MCVoice {text} -> text - mc -> msgContentText mc +msgContentHasText = + not . T.null . \case + MCVoice {text} -> text + mc -> msgContentText mc isVoice :: MsgContent -> Bool isVoice = \case diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 8c7a0bc552..8e23af24e9 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -37,7 +37,6 @@ import Data.Word (Word16, Word32) import qualified Network.HTTP.Types as N import Network.HTTP2.Server (responseStreaming) import qualified Paths_simplex_chat as SC -import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Messages (chatNameStr) @@ -71,6 +70,9 @@ import UnliftIO import UnliftIO.Concurrent (forkIO) import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile) +remoteFilesFolder :: String +remoteFilesFolder = "simplex_v1_files" + -- when acting as host minRemoteCtrlVersion :: AppVersion minRemoteCtrlVersion = AppVersion [6, 3, 0, 0] @@ -342,7 +344,7 @@ storeRemoteFile rhId encrypted_ localPath = do filePath' <- liftRH rhId $ remoteStoreFile c filePath (takeFileName localPath) hf_ <- chatReadVar remoteHostsFolder forM_ hf_ $ \hf -> do - let rhf = hf storePath archiveFilesFolder + let rhf = hf storePath remoteFilesFolder hPath = rhf takeFileName filePath' createDirectoryIfMissing True rhf (if encrypt then renameFile else copyFile) filePath hPath @@ -360,7 +362,7 @@ storeRemoteFile rhId encrypted_ localPath = do getRemoteFile :: RemoteHostId -> RemoteFile -> CM () getRemoteFile rhId rf = do c@RemoteHostClient {storePath} <- getRemoteHostClient rhId - dir <- lift $ ( storePath archiveFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar' remoteHostsFolder) + dir <- lift $ ( storePath remoteFilesFolder) <$> (maybe getDefaultFilesFolder pure =<< chatReadVar' remoteHostsFolder) createDirectoryIfMissing True dir liftRH rhId $ remoteGetFile c dir rf diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 7ae1b4a32a..dbb932740c 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE CPP #-} + module Simplex.Chat.Store ( DBStore, StoreError (..), @@ -7,20 +9,43 @@ module Simplex.Chat.Store AutoAccept (..), createChatStore, migrations, -- used in tests +#if defined(dbPostgres) + chatSchema, + agentSchema, +#else chatStoreFile, agentStoreFile, +#endif withTransaction, ) where -import Data.ByteArray (ScrubbedBytes) -import Simplex.Chat.Store.Migrations import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared -import Simplex.Messaging.Agent.Store.SQLite (createDBStore) -import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore (..), withTransaction) +import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, MigrationError) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (ConnectInfo (..)) +import Simplex.Chat.Store.Postgres.Migrations +import Simplex.Messaging.Agent.Store.Postgres (createDBStore) +#else +import Data.ByteArray (ScrubbedBytes) +import Simplex.Chat.Store.SQLite.Migrations +import Simplex.Messaging.Agent.Store.SQLite (createDBStore) +#endif +#if defined(dbPostgres) +createChatStore :: ConnectInfo -> String -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore connectInfo schema = createDBStore connectInfo schema migrations + +chatSchema :: String -> String +chatSchema "" = "chat_schema" +chatSchema prefix = prefix <> "_chat_schema" + +agentSchema :: String -> String +agentSchema "" = "agent_schema" +agentSchema prefix = prefix <> "_agent_schema" +#else createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> Bool -> IO (Either MigrationError DBStore) createChatStore dbPath key keepKey = createDBStore dbPath key keepKey migrations @@ -29,3 +54,4 @@ chatStoreFile = (<> "_chat.db") agentStoreFile :: FilePath -> FilePath agentStoreFile = (<> "_agent.db") +#endif diff --git a/src/Simplex/Chat/Store/AppSettings.hs b/src/Simplex/Chat/Store/AppSettings.hs index acecc577ca..dbdd538cf4 100644 --- a/src/Simplex/Chat/Store/AppSettings.hs +++ b/src/Simplex/Chat/Store/AppSettings.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Store.AppSettings where @@ -6,10 +7,14 @@ import Control.Monad (join) import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as J import Data.Maybe (fromMaybe) -import Database.SQLite.Simple (Only (..)) import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +#endif saveAppSettings :: DB.Connection -> AppSettings -> IO () saveAppSettings db appSettings = do diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 49f5656cb0..d8c154f1e0 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -25,8 +26,6 @@ import Control.Monad.IO.Class import Data.Bitraversable (bitraverse) import Data.Int (Int64) import Data.Maybe (catMaybes, fromMaybe) -import Database.SQLite.Simple (Only (..), (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files @@ -36,8 +35,16 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (ConnId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (eitherToMaybe) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif getChatLockEntity :: DB.Connection -> AgentConnId -> ExceptT StoreError IO ChatLockEntity getChatLockEntity db agentConnId = do @@ -110,40 +117,42 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do |] (userId, contactId) toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) = + toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData)) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do - gm <- ExceptT $ firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? - |] - (groupMemberId, userId, userContactId) + gm <- + ExceptT $ + firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + |] + (groupMemberId, userId, userContactId) liftIO $ bitraverse (addGroupChatTags db) pure gm toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) toGroupAndMember c (groupInfoRow :. memberRow) = @@ -212,7 +221,7 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? ORDER BY conn_ord DESC, created_at DESC LIMIT 1 - ) + ) c |] (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 80172fc1eb..cd7a87b443 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} @@ -93,8 +94,6 @@ import Data.Int (Int64) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -102,11 +101,19 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util ((<$$>)) import Simplex.Messaging.Version +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif getPendingContactConnection :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO PendingContactConnection getPendingContactConnection db userId connId = do @@ -160,9 +167,9 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) - :. (customUserProfileId, isJust groupLinkId, groupLinkId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, chatV, pqSup, pqSup) + ( (userId, acId, pccConnStatus, ConnContact, BI True, cReqHash, xContactId) + :. (customUserProfileId, BI (isJust groupLinkId), groupLinkId) + :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} @@ -183,26 +190,27 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash = do getContactByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> IO (Maybe Contact) getContactByConnReqHash db vr user@User {userId} cReqHash = do - ct_ <- maybeFirstRow (toContact vr user []) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - JOIN connections c ON c.contact_id = ct.contact_id - WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 - |] - (userId, cReqHash, CSActive) + ct_ <- + maybeFirstRow (toContact vr user []) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + |] + (userId, cReqHash, CSActive) mapM (addDirectChatTags db) ct_ createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection @@ -218,8 +226,8 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile created_at, updated_at, to_subscribe, conn_chat_version, pq_support, pq_encryption) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId) - :. (createdAt, createdAt, subMode == SMOnlyCreate, chatV, pqSup, pqSup) + ( (userId, acId, cReq, pccConnStatus, ConnContact, BI contactConnInitiated, customUserProfileId) + :. (createdAt, createdAt, BI (subMode == SMOnlyCreate), chatV, pqSup, pqSup) ) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -342,31 +350,33 @@ deleteContactProfile_ db userId contactId = deleteUnusedProfile_ :: DB.Connection -> UserId -> ProfileId -> IO () deleteUnusedProfile_ db userId profileId = - DB.executeNamed + DB.execute db [sql| DELETE FROM contact_profiles - WHERE user_id = :user_id AND contact_profile_id = :profile_id + WHERE user_id = ? AND contact_profile_id = ? AND 1 NOT IN ( SELECT 1 FROM connections - WHERE user_id = :user_id AND custom_user_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contacts - WHERE user_id = :user_id AND contact_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contact_requests - WHERE user_id = :user_id AND contact_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND contact_profile_id = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM group_members - WHERE user_id = :user_id - AND (member_profile_id = :profile_id OR contact_profile_id = :profile_id) + WHERE user_id = ? + AND (member_profile_id = ? OR contact_profile_id = ?) LIMIT 1 ) |] - [":user_id" := userId, ":profile_id" := profileId] + ( (userId, profileId, userId, profileId, userId, profileId) + :. (userId, profileId, userId, profileId, profileId) + ) updateContactProfile :: DB.Connection -> User -> Contact -> Profile -> ExceptT StoreError IO Contact updateContactProfile db user@User {userId} c p' @@ -465,14 +475,14 @@ updateContactUsed db User {userId} Contact {contactId} = do updateContactUnreadChat :: DB.Connection -> User -> Contact -> Bool -> IO () updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (unreadChat, updatedAt, userId, contactId) + DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (BI unreadChat, updatedAt, userId, contactId) setUserChatsRead :: DB.Connection -> User -> IO () setUserChatsRead db User {userId} = do updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (False, updatedAt, userId, True) - DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (False, updatedAt, userId, True) - DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (False, updatedAt, userId, True) + DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) + DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) + DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ?" (BI False, updatedAt, userId, BI True) DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND item_status = ?" (CISRcvRead, updatedAt, userId, CISRcvNew) updateContactStatus :: DB.Connection -> User -> Contact -> ContactStatus -> IO Contact @@ -491,7 +501,7 @@ updateContactStatus db User {userId} ct@Contact {contactId} contactStatus = do updateGroupUnreadChat :: DB.Connection -> User -> GroupInfo -> Bool -> IO () updateGroupUnreadChat db User {userId} GroupInfo {groupId} unreadChat = do updatedAt <- getCurrentTime - DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (unreadChat, updatedAt, userId, groupId) + DB.execute db "UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (BI unreadChat, updatedAt, userId, groupId) setConnectionVerified :: DB.Connection -> User -> Int64 -> Maybe Text -> IO () setConnectionVerified db User {userId} connId code = do @@ -635,40 +645,42 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact created_at, updated_at, xcontact_id, pq_support) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ( (userContactLinkId, invId, minV, maxV, profileId, ldn, userId) + ( (userContactLinkId, Binary invId, minV, maxV, profileId, ldn, userId) :. (currentTs, currentTs, xContactId_, pqSup) ) insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) getContact' xContactId = do - ct_ <- maybeFirstRow (toContact vr user []) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 - |] - (userId, xContactId) + ct_ <- + maybeFirstRow (toContact vr user []) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + |] + (userId, xContactId) mapM (addDirectChatTags db) ct_ getGroupInfo' :: XContactId -> IO (Maybe GroupInfo) getGroupInfo' xContactId = do - g_ <- maybeFirstRow (toGroupInfo vr userContactId []) $ - DB.query - db - (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") - (xContactId, userId, userContactId) + g_ <- + maybeFirstRow (toGroupInfo vr userContactId []) $ + DB.query + db + (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") + (xContactId, userId, userContactId) mapM (addGroupChatTags db) g_ getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) getContactRequestByXContactId xContactId = @@ -702,7 +714,7 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (invId, pqSup, minV, maxV, currentTs, userId, cReqId) + (Binary invId, pqSup, minV, maxV, currentTs, userId, cReqId) else withLocalDisplayName db userId displayName $ \ldn -> Right <$> do DB.execute @@ -712,7 +724,7 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (invId, pqSup, minV, maxV, ldn, currentTs, userId, cReqId) + (Binary invId, pqSup, minV, maxV, ldn, currentTs, userId, cReqId) safeDeleteLDN db user oldLdn where updateProfile currentTs = @@ -803,7 +815,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} DB.execute db "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id, contact_used) VALUES (?,?,?,?,?,?,?,?,?,?)" - (userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId, contactUsed) + (userId, localDisplayName, profileId, BI True, userPreferences, createdAt, createdAt, createdAt, xContactId, BI contactUsed) contactId <- insertedRowId db DB.execute db "UPDATE contact_requests SET contact_id = ? WHERE user_id = ? AND local_display_name = ?" (contactId, userId, localDisplayName) conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId ConnNew connChatVersion cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode pqSup @@ -841,7 +853,7 @@ updateContactAccepted db User {userId} Contact {contactId} contactUsed = DB.execute db "UPDATE contacts SET contact_used = ? WHERE user_id = ? AND contact_id = ?" - (contactUsed, userId, contactId) + (BI contactUsed, userId, contactId) getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName db User {userId} cName = @@ -882,12 +894,12 @@ getContact_ db vr user@User {userId} contactId deleted = do WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id ORDER BY cc_conn_status_ord DESC, cc_created_at DESC LIMIT 1 - ) + ) cc ) OR c.connection_id IS NULL ) |] - (userId, contactId, deleted, ConnReady, ConnSndReady) + (userId, contactId, BI deleted, ConnReady, ConnSndReady) getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User getUserByContactRequestId db contactRequestId = @@ -897,16 +909,16 @@ getUserByContactRequestId db contactRequestId = getPendingContactConnections :: DB.Connection -> User -> IO [PendingContactConnection] getPendingContactConnections db User {userId} = do map toPendingContactConnection - <$> DB.queryNamed + <$> DB.query db [sql| SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at FROM connections - WHERE user_id = :user_id - AND conn_type = :conn_type + WHERE user_id = ? + AND conn_type = ? AND contact_id IS NULL |] - [":user_id" := userId, ":conn_type" := ConnContact] + (userId, ConnContact) getContactConnections :: DB.Connection -> VersionRangeChat -> UserId -> Contact -> IO [Connection] getContactConnections db vr userId Contact {contactId} = @@ -945,9 +957,13 @@ getConnectionById db vr User {userId} connId = ExceptT $ do getConnectionsContacts :: DB.Connection -> [ConnId] -> IO [ContactRef] getConnectionsContacts db agentConnIds = do - DB.execute_ db "DROP TABLE IF EXISTS temp.conn_ids" - DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" - DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds + DB.execute_ db "DROP TABLE IF EXISTS temp_conn_ids" +#if defined(dbPostgres) + DB.execute_ db "CREATE TABLE temp_conn_ids (conn_id BYTEA)" +#else + DB.execute_ db "CREATE TABLE temp_conn_ids (conn_id BLOB)" +#endif + DB.executeMany db "INSERT INTO temp_conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds conns <- map toContactRef <$> DB.query @@ -956,12 +972,12 @@ getConnectionsContacts db agentConnIds = do SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name FROM contacts ct JOIN connections c ON c.contact_id = ct.contact_id - WHERE c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) + WHERE c.agent_conn_id IN (SELECT conn_id FROM temp_conn_ids) AND c.conn_type = ? AND ct.deleted = 0 |] (Only ConnContact) - DB.execute_ db "DROP TABLE temp.conn_ids" + DB.execute_ db "DROP TABLE temp_conn_ids" pure conns where toContactRef :: (ContactId, Int64, ConnId, ContactName) -> ContactRef @@ -986,7 +1002,7 @@ updateConnectionStatus_ db connId connStatus = do updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateContactSettings db User {userId} contactId ChatSettings {enableNtfs, sendRcpts, favorite} = - DB.execute db "UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ?" (enableNtfs, sendRcpts, favorite, userId, contactId) + DB.execute db "UPDATE contacts SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND contact_id = ?" (enableNtfs, BI <$> sendRcpts, BI favorite, userId, contactId) setConnConnReqInv :: DB.Connection -> User -> Int64 -> ConnReqInvitation -> IO () setConnConnReqInv db User {userId} connId connReq = do @@ -1025,7 +1041,7 @@ setContactUIThemes db User {userId} Contact {contactId} uiThemes = do setContactChatDeleted :: DB.Connection -> User -> Contact -> Bool -> IO () setContactChatDeleted db User {userId} Contact {contactId} chatDeleted = do updatedAt <- getCurrentTime - DB.execute db "UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (chatDeleted, updatedAt, userId, contactId) + DB.execute db "UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (BI chatDeleted, updatedAt, userId, contactId) updateDirectChatTags :: DB.Connection -> ContactId -> [ChatTagId] -> IO () updateDirectChatTags db contactId tIds = do diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index e4390decf1..95e169e400 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -96,9 +97,6 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay) import Data.Type.Equality import Data.Word (Word32) -import Database.SQLite.Simple (Only (..), (:.) (..)) -import Database.SQLite.Simple.QQ (sql) -import Database.SQLite.Simple.ToField (ToField) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol @@ -110,7 +108,8 @@ import Simplex.Chat.Types import Simplex.Chat.Util (week) import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -118,6 +117,15 @@ import Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Version import System.FilePath (takeFileName) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +import Database.PostgreSQL.Simple.ToField (ToField) +#else +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +import Database.SQLite.Simple.ToField (ToField) +#endif getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers db User {userId} = do @@ -283,7 +291,7 @@ createSndFTDescrXFTP db User {userId} m Connection {connId} FileTransferMeta {fi DB.execute db "INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (userId, fileDescrText, fileDescrPartNo, fileDescrComplete, currentTs, currentTs) + (userId, fileDescrText, fileDescrPartNo, BI fileDescrComplete, currentTs, currentTs) fileDescrId <- insertedRowId db DB.execute db @@ -308,7 +316,7 @@ updateSndFTDescrXFTP db user@User {userId} sft@SndFileTransfer {fileId, fileDesc SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ?, updated_at = ? WHERE user_id = ? AND file_descr_id = ? |] - (rfdText, 1 :: Int, True, currentTs, userId, fileDescrId) + (rfdText, 1 :: Int, BI True, currentTs, userId, fileDescrId) updateCIFileStatus db user fileId $ CIFSSndTransfer 1 1 updateSndFileStatus db sft FSConnected @@ -574,7 +582,7 @@ createRcvFD_ db userId currentTs FileDescr {fileDescrText, fileDescrPartNo, file DB.execute db "INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (userId, fileDescrText, fileDescrPartNo, fileDescrComplete, currentTs, currentTs) + (userId, fileDescrText, fileDescrPartNo, BI fileDescrComplete, currentTs, currentTs) insertedRowId db pure RcvFileDescr {fileDescrId, fileDescrPartNo, fileDescrText, fileDescrComplete} @@ -607,7 +615,7 @@ appendRcvFD db userId fileId fd@FileDescr {fileDescrText, fileDescrPartNo, fileD SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ? WHERE file_descr_id = ? |] - (fileDescrText', fileDescrPartNo, fileDescrComplete, fileDescrId) + (fileDescrText', fileDescrPartNo, BI fileDescrComplete, fileDescrId) pure RcvFileDescr {fileDescrId, fileDescrText = fileDescrText', fileDescrPartNo, fileDescrComplete} getRcvFileDescrByRcvFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO RcvFileDescr @@ -650,8 +658,8 @@ getRcvFileDescrBySndFileId_ db fileId = |] (Only fileId) -toRcvFileDescr :: (Int64, Text, Int, Bool) -> RcvFileDescr -toRcvFileDescr (fileDescrId, fileDescrText, fileDescrPartNo, fileDescrComplete) = +toRcvFileDescr :: (Int64, Text, Int, BoolInt) -> RcvFileDescr +toRcvFileDescr (fileDescrId, fileDescrText, fileDescrPartNo, BI fileDescrComplete) = RcvFileDescr {fileDescrId, fileDescrText, fileDescrPartNo, fileDescrComplete} updateRcvFileAgentId :: DB.Connection -> FileTransferId -> Maybe AgentRcvFileId -> IO () @@ -682,8 +690,8 @@ getRcvFileTransfer_ db userId fileId = do FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN connections c ON r.file_id = c.rcv_file_id - LEFT JOIN contacts cs USING (contact_id) - LEFT JOIN group_members m USING (group_member_id) + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = r.group_member_id WHERE f.user_id = ? AND f.file_id = ? |] (userId, fileId) @@ -692,9 +700,9 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, Bool, Bool) :. (Maybe Int64, Maybe AgentConnId) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe Int64, Maybe AgentConnId) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays) :. (connId_, agentConnId_)) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (connId_, agentConnId_)) = case contactName_ <|> memberName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> @@ -717,7 +725,7 @@ getRcvFileTransfer_ db userId fileId = do rfi_ = case (filePath_, connId_, agentConnId_) of (Just filePath, connId, agentConnId) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId} _ -> pure Nothing - cancelled = fromMaybe False cancelled_ + cancelled = maybe False unBI cancelled_ acceptRcvFileTransfer :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do @@ -726,7 +734,7 @@ acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus f DB.execute db "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?)" - (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, subMode == SMOnlyCreate) + (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, BI (subMode == SMOnlyCreate)) connId <- insertedRowId db setCommandConnId db user cmdId connId runExceptT $ getChatItemByFileId db vr user fileId @@ -763,7 +771,7 @@ acceptRcvFT_ db User {userId} fileId filePath userApprovedRelays rcvFileInline c DB.execute db "UPDATE rcv_files SET user_approved_relays = ?, rcv_file_inline = ?, file_status = ?, updated_at = ? WHERE file_id = ?" - (userApprovedRelays, rcvFileInline, FSAccepted, currentTs, fileId) + (BI userApprovedRelays, rcvFileInline, FSAccepted, currentTs, fileId) setRcvFileToReceive :: DB.Connection -> FileTransferId -> Bool -> Maybe CryptoFileArgs -> IO () setRcvFileToReceive db fileId userApprovedRelays cfArgs_ = do @@ -775,7 +783,7 @@ setRcvFileToReceive db fileId userApprovedRelays cfArgs_ = do SET to_receive = 1, user_approved_relays = ?, updated_at = ? WHERE file_id = ? |] - (userApprovedRelays, currentTs, fileId) + (BI userApprovedRelays, currentTs, fileId) forM_ cfArgs_ $ \cfArgs -> setFileCryptoArgs_ db fileId cfArgs currentTs setFileCryptoArgs :: DB.Connection -> FileTransferId -> CryptoFileArgs -> IO () @@ -928,8 +936,8 @@ getSndFileTransfers_ db userId fileId = FROM snd_files s JOIN files f USING (file_id) JOIN connections c USING (connection_id) - LEFT JOIN contacts cs USING (contact_id) - LEFT JOIN group_members m USING (group_member_id) + LEFT JOIN contacts cs ON cs.contact_id = f.contact_id + LEFT JOIN group_members m ON m.group_member_id = s.group_member_id WHERE f.user_id = ? AND f.file_id = ? |] (userId, fileId) @@ -955,11 +963,11 @@ getFileTransferMeta_ db userId fileId = |] (userId, fileId) where - fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, Bool, Maybe Text, Maybe Bool, Maybe FileTransferId) -> FileTransferMeta - fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) = + fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe AgentSndFileId, BoolInt, Maybe Text, Maybe BoolInt, Maybe FileTransferId) -> FileTransferMeta + fileTransferMeta (fileName, fileSize, chunkSize, filePath, fileKey, fileNonce, fileInline, aSndFileId_, BI agentSndFileDeleted, privateSndFileDescr, cancelled_, xftpRedirectFor) = let cryptoArgs = CFArgs <$> fileKey <*> fileNonce xftpSndFile = (\fId -> XFTPSndFile {agentSndFileId = fId, privateSndFileDescr, agentSndFileDeleted, cryptoArgs}) <$> aSndFileId_ - in FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = fromMaybe False cancelled_} + in FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, fileSize, chunkSize, filePath, fileInline, cancelled = maybe False unBI cancelled_} lookupFileTransferRedirectMeta :: DB.Connection -> User -> Int64 -> IO [FileTransferMeta] lookupFileTransferRedirectMeta db User {userId} fileId = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 004c297073..2e0fca19ca 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} @@ -141,8 +142,6 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), Query (..), (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Protocol (groupForwardVersion) import Simplex.Chat.Store.Direct @@ -152,16 +151,24 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, UserId) -import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = @@ -175,7 +182,7 @@ createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} DB.execute db "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, True, currentTs, currentTs) + (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, memberRole, BI True, currentTs, currentTs) userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff @@ -254,41 +261,42 @@ setGroupLinkMemberRole db User {userId} userContactLinkId memberRole = getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId vr = do - gm <- ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? - |] - (userId, groupMemberId, userId, userContactId) + gm <- + ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + |] + (userId, groupMemberId, userId, userContactId) liftIO $ bitraverse (addGroupChatTags db) pure gm where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) @@ -319,7 +327,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc created_at, updated_at, chat_ts, user_member_profile_sent_at) VALUES (?,?,?,?,?,?,?,?) |] - (ldn, userId, profileId, True, currentTs, currentTs, currentTs, currentTs) + (ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs vr @@ -387,7 +395,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, connRequest, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange vr peerChatVRange GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange @@ -532,7 +540,7 @@ createGroupInvitedViaLink created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) + ((profileId, localDisplayName, customUserProfileId, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db insertHost_ currentTs groupId = do let fromMemberProfile = profileFromName fromMemberName @@ -632,24 +640,28 @@ getUserGroups db vr user@User {userId} = do getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do - g_ <- map (toGroupInfo vr userContactId []) - <$> DB.query - db - [sql| - SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, - mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, - mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu USING (group_id) - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - AND (gp.display_name LIKE '%' || ? || '%' OR gp.full_name LIKE '%' || ? || '%' OR gp.description LIKE '%' || ? || '%') - |] - (userId, userContactId, search, search, search) + g_ <- + map (toGroupInfo vr userContactId []) + <$> DB.query + db + [sql| + SELECT + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, + mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences + FROM groups g + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu USING (group_id) + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' + ) + |] + (userId, userContactId, search, search, search) mapM (addGroupChatTags db) g_ where search = fromMaybe "" search_ @@ -958,7 +970,7 @@ createBusinessRequestGroup created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_xcontact_id) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (profileId, localDisplayName, userId, True, currentTs, currentTs, currentTs, currentTs, BCCustomer, xContactId) + (profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs, BCCustomer, xContactId) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs vr @@ -1193,57 +1205,47 @@ createIntroductions db chatV members toMember = do updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO () updateIntroStatus db introId introStatus = do currentTs <- getCurrentTime - DB.executeNamed + DB.execute db [sql| UPDATE group_member_intros - SET intro_status = :intro_status, updated_at = :updated_at - WHERE group_member_intro_id = :intro_id + SET intro_status = ?, updated_at = ? + WHERE group_member_intro_id = ? |] - [":intro_status" := introStatus, ":updated_at" := currentTs, ":intro_id" := introId] + (introStatus, currentTs, introId) saveIntroInvitation :: DB.Connection -> GroupMember -> GroupMember -> IntroInvitation -> ExceptT StoreError IO GroupMemberIntro saveIntroInvitation db reMember toMember introInv@IntroInvitation {groupConnReq} = do intro <- getIntroduction db reMember toMember liftIO $ do currentTs <- getCurrentTime - DB.executeNamed + DB.execute db [sql| UPDATE group_member_intros - SET intro_status = :intro_status, - group_queue_info = :group_queue_info, - direct_queue_info = :direct_queue_info, - updated_at = :updated_at - WHERE group_member_intro_id = :intro_id + SET intro_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_intro_id = ? |] - [ ":intro_status" := GMIntroInvReceived, - ":group_queue_info" := groupConnReq, - ":direct_queue_info" := directConnReq introInv, - ":updated_at" := currentTs, - ":intro_id" := introId intro - ] + (GMIntroInvReceived, groupConnReq, directConnReq introInv, currentTs, introId intro) pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> IO () saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} = do currentTs <- getCurrentTime - DB.executeNamed + DB.execute db [sql| UPDATE group_members - SET member_status = :member_status, - group_queue_info = :group_queue_info, - direct_queue_info = :direct_queue_info, - updated_at = :updated_at - WHERE group_member_id = :group_member_id + SET member_status = ?, + group_queue_info = ?, + direct_queue_info = ?, + updated_at = ? + WHERE group_member_id = ? |] - [ ":member_status" := GSMemIntroInvited, - ":group_queue_info" := groupConnReq, - ":direct_queue_info" := directConnReq, - ":updated_at" := currentTs, - ":group_member_id" := groupMemberId - ] + (GSMemIntroInvited, groupConnReq, directConnReq, currentTs, groupMemberId) getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro getIntroduction db reMember toMember = ExceptT $ do @@ -1364,14 +1366,14 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = pure contactId updateMember_ :: Int64 -> UTCTime -> IO () updateMember_ contactId ts = - DB.executeNamed + DB.execute db [sql| UPDATE group_members - SET contact_id = :contact_id, updated_at = :updated_at - WHERE group_member_id = :group_member_id + SET contact_id = ?, updated_at = ? + WHERE group_member_id = ? |] - [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] + (contactId, ts, groupMemberId) createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> VersionChat -> VersionRangeChat -> Maybe Int64 -> Int -> UTCTime -> SubscriptionMode -> IO Connection createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = @@ -1379,42 +1381,43 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do - gm_ <- maybeFirstRow toGroupAndMember $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, - g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, - -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, - -- GroupInfo {membership = GroupMember {memberProfile}} - pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contacts ct ON ct.contact_id = m.contact_id - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 - |] - (userId, userId, contactId, userContactId) + gm_ <- + maybeFirstRow toGroupAndMember $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, + -- via GroupMember + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 + |] + (userId, userId, contactId, userContactId) mapM (bitraverse (addGroupChatTags db) pure) gm_ where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) @@ -1650,7 +1653,7 @@ createSentProbe db gVar userId to = DB.execute db "INSERT INTO sent_probes (contact_id, group_member_id, probe, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (ctId, gmId, probe, userId, currentTs, currentTs) + (ctId, gmId, Binary probe, userId, currentTs, currentTs) (Probe probe,) <$> insertedRowId db createSentProbeHash :: DB.Connection -> UserId -> Int64 -> ContactOrMember -> IO () @@ -1676,13 +1679,13 @@ matchReceivedProbe db vr user@User {userId} from (Probe probe) = do LEFT JOIN groups g ON g.group_id = m.group_id WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL |] - (userId, probeHash) + (userId, Binary probeHash) currentTs <- getCurrentTime let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) + (ctId, gmId, Binary probe, Binary probeHash, userId, currentTs, currentTs) let cgmIds' = filterFirstContactId cgmIds catMaybes <$> mapM (getContactOrMember_ db vr user) cgmIds' where @@ -1708,13 +1711,13 @@ matchReceivedProbeHash db vr user@User {userId} from (ProbeHash probeHash) = do LEFT JOIN groups g ON g.group_id = m.group_id WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NOT NULL |] - (userId, probeHash) + (userId, Binary probeHash) currentTs <- getCurrentTime let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (ctId, gmId, probeHash, userId, currentTs, currentTs) + (ctId, gmId, Binary probeHash, userId, currentTs, currentTs) pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db vr user cgmIds matchSentProbe :: DB.Connection -> VersionRangeChat -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) @@ -1736,7 +1739,7 @@ matchSentProbe db vr user@User {userId} _from (Probe probe) = do WHERE s.user_id = ? AND s.probe = ? AND (h.contact_id = ? OR h.group_member_id = ?) |] - (userId, probe, ctId, gmId) + (userId, Binary probe, ctId, gmId) getContactOrMember_ :: DB.Connection -> VersionRangeChat -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) getContactOrMember_ db vr user ids = @@ -1777,22 +1780,18 @@ mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keep db "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" (toContactId, currentTs, fromContactId, userId) - DB.executeNamed + DB.execute db [sql| UPDATE group_members - SET contact_id = :to_contact_id, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), - updated_at = :updated_at - WHERE contact_id = :from_contact_id - AND user_id = :user_id + SET contact_id = ?, + local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = ?), + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = ?), + updated_at = ? + WHERE contact_id = ? + AND user_id = ? |] - [ ":to_contact_id" := toContactId, - ":from_contact_id" := fromContactId, - ":user_id" := userId, - ":updated_at" := currentTs - ] + (toContactId, toContactId, toContactId, currentTs, fromContactId, userId) deleteContactProfile_ db userId fromContactId DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) deleteUnusedDisplayName_ db userId fromLDN @@ -1867,41 +1866,44 @@ associateContactWithMemberRecord deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = - DB.executeNamed + DB.execute db [sql| DELETE FROM display_names - WHERE user_id = :user_id AND local_display_name = :local_display_name + WHERE user_id = ? AND local_display_name = ? AND 1 NOT IN ( SELECT 1 FROM users - WHERE local_display_name = :local_display_name LIMIT 1 + WHERE local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contacts - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM groups - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM group_members - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM user_contact_links - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contact_requests - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM contact_requests - WHERE user_id = :user_id AND local_display_name = :local_display_name LIMIT 1 + WHERE user_id = ? AND local_display_name = ? LIMIT 1 ) |] - [":user_id" := userId, ":local_display_name" := localDisplayName] + ( (userId, localDisplayName, localDisplayName, userId, localDisplayName, userId, localDisplayName) + :. (userId, localDisplayName, userId, localDisplayName, userId, localDisplayName) + :. (userId, localDisplayName) + ) deleteOldProbes :: DB.Connection -> UTCTime -> IO () deleteOldProbes db createdAtCutoff = do @@ -1911,7 +1913,7 @@ deleteOldProbes db createdAtCutoff = do updateGroupSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateGroupSettings db User {userId} groupId ChatSettings {enableNtfs, sendRcpts, favorite} = - DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, sendRcpts, favorite, userId, groupId) + DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, BI <$> sendRcpts, BI favorite, userId, groupId) updateGroupMemberSettings :: DB.Connection -> User -> GroupId -> GroupMemberId -> GroupMemberSettings -> IO () updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {showMessages} = do @@ -1923,7 +1925,7 @@ updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {sh SET show_messages = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND group_member_id = ? |] - (showMessages, currentTs, userId, gId, gMemberId) + (BI showMessages, currentTs, userId, gId, gMemberId) updateGroupMemberBlocked :: DB.Connection -> User -> GroupId -> GroupMemberId -> MemberRestrictionStatus -> IO () updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do @@ -2025,8 +2027,8 @@ createMemberContact contact_group_member_id, contact_grp_inv_sent, created_at, updated_at, chat_ts ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, localDisplayName, memberContactProfileId, True, userPreferences, True) - :. (groupMemberId, False, currentTs, currentTs, currentTs) + ( (userId, localDisplayName, memberContactProfileId, BI True, userPreferences, BI True) + :. (groupMemberId, BI False, currentTs, currentTs, currentTs) ) contactId <- insertedRowId db DB.execute @@ -2041,8 +2043,8 @@ createMemberContact conn_chat_version, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, True, contactId, customUserProfileId) - :. (connChatVersion, minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) + ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, BI True, contactId, customUserProfileId) + :. (connChatVersion, minV, maxV, currentTs, currentTs, BI (subMode == SMOnlyCreate)) ) connId <- insertedRowId db let ctConn = @@ -2093,7 +2095,7 @@ setContactGrpInvSent db Contact {contactId} xGrpDirectInvSent = do DB.execute db "UPDATE contacts SET contact_grp_inv_sent = ?, updated_at = ? WHERE contact_id = ?" - (xGrpDirectInvSent, currentTs, contactId) + (BI xGrpDirectInvSent, currentTs, contactId) createMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> GroupMember -> Connection -> SubscriptionMode -> IO (Contact, GroupMember) createMemberContactInvited @@ -2123,7 +2125,7 @@ createMemberContactInvited created_at, updated_at, chat_ts ) VALUES (?,?,?,?,?,?,?,?,?) |] - ( (userId, memberLDN, memberContactProfileId, True, userPreferences, True) + ( (userId, memberLDN, memberContactProfileId, BI True, userPreferences, BI True) :. (currentTs, currentTs, currentTs) ) contactId <- insertedRowId db @@ -2175,7 +2177,7 @@ createMemberContactConn_ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, ConnJoined, ConnContact, contactId, customUserProfileId) - :. (connChatVersion, minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) + :. (connChatVersion, minV, maxV, currentTs, currentTs, BI (subMode == SMOnlyCreate)) ) connId <- insertedRowId db setCommandConnId db user cmdId connId @@ -2244,7 +2246,7 @@ updateContactMemberProfile db user@User {userId} m ct@Contact {contactId} p' getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool getXGrpLinkMemReceived db mId = - ExceptT . firstRow fromOnly (SEGroupMemberNotFound mId) $ + ExceptT . firstRow fromOnlyBI (SEGroupMemberNotFound mId) $ DB.query db "SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ?" (Only mId) setXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> Bool -> IO () @@ -2253,7 +2255,7 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do DB.execute db "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" - (xGrpLinkMemReceived, currentTs, mId) + (BI xGrpLinkMemReceived, currentTs, mId) createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 03ba45f23f..b5c2acc36a 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} @@ -140,8 +141,6 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (FromRow, NamedParam (..), Only (..), Query, ToRow, (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), ContentFilter (..), PaginationByTime (..)) import Simplex.Chat.Markdown import Simplex.Chat.Messages @@ -160,6 +159,13 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (FromRow, Only (..), Query, ToRow, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (FromRow, Only (..), Query, ToRow, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif deleteContactCIs :: DB.Connection -> User -> Contact -> IO () deleteContactCIs db user@User {userId} ct@Contact {contactId} = do @@ -200,7 +206,7 @@ createNewSndMessage db gVar connOrGroupId chatMsgEvent encodeMessage = shared_msg_id, shared_msg_id_user, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?) |] - (MDSnd, toCMEventTag chatMsgEvent, msgBody, connId_, groupId_, sharedMsgId, Just True, createdAt, createdAt) + (MDSnd, toCMEventTag chatMsgEvent, DB.Binary msgBody, connId_, groupId_, DB.Binary sharedMsgId, Just (BI True), createdAt, createdAt) msgId <- insertedRowId db pure $ Right SndMessage {msgId, sharedMsgId = SharedMsgId sharedMsgId, msgBody} where @@ -285,7 +291,7 @@ createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody} share (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) + (MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) msgId <- insertedRowId db pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} @@ -415,13 +421,14 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, msgId_) :. idsRow :. itemRow :. quoteRow :. forwardedFromRow) + ((userId, msgId_) :. idsRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where - itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe Bool) :. (Maybe Int, Maybe UTCTime) - itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, justTrue live) :. ciTimedRow timed + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, Text, CIStatus d, Maybe MsgContentTag, Maybe SharedMsgId, Maybe GroupMemberId) :. (UTCTime, UTCTime, Maybe BoolInt) :. (Maybe Int, Maybe UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, toCIContentTag ciContent, ciContentToText ciContent, ciCreateStatus ciContent, msgContentTag <$> ciMsgContent ciContent, sharedMsgId, forwardedByMember) :. (createdAt, createdAt, BI <$> (justTrue live)) :. ciTimedRow timed + quoteRow' = let (a, b, c, d, e) = quoteRow in (a, b, c, BI <$> d, e) idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) @@ -452,11 +459,11 @@ getChatItemQuote_ :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirectio getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = case chatDirection of CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) - CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {memberId = senderMemberId} -> + CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {groupMemberId = senderGMId, memberId = senderMemberId} -> case memberId of Just mId | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId - | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId mId + | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId senderGMId | otherwise -> getGroupChatItemQuote_ groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where @@ -468,7 +475,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe DB.query db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ?" - (userId, contactId, msgId, userSent) + (userId, contactId, msgId, BI userSent) where ciQuoteDirect :: Maybe ChatItemId -> CIQuote 'CTDirect ciQuoteDirect = (`ciQuote` if userSent then CIQDirectSnd else CIQDirectRcv) @@ -479,17 +486,17 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id IS NULL" (userId, groupId, msgId, MDSnd) - getGroupChatItemId_ :: Int64 -> MemberId -> IO (Maybe ChatItemId) - getGroupChatItemId_ groupId mId = + getGroupChatItemId_ :: Int64 -> GroupMemberId -> IO (Maybe ChatItemId) + getGroupChatItemId_ groupId groupMemberId = maybeFirstRow fromOnly $ DB.query db "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ?" - (userId, groupId, msgId, MDRcv, mId) + (userId, groupId, msgId, MDRcv, groupMemberId) getGroupChatItemQuote_ :: Int64 -> MemberId -> IO (CIQuote 'CTGroup) getGroupChatItemQuote_ groupId mId = do ciQuoteGroup - <$> DB.queryNamed + <$> DB.query db [sql| SELECT i.chat_item_id, @@ -503,10 +510,10 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe LEFT JOIN chat_items i ON i.user_id = m.user_id AND i.group_id = m.group_id AND m.group_member_id = i.group_member_id - AND i.shared_msg_id = :msg_id - WHERE m.user_id = :user_id AND m.group_id = :group_id AND m.member_id = :member_id + AND i.shared_msg_id = ? + WHERE m.user_id = ? AND m.group_id = ? AND m.member_id = ? |] - [":user_id" := userId, ":group_id" := groupId, ":member_id" := mId, ":msg_id" := msgId] + (msgId, userId, groupId, mId) where ciQuoteGroup :: [Only (Maybe ChatItemId) :. GroupMemberRow] -> CIQuote 'CTGroup ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing @@ -564,14 +571,21 @@ findDirectChatPreviews_ db User {userId} pagination clq = ACPD SCTDirect $ DirectChatPD ts contactId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT ct.contact_id, ct.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), 0, COALESCE(ChatStats.MinUnread, 0), ct.unread_chat + SELECT + ct.contact_id, + ct.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.contact_id = ct.contact_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + ct.unread_chat FROM contacts ct - LEFT JOIN ( - SELECT contact_id, chat_item_id, MAX(created_at) - FROM chat_items - WHERE user_id = ? AND contact_id IS NOT NULL - GROUP BY contact_id - ) LastItems ON LastItems.contact_id = ct.contact_id LEFT JOIN ( SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items @@ -582,58 +596,61 @@ findDirectChatPreviews_ db User {userId} pagination clq = baseParams = (userId, userId, CISRcvNew) getPreviews = case clq of CLQFilters {favorite = False, unread = False} -> do - let q = baseQuery <> " WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used" + let q = baseQuery <> " WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1" p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = True, unread = False} -> do let q = baseQuery + <> " " <> [sql| - WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 AND ct.favorite = 1 - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = False, unread = True} -> do let q = baseQuery + <> " " <> [sql| - WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 AND (ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = True, unread = True} -> do let q = baseQuery + <> " " <> [sql| - WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 AND (ct.favorite = 1 OR ct.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQSearch {search} -> do let q = baseQuery + <> " " <> [sql| JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used + WHERE ct.user_id = ? AND ct.is_user = 0 AND ct.deleted = 0 AND ct.contact_used = 1 AND ( - ct.local_display_name LIKE '%' || ? || '%' - OR cp.display_name LIKE '%' || ? || '%' - OR cp.full_name LIKE '%' || ? || '%' - OR cp.local_alias LIKE '%' || ? || '%' + LOWER(ct.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(cp.local_alias) LIKE '%' || LOWER(?) || '%' ) - |] + |] p = baseParams :. (userId, search, search, search, search) - queryWithPagination db q p pagination - -queryWithPagination :: ToRow p => DB.Connection -> Query -> p -> PaginationByTime -> IO [(ContactId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] -queryWithPagination db query params = \case - PTLast count -> DB.query db (query <> " ORDER BY ts DESC LIMIT ?") (params :. Only count) - PTAfter ts count -> DB.query db (query <> " AND ts > ? ORDER BY ts ASC LIMIT ?") (params :. (ts, count)) - PTBefore ts count -> DB.query db (query <> " AND ts < ? ORDER BY ts DESC LIMIT ?") (params :. (ts, count)) + queryWithPagination q p + queryWithPagination :: ToRow p => Query -> p -> IO [(ContactId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination query params = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY ct.chat_ts DESC LIMIT ?") (params :. Only count) + PTAfter ts count -> DB.query db (query <> " AND ct.chat_ts > ? ORDER BY ct.chat_ts ASC LIMIT ?") (params :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND ct.chat_ts < ? ORDER BY ct.chat_ts DESC LIMIT ?") (params :. (ts, count)) getDirectChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do @@ -652,14 +669,21 @@ findGroupChatPreviews_ db User {userId} pagination clq = ACPD SCTGroup $ GroupChatPD ts groupId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT g.group_id, g.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), COALESCE(ReportCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat + SELECT + g.group_id, + g.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.group_id = g.group_id + ORDER BY ci.item_ts DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + COALESCE(ReportCount.Count, 0), + COALESCE(ChatStats.MinUnread, 0), + g.unread_chat FROM groups g - LEFT JOIN ( - SELECT group_id, chat_item_id, MAX(item_ts) - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - GROUP BY group_id - ) LastItems ON LastItems.group_id = g.group_id LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items @@ -679,50 +703,59 @@ findGroupChatPreviews_ db User {userId} pagination clq = CLQFilters {favorite = False, unread = False} -> do let q = baseQuery <> " WHERE g.user_id = ?" p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = True, unread = False} -> do let q = baseQuery + <> " " <> [sql| WHERE g.user_id = ? AND g.favorite = 1 - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = False, unread = True} -> do let q = baseQuery + <> " " <> [sql| WHERE g.user_id = ? AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = True, unread = True} -> do let q = baseQuery + <> " " <> [sql| WHERE g.user_id = ? AND (g.favorite = 1 OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQSearch {search} -> do let q = baseQuery + <> " " <> [sql| JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE g.user_id = ? AND ( - g.local_display_name LIKE '%' || ? || '%' - OR gp.display_name LIKE '%' || ? || '%' - OR gp.full_name LIKE '%' || ? || '%' - OR gp.description LIKE '%' || ? || '%' + LOWER(g.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.full_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(gp.description) LIKE '%' || LOWER(?) || '%' ) - |] + |] p = baseParams :. (userId, search, search, search, search) - queryWithPagination db q p pagination + queryWithPagination q p + queryWithPagination :: ToRow p => Query -> p -> IO [(GroupId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination query params = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY g.chat_ts DESC LIMIT ?") (params :. Only count) + PTAfter ts count -> DB.query db (query <> " AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ?") (params :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ?") (params :. (ts, count)) getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do @@ -741,14 +774,21 @@ findLocalChatPreviews_ db User {userId} pagination clq = ACPD SCTLocal $ LocalChatPD ts noteFolderId lastItemId_ (toChatStats statsRow) baseQuery = [sql| - SELECT nf.note_folder_id, nf.chat_ts as ts, LastItems.chat_item_id, COALESCE(ChatStats.UnreadCount, 0), 0, COALESCE(ChatStats.MinUnread, 0), nf.unread_chat + SELECT + nf.note_folder_id, + nf.chat_ts, + ( + SELECT chat_item_id + FROM chat_items ci + WHERE ci.user_id = ? AND ci.note_folder_id = nf.note_folder_id + ORDER BY ci.created_at DESC + LIMIT 1 + ) AS chat_item_id, + COALESCE(ChatStats.UnreadCount, 0), + 0, + COALESCE(ChatStats.MinUnread, 0), + nf.unread_chat FROM note_folders nf - LEFT JOIN ( - SELECT note_folder_id, chat_item_id, MAX(created_at) - FROM chat_items - WHERE user_id = ? AND note_folder_id IS NOT NULL - GROUP BY note_folder_id - ) LastItems ON LastItems.note_folder_id = nf.note_folder_id LEFT JOIN ( SELECT note_folder_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items @@ -761,36 +801,44 @@ findLocalChatPreviews_ db User {userId} pagination clq = CLQFilters {favorite = False, unread = False} -> do let q = baseQuery <> " WHERE nf.user_id = ?" p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = True, unread = False} -> do let q = baseQuery + <> " " <> [sql| WHERE nf.user_id = ? AND nf.favorite = 1 - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = False, unread = True} -> do let q = baseQuery + <> " " <> [sql| WHERE nf.user_id = ? AND (nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQFilters {favorite = True, unread = True} -> do let q = baseQuery + <> " " <> [sql| WHERE nf.user_id = ? AND (nf.favorite = 1 OR nf.unread_chat = 1 OR ChatStats.UnreadCount > 0) - |] + |] p = baseParams :. Only userId - queryWithPagination db q p pagination + queryWithPagination q p CLQSearch {} -> pure [] + queryWithPagination :: ToRow p => Query -> p -> IO [(NoteFolderId, UTCTime, Maybe ChatItemId) :. ChatStatsRow] + queryWithPagination query params = case pagination of + PTLast count -> DB.query db (query <> " ORDER BY nf.chat_ts DESC LIMIT ?") (params :. Only count) + PTAfter ts count -> DB.query db (query <> " AND nf.chat_ts > ? ORDER BY nf.chat_ts ASC LIMIT ?") (params :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND nf.chat_ts < ? ORDER BY nf.chat_ts DESC LIMIT ?") (params :. (ts, count)) getLocalChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTLocal -> ExceptT StoreError IO AChat getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do @@ -833,9 +881,9 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex let itemDeleted' = case itemDeleted of DBCINotDeleted -> Nothing _ -> Just (CIDeleted @'CTLocal deletedTs) - itemEdited' = fromMaybe False itemEdited + itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -852,7 +900,7 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.user_contact_link_id, c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, cr.xcontact_id, cr.pq_support, p.preferences, - cr.created_at, cr.updated_at as ts, + cr.created_at, cr.updated_at, cr.peer_chat_min_version, cr.peer_chat_max_version FROM contact_requests cr JOIN connections c ON c.user_contact_link_id = cr.user_contact_link_id @@ -863,16 +911,16 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = case clq of AND uc.local_display_name = '' AND uc.group_id IS NULL AND ( - cr.local_display_name LIKE '%' || ? || '%' - OR p.display_name LIKE '%' || ? || '%' - OR p.full_name LIKE '%' || ? || '%' + LOWER(cr.local_display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.display_name) LIKE '%' || LOWER(?) || '%' + OR LOWER(p.full_name) LIKE '%' || LOWER(?) || '%' ) |] params search = (userId, userId, search, search, search) getPreviews search = case pagination of - PTLast count -> DB.query db (query <> " ORDER BY ts DESC LIMIT ?") (params search :. Only count) - PTAfter ts count -> DB.query db (query <> " AND ts > ? ORDER BY ts ASC LIMIT ?") (params search :. (ts, count)) - PTBefore ts count -> DB.query db (query <> " AND ts < ? ORDER BY ts DESC LIMIT ?") (params search :. (ts, count)) + PTLast count -> DB.query db (query <> " ORDER BY cr.updated_at DESC LIMIT ?") (params search :. Only count) + PTAfter ts count -> DB.query db (query <> " AND cr.updated_at > ? ORDER BY cr.updated_at ASC LIMIT ?") (params search :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND cr.updated_at < ? ORDER BY cr.updated_at DESC LIMIT ?") (params search :. (ts, count)) toPreview :: ContactRequestRow -> AChatPreviewData toPreview cReqRow = let cReq@UserContactRequest {updatedAt} = toContactRequest cReqRow @@ -891,7 +939,7 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of [sql| SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, - custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at as ts + custom_user_profile_id, conn_req_inv, local_alias, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? @@ -899,14 +947,14 @@ getContactConnectionChatPreviews_ db User {userId} pagination clq = case clq of AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL - AND (via_group_link = 0 || (via_group_link = 1 AND group_link_id IS NOT NULL)) - AND local_alias LIKE '%' || ? || '%' + AND (via_group_link = 0 OR (via_group_link = 1 AND group_link_id IS NOT NULL)) + AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' |] params search = (userId, ConnContact, ConnPrepared, search) getPreviews search = case pagination of - PTLast count -> DB.query db (query <> " ORDER BY ts DESC LIMIT ?") (params search :. Only count) - PTAfter ts count -> DB.query db (query <> " AND ts > ? ORDER BY ts ASC LIMIT ?") (params search :. (ts, count)) - PTBefore ts count -> DB.query db (query <> " AND ts < ? ORDER BY ts DESC LIMIT ?") (params search :. (ts, count)) + PTLast count -> DB.query db (query <> " ORDER BY updated_at DESC LIMIT ?") (params search :. Only count) + PTAfter ts count -> DB.query db (query <> " AND updated_at > ? ORDER BY updated_at ASC LIMIT ?") (params search :. (ts, count)) + PTBefore ts count -> DB.query db (query <> " AND updated_at < ? ORDER BY updated_at DESC LIMIT ?") (params search :. (ts, count)) toPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, LocalAlias, UTCTime, UTCTime) -> AChatPreviewData toPreview connRow = let conn@PendingContactConnection {updatedAt} = toPendingContactConnection connRow @@ -942,7 +990,7 @@ getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search = [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY created_at DESC, chat_item_id DESC LIMIT ? |] @@ -1006,7 +1054,7 @@ getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) ORDER BY created_at ASC, chat_item_id ASC LIMIT ? @@ -1029,7 +1077,7 @@ getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND contact_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND contact_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) ORDER BY created_at DESC, chat_item_id DESC LIMIT ? @@ -1121,7 +1169,7 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do FROM chat_items WHERE user_id = ? AND contact_id = ? AND item_status = ? AND created_at = ? AND chat_item_id > ? - ) + ) ci |] ( (userId, contactId, CISRcvNew, ciCreatedAt afterCI) :. (userId, contactId, CISRcvNew, ciCreatedAt afterCI, cChatItemId afterCI) @@ -1143,7 +1191,7 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do FROM chat_items WHERE user_id = ? AND contact_id = ? AND created_at = ? AND chat_item_id > ? - ) + ) ci |] ( (userId, contactId, ciCreatedAt afterCI) :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) @@ -1199,7 +1247,7 @@ getGroupChatItemIDs db User {userId} GroupInfo {groupId} contentFilter range cou rangeQuery :: ToRow p => Query -> p -> Query -> IO [ChatItemId] rangeQuery c p ob | null search = searchQuery "" () - | otherwise = searchQuery " AND item_text LIKE '%' || ? || '%' " (Only search) + | otherwise = searchQuery " AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' " (Only search) where searchQuery :: ToRow p' => Query -> p' -> IO [ChatItemId] searchQuery c' p' = @@ -1313,7 +1361,7 @@ getGroupMinUnreadId_ db user g contentFilter = queryUnreadGroupItems db user g contentFilter baseQuery orderLimit where baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " - orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" + orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> IO Int getGroupUnreadCount_ db user g contentFilter = @@ -1372,7 +1420,7 @@ getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do FROM chat_items WHERE user_id = ? AND group_id = ? AND item_status = ? AND item_ts = ? AND chat_item_id > ? - ) + ) ci |] ( (userId, groupId, CISRcvNew, chatItemTs afterCI) :. (userId, groupId, CISRcvNew, chatItemTs afterCI, cChatItemId afterCI) @@ -1394,7 +1442,7 @@ getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do FROM chat_items WHERE user_id = ? AND group_id = ? AND item_ts = ? AND chat_item_id > ? - ) + ) ci |] ( (userId, groupId, chatItemTs afterCI) :. (userId, groupId, chatItemTs afterCI, cChatItemId afterCI) @@ -1428,7 +1476,7 @@ getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY created_at DESC, chat_item_id DESC LIMIT ? |] @@ -1476,7 +1524,7 @@ getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count searc [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (created_at > ? OR (created_at = ? AND chat_item_id > ?)) ORDER BY created_at ASC, chat_item_id ASC LIMIT ? @@ -1499,7 +1547,7 @@ getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count sea [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND note_folder_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND note_folder_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (created_at < ? OR (created_at = ? AND chat_item_id < ?)) ORDER BY created_at DESC, chat_item_id DESC LIMIT ? @@ -1591,7 +1639,7 @@ getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do FROM chat_items WHERE user_id = ? AND note_folder_id = ? AND item_status = ? AND created_at = ? AND chat_item_id > ? - ) + ) ci |] ( (userId, noteFolderId, CISRcvNew, ciCreatedAt afterCI) :. (userId, noteFolderId, CISRcvNew, ciCreatedAt afterCI, cChatItemId afterCI) @@ -1613,7 +1661,7 @@ getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do FROM chat_items WHERE user_id = ? AND note_folder_id = ? AND created_at = ? AND chat_item_id > ? - ) + ) ci |] ( (userId, noteFolderId, ciCreatedAt afterCI) :. (userId, noteFolderId, ciCreatedAt afterCI, cChatItemId afterCI) @@ -1763,21 +1811,21 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol) -type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) +type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) type ChatItemRow = - (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe Bool, Maybe SharedMsgId) - :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) + (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe BoolInt, Maybe SharedMsgId) + :. (Int, Maybe UTCTime, Maybe BoolInt, UTCTime, UTCTime) :. ChatItemForwardedFromRow :. ChatItemModeRow :. MaybeCIFIleRow -type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) +type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe BoolInt) toDirectQuote :: QuoteRow -> Maybe (CIQuote 'CTDirect) -toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction <$> quotedSent +toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction . unBI <$> quotedSent where direction sent = if sent then CIQDirectSnd else CIQDirectRcv @@ -1818,9 +1866,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT let itemDeleted' = case itemDeleted of DBCINotDeleted -> Nothing _ -> Just (CIDeleted @'CTDirect deletedTs) - itemEdited' = fromMaybe False itemEdited + itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1837,9 +1885,9 @@ type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow toGroupQuote :: QuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup) toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction quotedSent quotedMember_ where - direction (Just True) _ = Just CIQGroupSnd - direction (Just False) (Just member) = Just . CIQGroupRcv $ Just member - direction (Just False) Nothing = Just $ CIQGroupRcv Nothing + direction (Just (BI True)) _ = Just CIQGroupSnd + direction (Just (BI False)) (Just member) = Just . CIQGroupRcv $ Just member + direction (Just (BI False)) Nothing = Just $ CIQGroupRcv Nothing direction _ _ = Nothing -- this function can be changed so it never fails, not only avoid failure on invalid json @@ -1880,9 +1928,9 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, DBCIBlocked -> Just (CIBlocked deletedTs) DBCIBlockedByAdmin -> Just (CIBlockedByAdmin deletedTs) _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) - itemEdited' = fromMaybe False itemEdited + itemEdited' = maybe False unBI itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt + in mkCIMeta itemId content itemText status (unBI <$> sentViaProxy) sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed (unBI <$> itemLive) currentTs itemTs forwardedByMember createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1912,7 +1960,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do [sql| SELECT chat_item_id, contact_id, group_id, note_folder_id FROM chat_items - WHERE user_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? |] @@ -1923,7 +1971,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do [sql| SELECT chat_item_id, contact_id, group_id, note_folder_id FROM chat_items - WHERE user_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) ORDER BY item_ts ASC, chat_item_id ASC LIMIT ? @@ -1936,7 +1984,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do [sql| SELECT chat_item_id, contact_id, group_id, note_folder_id FROM chat_items - WHERE user_id = ? AND item_text LIKE '%' || ? || '%' + WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? @@ -1992,7 +2040,7 @@ updateDirectChatItemStatus db user@User {userId} ct@Contact {contactId} itemId i setDirectSndChatItemViaProxy :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect 'MDSnd -> Bool -> IO (ChatItem 'CTDirect 'MDSnd) setDirectSndChatItemViaProxy db User {userId} Contact {contactId} ci viaProxy = do - DB.execute db "UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (viaProxy, userId, contactId, chatItemId' ci) + DB.execute db "UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (BI viaProxy, userId, contactId, chatItemId' ci) pure ci {meta = (meta ci) {sentViaProxy = Just viaProxy}} updateDirectChatItem :: MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIContent d -> Bool -> Bool -> Maybe CITimed -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) @@ -2044,7 +2092,7 @@ updateDirectChatItem_ db userId contactId ChatItem {meta, content} msgId_ = do SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] - ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, contactId, itemId)) + ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, contactId, itemId)) forM_ msgId_ $ \msgId -> liftIO $ insertChatItemMessage_ db itemId msgId updatedAt addInitialAndNewCIVersions :: DB.Connection -> ChatItemId -> (UTCTime, MsgContent) -> (UTCTime, MsgContent) -> IO () @@ -2235,7 +2283,7 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, item_live = ?, updated_at = ?, timed_ttl = ?, timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] - ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) + ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, BI <$> itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () @@ -2573,7 +2621,7 @@ updateLocalChatItem_ db userId noteFolderId ChatItem {meta, content} = do SET item_content = ?, item_text = ?, item_status = ?, item_deleted = ?, item_deleted_ts = ?, item_edited = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ? AND chat_item_id = ? |] - ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, updatedAt) :. (userId, noteFolderId, itemId)) + ((content, itemText, itemStatus, BI itemDeleted', itemDeletedTs', BI itemEdited, updatedAt) :. (userId, noteFolderId, itemId)) deleteLocalChatItem :: DB.Connection -> User -> NoteFolder -> ChatItem 'CTLocal d -> IO () deleteLocalChatItem db User {userId} NoteFolder {noteFolderId} ci = do @@ -2740,8 +2788,8 @@ deleteGroupCIReactions_ db g@GroupInfo {groupId} ci@ChatItem {meta = CIMeta {ite "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ?" (groupId, itemSharedMId, memberId) -toCIReaction :: (MsgReaction, Bool, Int) -> CIReactionCount -toCIReaction (reaction, userReacted, totalReacted) = CIReactionCount {reaction, userReacted, totalReacted} +toCIReaction :: (MsgReaction, BoolInt, Int) -> CIReactionCount +toCIReaction (reaction, BI userReacted, totalReacted) = CIReactionCount {reaction, userReacted, totalReacted} getDirectReactions :: DB.Connection -> Contact -> SharedMsgId -> Bool -> IO [MsgReaction] getDirectReactions db ct itemSharedMId sent = @@ -2753,7 +2801,7 @@ getDirectReactions db ct itemSharedMId sent = FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? |] - (contactId' ct, itemSharedMId, sent) + (contactId' ct, itemSharedMId, BI sent) setDirectReaction :: DB.Connection -> Contact -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs @@ -2765,7 +2813,7 @@ setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) VALUES (?,?,?,?,?,?) |] - (contactId' ct, itemSharedMId, sent, reaction, msgId, reactionTs) + (contactId' ct, itemSharedMId, BI sent, reaction, msgId, reactionTs) | otherwise = DB.execute db @@ -2773,7 +2821,7 @@ setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? |] - (contactId' ct, itemSharedMId, sent, reaction) + (contactId' ct, itemSharedMId, BI sent, reaction) getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = @@ -2785,7 +2833,7 @@ getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = FROM chat_item_reactions WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? |] - (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent) + (groupId, groupMemberId' m, itemMemberId, itemSharedMId, BI sent) setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs @@ -2797,7 +2845,7 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) VALUES (?,?,?,?,?,?,?,?) |] - (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent, reaction, msgId, reactionTs) + (groupId, groupMemberId' m, itemMemberId, itemSharedMId, BI sent, reaction, msgId, reactionTs) | otherwise = DB.execute db @@ -2805,7 +2853,7 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti DELETE FROM chat_item_reactions WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? |] - (groupId, groupMemberId' m, itemSharedMId, itemMemberId, sent, reaction) + (groupId, groupMemberId' m, itemSharedMId, itemMemberId, BI sent, reaction) getReactionMembers :: DB.Connection -> VersionRangeChat -> User -> GroupId -> SharedMsgId -> MsgReaction -> IO [MemberReaction] getReactionMembers db vr user groupId itemSharedMId reaction = do @@ -2974,7 +3022,7 @@ setGroupSndViaProxy db itemId memberId viaProxy = SET via_proxy = ? WHERE chat_item_id = ? AND group_member_id = ? |] - (viaProxy, itemId, memberId) + (BI viaProxy, itemId, memberId) getGroupSndStatuses :: DB.Connection -> ChatItemId -> IO [MemberDeliveryStatus] getGroupSndStatuses db itemId = @@ -2989,7 +3037,7 @@ getGroupSndStatuses db itemId = (Only itemId) where memStatus (groupMemberId, memberDeliveryStatus, sentViaProxy) = - MemberDeliveryStatus {groupMemberId, memberDeliveryStatus, sentViaProxy} + MemberDeliveryStatus {groupMemberId, memberDeliveryStatus, sentViaProxy = unBI <$> sentViaProxy} getGroupSndStatusCounts :: DB.Connection -> ChatItemId -> IO [(GroupSndStatus, Int)] getGroupSndStatusCounts db itemId = diff --git a/src/Simplex/Chat/Store/NoteFolders.hs b/src/Simplex/Chat/Store/NoteFolders.hs index feb687f2ff..8f71f3f21e 100644 --- a/src/Simplex/Chat/Store/NoteFolders.hs +++ b/src/Simplex/Chat/Store/NoteFolders.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -10,13 +11,19 @@ module Simplex.Chat.Store.NoteFolders where import Control.Monad.Except (ExceptT (..), throwError) import Control.Monad.IO.Class (liftIO) import Data.Time (getCurrentTime) -import Database.SQLite.Simple (Only (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Types (NoteFolder (..), NoteFolderId, User (..)) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple.QQ (sql) +#endif createNoteFolder :: DB.Connection -> User -> ExceptT StoreError IO () createNoteFolder db User {userId} = do @@ -43,13 +50,13 @@ getNoteFolder db User {userId} noteFolderId = |] (userId, noteFolderId) where - toNoteFolder (createdAt, updatedAt, chatTs, favorite, unread) = + toNoteFolder (createdAt, updatedAt, chatTs, BI favorite, BI unread) = NoteFolder {noteFolderId, userId, createdAt, updatedAt, chatTs, favorite, unread} updateNoteFolderUnreadChat :: DB.Connection -> User -> NoteFolder -> Bool -> IO () updateNoteFolderUnreadChat db User {userId} NoteFolder {noteFolderId} unreadChat = do updatedAt <- getCurrentTime - DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ?" (unreadChat, updatedAt, userId, noteFolderId) + DB.execute db "UPDATE note_folders SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND note_folder_id = ?" (BI unreadChat, updatedAt, userId, noteFolderId) deleteNoteFolderFiles :: DB.Connection -> UserId -> NoteFolder -> IO () deleteNoteFolderFiles db userId NoteFolder {noteFolderId} = do diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs new file mode 100644 index 0000000000..285a952279 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Simplex.Chat.Store.Postgres.Migrations (migrations) where + +import Data.List (sortOn) +import Data.Text (Text) +import Simplex.Chat.Store.Postgres.Migrations.M20241220_initial +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20241220_initial", m20241220_initial, Nothing) + ] + +-- | The list of migrations in ascending order by date +migrations :: [Migration] +migrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs new file mode 100644 index 0000000000..24624cdf37 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -0,0 +1,1012 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20241220_initial where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20241220_initial :: Text +m20241220_initial = + T.pack + [r| +CREATE TABLE users( + user_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT NOT NULL UNIQUE, + local_display_name TEXT NOT NULL UNIQUE, + active_user SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + agent_user_id BIGINT NOT NULL, + view_pwd_hash BYTEA, + view_pwd_salt BYTEA, + show_ntfs SMALLINT NOT NULL DEFAULT 1, + send_rcpts_contacts SMALLINT NOT NULL DEFAULT 0, + send_rcpts_small_groups SMALLINT NOT NULL DEFAULT 0, + user_member_profile_updated_at TIMESTAMPTZ, + ui_themes TEXT, + active_order BIGINT NOT NULL DEFAULT 0 +); +CREATE TABLE contact_profiles( + contact_profile_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + display_name TEXT NOT NULL, + full_name TEXT NOT NULL, + properties TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + image TEXT, + user_id BIGINT DEFAULT NULL REFERENCES users ON DELETE CASCADE, + incognito SMALLINT, + local_alias TEXT NOT NULL DEFAULT '', + preferences TEXT, + contact_link BYTEA +); +CREATE TABLE display_names( + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + ldn_base TEXT NOT NULL, + ldn_suffix BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY(user_id, local_display_name), + UNIQUE(user_id, ldn_base, ldn_suffix) +); +ALTER TABLE users +ADD CONSTRAINT fk_users_display_names + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE RESTRICT + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED; +CREATE TABLE contacts( + contact_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + is_user SMALLINT NOT NULL DEFAULT 0, + via_group BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL, + xcontact_id BYTEA, + enable_ntfs SMALLINT, + unread_chat SMALLINT NOT NULL DEFAULT 0, + contact_used SMALLINT NOT NULL DEFAULT 0, + user_preferences TEXT NOT NULL DEFAULT '{}', + chat_ts TIMESTAMPTZ, + deleted SMALLINT NOT NULL DEFAULT 0, + favorite SMALLINT NOT NULL DEFAULT 0, + send_rcpts SMALLINT, + contact_group_member_id BIGINT, + contact_grp_inv_sent SMALLINT NOT NULL DEFAULT 0, + contact_status TEXT NOT NULL DEFAULT 'active', + custom_data BYTEA, + ui_themes TEXT, + chat_deleted SMALLINT NOT NULL DEFAULT 0, + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(user_id, local_display_name), + UNIQUE(user_id, contact_profile_id) +); +ALTER TABLE users +ADD CONSTRAINT fk_users_contacts + FOREIGN KEY(contact_id) + REFERENCES contacts(contact_id) + ON DELETE RESTRICT + DEFERRABLE INITIALLY DEFERRED; +CREATE TABLE known_servers( + server_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BYTEA, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(user_id, host, port) +); +CREATE TABLE group_profiles( + group_profile_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + display_name TEXT NOT NULL, + full_name TEXT NOT NULL, + properties TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + image TEXT, + user_id BIGINT DEFAULT NULL REFERENCES users ON DELETE CASCADE, + preferences TEXT, + description TEXT NULL +); +CREATE TABLE groups( + group_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + group_profile_id BIGINT REFERENCES group_profiles ON DELETE SET NULL, + inv_queue_info BYTEA, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + chat_item_id BIGINT DEFAULT NULL, + enable_ntfs SMALLINT, + host_conn_custom_user_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, + unread_chat SMALLINT NOT NULL DEFAULT 0, + chat_ts TIMESTAMPTZ, + favorite SMALLINT NOT NULL DEFAULT 0, + send_rcpts SMALLINT, + via_group_link_uri_hash BYTEA, + user_member_profile_sent_at TIMESTAMPTZ, + custom_data BYTEA, + ui_themes TEXT, + business_member_id BYTEA NULL, + business_chat TEXT NULL, + business_xcontact_id BYTEA NULL, + customer_member_id BYTEA NULL, + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(user_id, local_display_name), + UNIQUE(user_id, group_profile_id) +); +ALTER TABLE contacts +ADD CONSTRAINT fk_contacts_groups + FOREIGN KEY(via_group) + REFERENCES groups(group_id) ON DELETE SET NULL; +CREATE TABLE group_members( + group_member_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BYTEA NOT NULL, + member_role TEXT NOT NULL, + member_category TEXT NOT NULL, + member_status TEXT NOT NULL, + invited_by BIGINT REFERENCES contacts(contact_id) ON DELETE SET NULL, + sent_inv_queue_info BYTEA, + group_queue_info BYTEA, + direct_queue_info BYTEA, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + contact_profile_id BIGINT NOT NULL REFERENCES contact_profiles ON DELETE CASCADE, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + member_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, + show_messages SMALLINT NOT NULL DEFAULT 1, + xgrplinkmem_received SMALLINT NOT NULL DEFAULT 0, + invited_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + member_restriction TEXT, + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE(group_id, member_id) +); +ALTER TABLE contacts +ADD CONSTRAINT fk_contacts_group_members + FOREIGN KEY(contact_group_member_id) + REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE TABLE group_member_intros( + group_member_intro_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + re_group_member_id BIGINT NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + to_group_member_id BIGINT NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + group_queue_info BYTEA, + direct_queue_info BYTEA, + intro_status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + intro_chat_protocol_version INTEGER NOT NULL DEFAULT 3, + UNIQUE(re_group_member_id, to_group_member_id) +); +CREATE TABLE files( + file_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT, + file_size BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + chat_item_id BIGINT DEFAULT NULL, + updated_at TIMESTAMPTZ NOT NULL, + cancelled SMALLINT, + ci_file_status TEXT, + file_inline TEXT, + agent_snd_file_id BYTEA NULL, + private_snd_file_descr TEXT NULL, + agent_snd_file_deleted SMALLINT NOT NULL DEFAULT 0, + protocol TEXT NOT NULL DEFAULT 'smp', + file_crypto_key BYTEA, + file_crypto_nonce BYTEA, + note_folder_id BIGINT DEFAULT NULL, + redirect_file_id BIGINT REFERENCES files ON DELETE CASCADE +); +CREATE TABLE snd_files( + file_id BIGINT NOT NULL REFERENCES files ON DELETE CASCADE, + connection_id BIGINT NOT NULL, + file_status TEXT NOT NULL, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + file_inline TEXT, + last_inline_msg_delivery_id BIGINT, + file_descr_id BIGINT NULL, + PRIMARY KEY(file_id, connection_id) +); +CREATE TABLE rcv_files( + file_id BIGINT PRIMARY KEY REFERENCES files ON DELETE CASCADE, + file_status TEXT NOT NULL, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + file_queue_info BYTEA, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + rcv_file_inline TEXT, + file_inline TEXT, + file_descr_id BIGINT NULL, + agent_rcv_file_id BYTEA NULL, + agent_rcv_file_deleted SMALLINT NOT NULL DEFAULT 0, + to_receive SMALLINT, + user_approved_relays SMALLINT NOT NULL DEFAULT 0 +); +CREATE TABLE snd_file_chunks( + file_id BIGINT NOT NULL, + connection_id BIGINT NOT NULL, + chunk_number BIGINT NOT NULL, + chunk_agent_msg_id BIGINT, + chunk_sent SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY(file_id, connection_id, chunk_number) +); +CREATE TABLE rcv_file_chunks( + file_id BIGINT NOT NULL REFERENCES rcv_files ON DELETE CASCADE, + chunk_number BIGINT NOT NULL, + chunk_agent_msg_id BIGINT NOT NULL, + chunk_stored SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY(file_id, chunk_number) +); +CREATE TABLE connections( + connection_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + agent_conn_id BYTEA NOT NULL UNIQUE, + conn_level BIGINT NOT NULL DEFAULT 0, + via_contact BIGINT REFERENCES contacts(contact_id) ON DELETE SET NULL, + conn_status TEXT NOT NULL, + conn_type TEXT NOT NULL, + user_contact_link_id BIGINT, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + snd_file_id BIGINT, + rcv_file_id BIGINT REFERENCES rcv_files(file_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + updated_at TIMESTAMPTZ NOT NULL, + via_contact_uri_hash BYTEA, + xcontact_id BYTEA, + via_user_contact_link BIGINT DEFAULT NULL, + custom_user_profile_id BIGINT REFERENCES contact_profiles ON DELETE SET NULL, + conn_req_inv BYTEA, + local_alias TEXT NOT NULL DEFAULT '', + via_group_link SMALLINT NOT NULL DEFAULT 0, + group_link_id BYTEA, + security_code TEXT NULL, + security_code_verified_at TIMESTAMPTZ NULL, + auth_err_counter BIGINT NOT NULL DEFAULT 0, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + to_subscribe SMALLINT DEFAULT 0 NOT NULL, + contact_conn_initiated SMALLINT NOT NULL DEFAULT 0, + conn_chat_version INTEGER, + pq_support SMALLINT NOT NULL DEFAULT 0, + pq_encryption SMALLINT NOT NULL DEFAULT 0, + pq_snd_enabled SMALLINT, + pq_rcv_enabled SMALLINT, + quota_err_counter BIGINT NOT NULL DEFAULT 0, + FOREIGN KEY(snd_file_id, connection_id) + REFERENCES snd_files(file_id, connection_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED +); +ALTER TABLE snd_files +ADD CONSTRAINT fk_snd_files_connections + FOREIGN KEY(connection_id) + REFERENCES connections(connection_id) ON DELETE CASCADE; +CREATE TABLE user_contact_links( + user_contact_link_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + conn_req_contact BYTEA NOT NULL, + local_display_name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + updated_at TIMESTAMPTZ NOT NULL, + auto_accept SMALLINT DEFAULT 0, + auto_reply_msg_content TEXT DEFAULT NULL, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + auto_accept_incognito SMALLINT NOT NULL DEFAULT 0, + group_link_id BYTEA, + group_link_member_role TEXT NULL, + business_address SMALLINT DEFAULT 0, + UNIQUE(user_id, local_display_name) +); +ALTER TABLE connections +ADD CONSTRAINT fk_connections_user_contact_links_user_contact_link_id + FOREIGN KEY(user_contact_link_id) + REFERENCES user_contact_links(user_contact_link_id) ON DELETE CASCADE; +ALTER TABLE connections +ADD CONSTRAINT fk_connections_user_contact_links_via_user_contact_link + FOREIGN KEY(via_user_contact_link) + REFERENCES user_contact_links(user_contact_link_id) ON DELETE SET NULL; +CREATE TABLE contact_requests( + contact_request_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_contact_link_id BIGINT NOT NULL REFERENCES user_contact_links + ON UPDATE CASCADE ON DELETE CASCADE, + agent_invitation_id BYTEA NOT NULL, + contact_profile_id BIGINT REFERENCES contact_profiles + ON DELETE SET NULL + DEFERRABLE INITIALLY DEFERRED, + local_display_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + updated_at TIMESTAMPTZ NOT NULL, + xcontact_id BYTEA, + peer_chat_min_version INTEGER NOT NULL DEFAULT 1, + peer_chat_max_version INTEGER NOT NULL DEFAULT 1, + pq_support SMALLINT NOT NULL DEFAULT 0, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + FOREIGN KEY(user_id, local_display_name) + REFERENCES display_names(user_id, local_display_name) + ON UPDATE CASCADE + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + UNIQUE(user_id, local_display_name), + UNIQUE(user_id, contact_profile_id) +); +CREATE TABLE messages( + message_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + msg_sent SMALLINT NOT NULL, + chat_msg_event TEXT NOT NULL, + msg_body BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL, + connection_id BIGINT DEFAULT NULL REFERENCES connections ON DELETE CASCADE, + group_id BIGINT DEFAULT NULL REFERENCES groups ON DELETE CASCADE, + shared_msg_id BYTEA, + shared_msg_id_user SMALLINT, + author_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + forwarded_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL +); +CREATE TABLE pending_group_messages( + pending_group_message_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + message_id BIGINT NOT NULL REFERENCES messages ON DELETE CASCADE, + group_member_intro_id BIGINT REFERENCES group_member_intros ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE chat_items( + chat_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + chat_msg_id BIGINT, + created_by_msg_id BIGINT UNIQUE REFERENCES messages(message_id) ON DELETE SET NULL, + item_sent SMALLINT NOT NULL, + item_ts TIMESTAMPTZ NOT NULL, + item_deleted SMALLINT NOT NULL DEFAULT 0, + item_content TEXT NOT NULL, + item_text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + item_status TEXT NOT NULL, + shared_msg_id BYTEA, + quoted_shared_msg_id BYTEA, + quoted_sent_at TIMESTAMPTZ, + quoted_content TEXT, + quoted_sent SMALLINT, + quoted_member_id BYTEA, + item_edited SMALLINT, + timed_ttl BIGINT, + timed_delete_at TIMESTAMPTZ, + item_live SMALLINT, + item_deleted_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + item_deleted_ts TIMESTAMPTZ, + forwarded_by_group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + item_content_tag TEXT, + note_folder_id BIGINT DEFAULT NULL, + fwd_from_tag TEXT, + fwd_from_chat_name TEXT, + fwd_from_msg_dir SMALLINT, + fwd_from_contact_id BIGINT REFERENCES contacts ON DELETE SET NULL, + fwd_from_group_id BIGINT REFERENCES groups ON DELETE SET NULL, + fwd_from_chat_item_id BIGINT REFERENCES chat_items ON DELETE SET NULL, + via_proxy SMALLINT, + msg_content_tag TEXT +); +ALTER TABLE groups +ADD CONSTRAINT fk_groups_chat_items + FOREIGN KEY(chat_item_id) + REFERENCES chat_items(chat_item_id) ON DELETE SET NULL; +ALTER TABLE files +ADD CONSTRAINT fk_files_chat_items + FOREIGN KEY(chat_item_id) + REFERENCES chat_items(chat_item_id) ON DELETE CASCADE; +CREATE TABLE chat_item_messages( + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + message_id BIGINT NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + UNIQUE(chat_item_id, message_id) +); +CREATE TABLE calls( + call_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT NOT NULL REFERENCES contacts ON DELETE CASCADE, + shared_call_id BYTEA NOT NULL, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + call_state BYTEA NOT NULL, + call_ts TIMESTAMPTZ NOT NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + call_uuid TEXT NOT NULL DEFAULT '' +); +CREATE TABLE commands( + command_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + connection_id BIGINT REFERENCES connections ON DELETE CASCADE, + command_function TEXT NOT NULL, + command_status TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE settings( + settings_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_ttl BIGINT, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE protocol_servers( + smp_server_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BYTEA NOT NULL, + basic_auth TEXT, + preset SMALLINT NOT NULL DEFAULT 0, + tested SMALLINT, + enabled SMALLINT NOT NULL DEFAULT 1, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + protocol TEXT NOT NULL DEFAULT 'smp', + UNIQUE(user_id, host, port) +); +CREATE TABLE xftp_file_descriptions( + file_descr_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + file_descr_text TEXT NOT NULL, + file_descr_part_no BIGINT NOT NULL DEFAULT(0), + file_descr_complete SMALLINT NOT NULL DEFAULT(0), + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +ALTER TABLE snd_files +ADD CONSTRAINT fk_snd_files_xftp_file_descriptions + FOREIGN KEY(file_descr_id) + REFERENCES xftp_file_descriptions(file_descr_id) ON DELETE SET NULL; +ALTER TABLE rcv_files +ADD CONSTRAINT fk_rcv_files_xftp_file_descriptions + FOREIGN KEY(file_descr_id) + REFERENCES xftp_file_descriptions(file_descr_id) ON DELETE SET NULL; +CREATE TABLE extra_xftp_file_descriptions( + extra_file_descr_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + file_id BIGINT NOT NULL REFERENCES files ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + file_descr_text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE chat_item_versions( + chat_item_version_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + msg_content TEXT NOT NULL, + item_version_ts TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE chat_item_reactions( + chat_item_reaction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + item_member_id BYTEA, + shared_msg_id BYTEA NOT NULL, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE SET NULL, + created_by_msg_id BIGINT REFERENCES messages(message_id) ON DELETE SET NULL, + reaction TEXT NOT NULL, + reaction_sent SMALLINT NOT NULL, + reaction_ts TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE chat_item_moderations( + chat_item_moderation_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + moderator_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + item_member_id BYTEA NOT NULL, + shared_msg_id BYTEA NOT NULL, + created_by_msg_id BIGINT REFERENCES messages(message_id) ON DELETE SET NULL, + moderated_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE group_snd_item_statuses( + group_snd_item_status_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + chat_item_id BIGINT NOT NULL REFERENCES chat_items ON DELETE CASCADE, + group_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + group_snd_item_status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + via_proxy SMALLINT +); +CREATE TABLE sent_probes( + sent_probe_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + probe BYTEA NOT NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(user_id, probe) +); +CREATE TABLE sent_probe_hashes( + sent_probe_hash_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + sent_probe_id BIGINT NOT NULL REFERENCES sent_probes ON DELETE CASCADE, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE TABLE received_probes( + received_probe_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_member_id BIGINT REFERENCES group_members ON DELETE CASCADE, + probe BYTEA, + probe_hash BYTEA NOT NULL, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +CREATE TABLE remote_hosts( + remote_host_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + host_device_name TEXT NOT NULL, + store_path TEXT NOT NULL, + ca_key BYTEA NOT NULL, + ca_cert BYTEA NOT NULL, + id_key BYTEA NOT NULL, + host_fingerprint BYTEA NOT NULL, + host_dh_pub BYTEA NOT NULL, + bind_addr TEXT, + bind_iface TEXT, + bind_port INTEGER +); +CREATE TABLE remote_controllers( + remote_ctrl_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + ctrl_device_name TEXT NOT NULL, + ca_key BYTEA NOT NULL, + ca_cert BYTEA NOT NULL, + ctrl_fingerprint BYTEA NOT NULL, + id_pub BYTEA NOT NULL, + dh_priv_key BYTEA NOT NULL, + prev_dh_priv_key BYTEA +); +CREATE TABLE msg_deliveries( + msg_delivery_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + message_id BIGINT NOT NULL REFERENCES messages ON DELETE CASCADE, + connection_id BIGINT NOT NULL REFERENCES connections ON DELETE CASCADE, + agent_msg_id BIGINT, + agent_msg_meta TEXT, + chat_ts TIMESTAMPTZ NOT NULL DEFAULT (now()), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + delivery_status TEXT +); +CREATE TABLE note_folders( + note_folder_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + chat_ts TIMESTAMPTZ NOT NULL DEFAULT (now()), + favorite SMALLINT NOT NULL DEFAULT 0, + unread_chat SMALLINT NOT NULL DEFAULT 0 +); +ALTER TABLE files +ADD CONSTRAINT fk_files_note_folders + FOREIGN KEY(note_folder_id) + REFERENCES note_folders(note_folder_id) ON DELETE CASCADE; +ALTER TABLE chat_items +ADD CONSTRAINT fk_chat_items_note_folders + FOREIGN KEY(note_folder_id) + REFERENCES note_folders(note_folder_id) ON DELETE CASCADE; +CREATE TABLE app_settings(app_settings TEXT NOT NULL); +CREATE TABLE server_operators( + server_operator_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + server_operator_tag TEXT, + trade_name TEXT NOT NULL, + legal_name TEXT, + server_domains TEXT, + enabled SMALLINT NOT NULL DEFAULT 1, + smp_role_storage SMALLINT NOT NULL DEFAULT 1, + smp_role_proxy SMALLINT NOT NULL DEFAULT 1, + xftp_role_storage SMALLINT NOT NULL DEFAULT 1, + xftp_role_proxy SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE usage_conditions( + usage_conditions_id BIGINT PRIMARY KEY, + conditions_commit TEXT NOT NULL UNIQUE, + notified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); +CREATE TABLE operator_usage_conditions( + operator_usage_conditions_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + server_operator_id BIGINT REFERENCES server_operators(server_operator_id) ON DELETE SET NULL ON UPDATE CASCADE, + server_operator_tag TEXT, + conditions_commit TEXT NOT NULL, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + auto_accepted SMALLINT DEFAULT 0 +); +CREATE TABLE chat_tags( + chat_tag_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id BIGINT REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order BIGINT NOT NULL +); +CREATE TABLE chat_tags_chats( + contact_id BIGINT REFERENCES contacts ON DELETE CASCADE, + group_id BIGINT REFERENCES groups ON DELETE CASCADE, + chat_tag_id BIGINT NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); +CREATE INDEX contact_profiles_index ON contact_profiles( + display_name, + full_name +); +CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); +CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); +CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); +CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); +CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); +CREATE UNIQUE INDEX idx_chat_items_direct_shared_msg_id ON chat_items( + user_id, + contact_id, + shared_msg_id +); +CREATE UNIQUE INDEX idx_chat_items_group_shared_msg_id ON chat_items( + user_id, + group_id, + group_member_id, + shared_msg_id +); +CREATE UNIQUE INDEX idx_user_contact_links_group_id ON user_contact_links( + group_id +); +CREATE UNIQUE INDEX idx_snd_files_last_inline_msg_delivery_id ON snd_files( + last_inline_msg_delivery_id +); +CREATE INDEX idx_messages_connection_id ON messages(connection_id); +CREATE INDEX idx_chat_items_group_member_id ON chat_items(group_member_id); +CREATE INDEX idx_chat_items_contact_id ON chat_items(contact_id); +CREATE INDEX idx_chat_items_item_status ON chat_items(item_status); +CREATE INDEX idx_connections_group_member ON connections( + user_id, + group_member_id +); +CREATE INDEX idx_commands_connection_id ON commands(connection_id); +CREATE INDEX idx_calls_user_id ON calls(user_id); +CREATE INDEX idx_calls_chat_item_id ON calls(chat_item_id); +CREATE INDEX idx_calls_contact_id ON calls(contact_id); +CREATE INDEX idx_commands_user_id ON commands(user_id); +CREATE INDEX idx_connections_custom_user_profile_id ON connections( + custom_user_profile_id +); +CREATE INDEX idx_connections_via_user_contact_link ON connections( + via_user_contact_link +); +CREATE INDEX idx_connections_rcv_file_id ON connections(rcv_file_id); +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_user_contact_link_id ON connections( + user_contact_link_id +); +CREATE INDEX idx_connections_via_contact ON connections(via_contact); +CREATE INDEX idx_contact_profiles_user_id ON contact_profiles(user_id); +CREATE INDEX idx_contact_requests_contact_profile_id ON contact_requests( + contact_profile_id +); +CREATE INDEX idx_contact_requests_user_contact_link_id ON contact_requests( + user_contact_link_id +); +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +CREATE INDEX idx_contacts_contact_profile_id ON contacts(contact_profile_id); +CREATE INDEX idx_files_chat_item_id ON files(chat_item_id); +CREATE INDEX idx_files_user_id ON files(user_id); +CREATE INDEX idx_files_group_id ON files(group_id); +CREATE INDEX idx_files_contact_id ON files(contact_id); +CREATE INDEX idx_group_member_intros_to_group_member_id ON group_member_intros( + to_group_member_id +); +CREATE INDEX idx_group_members_user_id_local_display_name ON group_members( + user_id, + local_display_name +); +CREATE INDEX idx_group_members_member_profile_id ON group_members( + member_profile_id +); +CREATE INDEX idx_group_members_contact_id ON group_members(contact_id); +CREATE INDEX idx_group_members_contact_profile_id ON group_members( + contact_profile_id +); +CREATE INDEX idx_group_members_user_id ON group_members(user_id); +CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); +CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( + host_conn_custom_user_profile_id +); +CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); +CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); +CREATE INDEX idx_messages_group_id ON messages(group_id); +CREATE INDEX idx_pending_group_messages_group_member_intro_id ON pending_group_messages( + group_member_intro_id +); +CREATE INDEX idx_pending_group_messages_message_id ON pending_group_messages( + message_id +); +CREATE INDEX idx_pending_group_messages_group_member_id ON pending_group_messages( + group_member_id +); +CREATE INDEX idx_rcv_file_chunks_file_id ON rcv_file_chunks(file_id); +CREATE INDEX idx_rcv_files_group_member_id ON rcv_files(group_member_id); +CREATE INDEX idx_settings_user_id ON settings(user_id); +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks( + file_id, + connection_id +); +CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); +CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); +CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); +CREATE INDEX idx_smp_servers_user_id ON protocol_servers(user_id); +CREATE INDEX idx_chat_items_item_deleted_by_group_member_id ON chat_items( + item_deleted_by_group_member_id +); +CREATE INDEX idx_snd_files_file_descr_id ON snd_files(file_descr_id); +CREATE INDEX idx_rcv_files_file_descr_id ON rcv_files(file_descr_id); +CREATE INDEX idx_extra_xftp_file_descriptions_file_id ON extra_xftp_file_descriptions( + file_id +); +CREATE INDEX idx_extra_xftp_file_descriptions_user_id ON extra_xftp_file_descriptions( + user_id +); +CREATE INDEX idx_xftp_file_descriptions_user_id ON xftp_file_descriptions( + user_id +); +CREATE INDEX idx_chat_item_versions_chat_item_id ON chat_item_versions( + chat_item_id +); +CREATE INDEX idx_chat_item_reactions_shared_msg_id ON chat_item_reactions( + shared_msg_id +); +CREATE INDEX idx_chat_item_reactions_contact_id ON chat_item_reactions( + contact_id +); +CREATE INDEX idx_chat_item_reactions_group_id ON chat_item_reactions(group_id); +CREATE INDEX idx_chat_item_reactions_group_member_id ON chat_item_reactions( + group_member_id +); +CREATE INDEX idx_chat_item_reactions_contact ON chat_item_reactions( + contact_id, + shared_msg_id +); +CREATE INDEX idx_chat_item_reactions_group ON chat_item_reactions( + group_id, + shared_msg_id +); +CREATE INDEX idx_messages_created_at ON messages(created_at); +CREATE INDEX idx_chat_item_reactions_created_by_msg_id ON chat_item_reactions( + created_by_msg_id +); +CREATE INDEX idx_chat_items_timed_delete_at ON chat_items( + user_id, + timed_delete_at +); +CREATE INDEX idx_group_members_group_id ON group_members(user_id, group_id); +CREATE INDEX idx_chat_item_moderations_group_id ON chat_item_moderations( + group_id +); +CREATE INDEX idx_chat_item_moderations_moderator_member_id ON chat_item_moderations( + moderator_member_id +); +CREATE INDEX idx_chat_item_moderations_created_by_msg_id ON chat_item_moderations( + created_by_msg_id +); +CREATE INDEX idx_chat_item_moderations_group ON chat_item_moderations( + group_id, + item_member_id, + shared_msg_id +); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON group_snd_item_statuses( + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_group_member_id ON group_snd_item_statuses( + group_member_id +); +CREATE INDEX idx_chat_items_user_id_item_status ON chat_items( + user_id, + item_status +); +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); +CREATE INDEX idx_contacts_contact_group_member_id ON contacts( + contact_group_member_id +); +CREATE INDEX idx_sent_probes_user_id ON sent_probes(user_id); +CREATE INDEX idx_sent_probes_contact_id ON sent_probes(contact_id); +CREATE INDEX idx_sent_probes_group_member_id ON sent_probes(group_member_id); +CREATE INDEX idx_sent_probe_hashes_user_id ON sent_probe_hashes(user_id); +CREATE INDEX idx_sent_probe_hashes_sent_probe_id ON sent_probe_hashes( + sent_probe_id +); +CREATE INDEX idx_sent_probe_hashes_contact_id ON sent_probe_hashes(contact_id); +CREATE INDEX idx_sent_probe_hashes_group_member_id ON sent_probe_hashes( + group_member_id +); +CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); +CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); +CREATE INDEX idx_received_probes_probe ON received_probes(probe); +CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); +CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); +CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); +CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); +CREATE INDEX idx_connections_conn_req_inv ON connections( + user_id, + conn_req_inv +); +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups( + user_id, + via_group_link_uri_hash +); +CREATE INDEX idx_connections_via_contact_uri_hash ON connections( + user_id, + via_contact_uri_hash +); +CREATE INDEX idx_contact_profiles_contact_link ON contact_profiles( + user_id, + contact_link +); +CREATE INDEX idx_group_member_intros_re_group_member_id ON group_member_intros( + re_group_member_id +); +CREATE INDEX idx_group_members_invited_by_group_member_id ON group_members( + invited_by_group_member_id +); +CREATE INDEX idx_messages_author_group_member_id ON messages( + author_group_member_id +); +CREATE INDEX idx_messages_forwarded_by_group_member_id ON messages( + forwarded_by_group_member_id +); +CREATE INDEX idx_messages_group_id_shared_msg_id ON messages( + group_id, + shared_msg_id +); +CREATE INDEX idx_chat_items_forwarded_by_group_member_id ON chat_items( + forwarded_by_group_member_id +); +CREATE UNIQUE INDEX idx_remote_hosts_host_fingerprint ON remote_hosts( + host_fingerprint +); +CREATE UNIQUE INDEX idx_remote_controllers_ctrl_fingerprint ON remote_controllers( + ctrl_fingerprint +); +CREATE INDEX idx_contacts_chat_ts ON contacts(user_id, chat_ts); +CREATE INDEX idx_groups_chat_ts ON groups(user_id, chat_ts); +CREATE INDEX idx_contact_requests_updated_at ON contact_requests( + user_id, + updated_at +); +CREATE INDEX idx_connections_updated_at ON connections(user_id, updated_at); +CREATE INDEX idx_msg_deliveries_message_id ON msg_deliveries(message_id); +CREATE INDEX idx_msg_deliveries_agent_msg_id ON msg_deliveries( + connection_id, + agent_msg_id +); +CREATE INDEX chat_items_note_folder_id ON chat_items(note_folder_id); +CREATE INDEX files_note_folder_id ON files(note_folder_id); +CREATE INDEX note_folders_user_id ON note_folders(user_id); +CREATE INDEX idx_chat_items_contacts_created_at on chat_items( + user_id, + contact_id, + created_at +); +CREATE INDEX idx_chat_items_notes_created_at on chat_items( + user_id, + note_folder_id, + created_at +); +CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id); +CREATE INDEX idx_chat_items_fwd_from_contact_id ON chat_items( + fwd_from_contact_id +); +CREATE INDEX idx_chat_items_fwd_from_group_id ON chat_items(fwd_from_group_id); +CREATE INDEX idx_chat_items_fwd_from_chat_item_id ON chat_items( + fwd_from_chat_item_id +); +CREATE INDEX idx_received_probes_group_member_id on received_probes( + group_member_id +); +CREATE INDEX idx_contact_requests_contact_id ON contact_requests(contact_id); +CREATE INDEX idx_operator_usage_conditions_server_operator_id ON operator_usage_conditions( + server_operator_id +); +CREATE UNIQUE INDEX idx_operator_usage_conditions_conditions_commit ON operator_usage_conditions( + conditions_commit, + server_operator_id +); +CREATE INDEX idx_chat_items_contacts ON chat_items( + user_id, + contact_id, + item_status, + created_at +); +CREATE INDEX idx_chat_items_groups ON chat_items( + user_id, + group_id, + item_status, + item_ts +); +CREATE INDEX idx_chat_items_groups_item_ts ON chat_items( + user_id, + group_id, + item_ts +); +CREATE INDEX idx_chat_items_notes ON chat_items( + user_id, + note_folder_id, + item_status, + created_at +); +CREATE INDEX idx_groups_business_xcontact_id ON groups(business_xcontact_id); +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags( + user_id, + chat_tag_text +); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags( + user_id, + chat_tag_emoji +); +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats( + contact_id, + chat_tag_id +); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats( + group_id, + chat_tag_id +); +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items( + user_id, + group_id, + msg_content_tag, + item_ts +); +CREATE INDEX idx_chat_items_groups_msg_content_tag_item_deleted_item_ts ON chat_items( + user_id, + group_id, + msg_content_tag, + item_deleted, + item_ts +); +|] diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 809155d557..4fca5fb1a4 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -86,8 +87,6 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), Query, (:.) (..)) -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Call import Simplex.Chat.Messages import Simplex.Chat.Operators @@ -101,7 +100,8 @@ import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..)) import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String @@ -109,6 +109,13 @@ import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (BasicAuth (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode) import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime @@ -124,7 +131,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, DB.execute db "INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?)" - (auId, displayName, activeUser, order, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, currentTs, currentTs) + (auId, displayName, BI activeUser, order, BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, currentTs, currentTs) userId <- insertedRowId db DB.execute db @@ -138,10 +145,10 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is_user, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?)" - (profileId, displayName, userId, True, currentTs, currentTs, currentTs) + (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing) + pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing) getUsersInfo :: DB.Connection -> IO [UserInfo] getUsersInfo db = getUsers db >>= mapM getUserInfo @@ -253,7 +260,7 @@ updateUserPrivacy db User {userId, showNtfs, viewPwdHash} = SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ? WHERE user_id = ? |] - (hashSalt viewPwdHash :. (showNtfs, userId)) + (hashSalt viewPwdHash :. (BI showNtfs, userId)) where hashSalt = L.unzip . fmap (\UserPwdHash {hash, salt} -> (hash, salt)) @@ -262,16 +269,16 @@ updateAllContactReceipts db onOff = DB.execute db "UPDATE users SET send_rcpts_contacts = ?, send_rcpts_small_groups = ? WHERE view_pwd_hash IS NULL" - (onOff, onOff) + (BI onOff, BI onOff) updateUserContactReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO () updateUserContactReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do - DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ?" (enable, userId) + DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ?" (BI enable, userId) when clearOverrides $ DB.execute_ db "UPDATE contacts SET send_rcpts = NULL" updateUserGroupReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO () updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do - DB.execute db "UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?" (enable, userId) + DB.execute db "UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?" (BI enable, userId) when clearOverrides $ DB.execute_ db "UPDATE groups SET send_rcpts = NULL" updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User @@ -403,21 +410,21 @@ deleteUserAddress db user@User {userId} = do ) |] (Only userId) - DB.executeNamed + DB.execute db [sql| DELETE FROM display_names - WHERE user_id = :user_id + WHERE user_id = ? AND local_display_name in ( SELECT cr.local_display_name FROM contact_requests cr JOIN user_contact_links uc USING (user_contact_link_id) - WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL ) - AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = :user_id) + AND local_display_name NOT IN (SELECT local_display_name FROM users WHERE user_id = ?) |] - [":user_id" := userId] - DB.executeNamed + (userId, userId, userId) + DB.execute db [sql| DELETE FROM contact_profiles @@ -425,10 +432,10 @@ deleteUserAddress db user@User {userId} = do SELECT cr.contact_profile_id FROM contact_requests cr JOIN user_contact_links uc USING (user_contact_link_id) - WHERE uc.user_id = :user_id AND uc.local_display_name = '' AND uc.group_id IS NULL + WHERE uc.user_id = ? AND uc.local_display_name = '' AND uc.group_id IS NULL ) |] - [":user_id" := userId] + (Only userId) void $ setUserProfileContactLink db user Nothing DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL" (Only userId) @@ -455,8 +462,8 @@ $(J.deriveJSON defaultJSON ''AutoAccept) $(J.deriveJSON defaultJSON ''UserContactLink) -toUserContactLink :: (ConnReqContact, Bool, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink -toUserContactLink (connReq, autoAccept, businessAddress, acceptIncognito, autoReply) = +toUserContactLink :: (ConnReqContact, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink +toUserContactLink (connReq, BI autoAccept, BI businessAddress, BI acceptIncognito, autoReply) = UserContactLink connReq $ if autoAccept then Just AutoAccept {businessAddress, acceptIncognito, autoReply} else Nothing @@ -528,8 +535,8 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do |] (ucl :. Only userId) ucl = case autoAccept of - Just AutoAccept {businessAddress, acceptIncognito, autoReply} -> (True, businessAddress, acceptIncognito, autoReply) - _ -> (False, False, False, Nothing) + Just AutoAccept {businessAddress, acceptIncognito, autoReply} -> (BI True, BI businessAddress, BI acceptIncognito, autoReply) + _ -> (BI False, BI False, BI False, Nothing) getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> IO [UserServer p] getProtocolServers db p User {userId} = @@ -543,10 +550,10 @@ getProtocolServers db p User {userId} = |] (userId, decodeLatin1 $ strEncode p) where - toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, Bool, Maybe Bool, Bool) -> UserServer p - toUserServer (serverId, host, port, keyHash, auth_, preset, tested, enabled) = + toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) -> UserServer p + toUserServer (serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) = let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in UserServer {serverId, server, preset, tested, enabled, deleted = False} + in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, deleted = False} insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p) insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do @@ -557,7 +564,7 @@ insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, teste (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (serverColumns p server :. (preset, tested, enabled, userId, ts, ts)) + (serverColumns p server :. (BI preset, BI <$> tested, BI enabled, userId, ts, ts)) sId <- insertedRowId db pure (srv :: NewUserServer p) {serverId = DBEntityId sId} @@ -571,7 +578,7 @@ updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabl preset = ?, tested = ?, enabled = ?, updated_at = ? WHERE smp_server_id = ? |] - (serverColumns p server :. (preset, tested, enabled, ts, serverId)) + (serverColumns p server :. (BI preset, BI <$> tested, BI enabled, ts, serverId)) serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text) serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) = @@ -611,7 +618,7 @@ updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? WHERE server_operator_id = ? |] - (enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, currentTs, operatorId) + (BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), currentTs, operatorId) getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)] getUpdateServerOperators db presetOps newUser = do @@ -649,7 +656,7 @@ getUpdateServerOperators db presetOps newUser = do SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, xftp_role_storage = ?, xftp_role_proxy = ? WHERE server_operator_id = ? |] - (tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles, operatorId) + (tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), operatorId) insertOperator :: NewServerOperator -> IO ServerOperator insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do DB.execute @@ -659,7 +666,7 @@ getUpdateServerOperators db presetOps newUser = do (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, xftp_role_storage, xftp_role_proxy) VALUES (?,?,?,?,?,?,?,?,?) |] - (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, enabled, storage smpRoles, proxy smpRoles, storage xftpRoles, proxy xftpRoles) + (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles)) opId <- insertedRowId db pure op {operatorId = DBEntityId opId} autoAcceptConditions op UsageConditions {conditionsCommit} now = @@ -677,8 +684,8 @@ serverOperatorQuery = getServerOperators_ :: DB.Connection -> IO [ServerOperator] getServerOperators_ db = map toServerOperator <$> DB.query_ db serverOperatorQuery -toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, Bool) :. (Bool, Bool) :. (Bool, Bool) -> ServerOperator -toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, enabled) :. smpRoles' :. xftpRoles') = +toServerOperator :: (DBEntityId, Maybe OperatorTag, Text, Maybe Text, Text, BoolInt) :. (BoolInt, BoolInt) :. (BoolInt, BoolInt) -> ServerOperator +toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, BI enabled) :. smpRoles' :. xftpRoles') = ServerOperator { operatorId, operatorTag, @@ -691,7 +698,7 @@ toServerOperator ((operatorId, operatorTag, tradeName, legalName, domains, enabl xftpRoles = serverRoles xftpRoles' } where - serverRoles (storage, proxy) = ServerRoles {storage, proxy} + serverRoles (BI storage, BI proxy) = ServerRoles {storage, proxy} getOperatorConditions_ :: DB.Connection -> ServerOperator -> UsageConditions -> Maybe UsageConditions -> UTCTime -> IO ConditionsAcceptance getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {conditionsCommit = currentCommit, createdAt, notifiedAt} latestAcceptedConds_ now = do @@ -711,7 +718,7 @@ getOperatorConditions_ db ServerOperator {operatorId} UsageConditions {condition |] (Only operatorId) pure $ case operatorAcceptedConds_ of - Just (operatorCommit, acceptedAt_, autoAccept) + Just (operatorCommit, acceptedAt_, BI autoAccept) | operatorCommit /= latestAcceptedCommit -> CARequired Nothing -- TODO should we consider this operator disabled? | currentCommit /= latestAcceptedCommit -> CARequired $ conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt) | otherwise -> CAAccepted acceptedAt_ autoAccept @@ -767,23 +774,23 @@ acceptConditions db condId opIds acceptedAt = do acceptConditions_ :: DB.Connection -> ServerOperator -> Text -> UTCTime -> Bool -> IO () acceptConditions_ db ServerOperator {operatorId, operatorTag} conditionsCommit acceptedAt autoAccepted = do - acceptedAt_ :: Maybe (Maybe UTCTime) <- maybeFirstRow fromOnly $ DB.query db "SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit == ?" (operatorId, conditionsCommit) + acceptedAt_ :: Maybe (Maybe UTCTime) <- maybeFirstRow fromOnly $ DB.query db "SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ?" (operatorId, conditionsCommit) case acceptedAt_ of - Just Nothing -> - DB.execute - db - (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO UPDATE SET accepted_at = ?, auto_accepted = ?") - (operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted, acceptedAt, autoAccepted) - Just (Just _) -> - DB.execute - db - (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO NOTHING") - (operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted) - Nothing -> - DB.execute - db - q - (operatorId, operatorTag, conditionsCommit, acceptedAt, autoAccepted) + Just Nothing -> + DB.execute + db + (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO UPDATE SET accepted_at = ?, auto_accepted = ?") + (operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted, acceptedAt, BI autoAccepted) + Just (Just _) -> + DB.execute + db + (q <> "ON CONFLICT (server_operator_id, conditions_commit) DO NOTHING") + (operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted) + Nothing -> + DB.execute + db + q + (operatorId, operatorTag, conditionsCommit, acceptedAt, BI autoAccepted) where q = [sql| @@ -820,7 +827,7 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s | deleted -> pure Nothing | otherwise -> Just <$> insertProtocolServer db p user ts s DBEntityId srvId - | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, False) + | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, BI False) | otherwise -> Just s <$ updateProtocolServer db p ts s createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () diff --git a/src/Simplex/Chat/Store/Remote.hs b/src/Simplex/Chat/Store/Remote.hs index 68cd1281e7..4921369b10 100644 --- a/src/Simplex/Chat/Store/Remote.hs +++ b/src/Simplex/Chat/Store/Remote.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -8,19 +9,23 @@ module Simplex.Chat.Store.Remote where import Control.Monad.Except import Data.Int (Int64) import Data.Text (Text) -import Data.Text.Encoding (encodeUtf8, decodeASCII) +import Data.Text.Encoding (decodeASCII, encodeUtf8) import Data.Word (Word16) -import Database.SQLite.Simple (Only (..)) -import qualified Database.SQLite.Simple as SQL -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Remote.Types import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (..)) import Simplex.RemoteControl.Types import UnliftIO +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query) +import Database.SQLite.Simple.QQ (sql) +#endif insertRemoteHost :: DB.Connection -> Text -> FilePath -> Maybe RCCtrlAddress -> Maybe Word16 -> RCHostPairing -> ExceptT StoreError IO RemoteHostId insertRemoteHost db hostDeviceName storePath rcAddr_ bindPort_ RCHostPairing {caKey, caCert, idPrivKey, knownHost = kh_} = do @@ -54,7 +59,7 @@ getRemoteHostByFingerprint db fingerprint = maybeFirstRow toRemoteHost $ DB.query db (remoteHostQuery <> " WHERE host_fingerprint = ?") (Only fingerprint) -remoteHostQuery :: SQL.Query +remoteHostQuery :: Query remoteHostQuery = [sql| SELECT remote_host_id, host_device_name, store_path, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub, bind_iface, bind_addr, bind_port @@ -117,7 +122,7 @@ getRemoteCtrlByFingerprint db fingerprint = maybeFirstRow toRemoteCtrl $ DB.query db (remoteCtrlQuery <> " WHERE ctrl_fingerprint = ?") (Only fingerprint) -remoteCtrlQuery :: SQL.Query +remoteCtrlQuery :: Query remoteCtrlQuery = [sql| SELECT remote_ctrl_id, ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs similarity index 56% rename from src/Simplex/Chat/Store/Migrations.hs rename to src/Simplex/Chat/Store/SQLite/Migrations.hs index a6cb562aa1..0126fc600f 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -1,128 +1,128 @@ {-# LANGUAGE NamedFieldPuns #-} -module Simplex.Chat.Store.Migrations (migrations) where +module Simplex.Chat.Store.SQLite.Migrations (migrations) where import Data.List (sortOn) import Database.SQLite.Simple (Query (..)) -import Simplex.Chat.Migrations.M20220101_initial -import Simplex.Chat.Migrations.M20220122_v1_1 -import Simplex.Chat.Migrations.M20220205_chat_item_status -import Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests -import Simplex.Chat.Migrations.M20220224_messages_fks -import Simplex.Chat.Migrations.M20220301_smp_servers -import Simplex.Chat.Migrations.M20220302_profile_images -import Simplex.Chat.Migrations.M20220304_msg_quotes -import Simplex.Chat.Migrations.M20220321_chat_item_edited -import Simplex.Chat.Migrations.M20220404_files_status_fields -import Simplex.Chat.Migrations.M20220514_profiles_user_id -import Simplex.Chat.Migrations.M20220626_auto_reply -import Simplex.Chat.Migrations.M20220702_calls -import Simplex.Chat.Migrations.M20220715_groups_chat_item_id -import Simplex.Chat.Migrations.M20220811_chat_items_indices -import Simplex.Chat.Migrations.M20220812_incognito_profiles -import Simplex.Chat.Migrations.M20220818_chat_notifications -import Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id -import Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items -import Simplex.Chat.Migrations.M20220824_profiles_local_alias -import Simplex.Chat.Migrations.M20220909_commands -import Simplex.Chat.Migrations.M20220926_connection_alias -import Simplex.Chat.Migrations.M20220928_settings -import Simplex.Chat.Migrations.M20221001_shared_msg_id_indices -import Simplex.Chat.Migrations.M20221003_delete_broken_integrity_error_chat_items -import Simplex.Chat.Migrations.M20221004_idx_msg_deliveries_message_id -import Simplex.Chat.Migrations.M20221011_user_contact_links_group_id -import Simplex.Chat.Migrations.M20221012_inline_files -import Simplex.Chat.Migrations.M20221019_unread_chat -import Simplex.Chat.Migrations.M20221021_auto_accept__group_links -import Simplex.Chat.Migrations.M20221024_contact_used -import Simplex.Chat.Migrations.M20221025_chat_settings -import Simplex.Chat.Migrations.M20221029_group_link_id -import Simplex.Chat.Migrations.M20221112_server_password -import Simplex.Chat.Migrations.M20221115_server_cfg -import Simplex.Chat.Migrations.M20221129_delete_group_feature_items -import Simplex.Chat.Migrations.M20221130_delete_item_deleted -import Simplex.Chat.Migrations.M20221209_verified_connection -import Simplex.Chat.Migrations.M20221210_idxs -import Simplex.Chat.Migrations.M20221211_group_description -import Simplex.Chat.Migrations.M20221212_chat_items_timed -import Simplex.Chat.Migrations.M20221214_live_message -import Simplex.Chat.Migrations.M20221222_chat_ts -import Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status -import Simplex.Chat.Migrations.M20221230_idxs -import Simplex.Chat.Migrations.M20230107_connections_auth_err_counter -import Simplex.Chat.Migrations.M20230111_users_agent_user_id -import Simplex.Chat.Migrations.M20230117_fkey_indexes -import Simplex.Chat.Migrations.M20230118_recreate_smp_servers -import Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx -import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id -import Simplex.Chat.Migrations.M20230303_group_link_role -import Simplex.Chat.Migrations.M20230317_hidden_profiles -import Simplex.Chat.Migrations.M20230318_file_description -import Simplex.Chat.Migrations.M20230321_agent_file_deleted -import Simplex.Chat.Migrations.M20230328_files_protocol -import Simplex.Chat.Migrations.M20230402_protocol_servers -import Simplex.Chat.Migrations.M20230411_extra_xftp_file_descriptions -import Simplex.Chat.Migrations.M20230420_rcv_files_to_receive -import Simplex.Chat.Migrations.M20230422_profile_contact_links -import Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages -import Simplex.Chat.Migrations.M20230505_chat_item_versions -import Simplex.Chat.Migrations.M20230511_reactions -import Simplex.Chat.Migrations.M20230519_item_deleted_ts -import Simplex.Chat.Migrations.M20230526_indexes -import Simplex.Chat.Migrations.M20230529_indexes -import Simplex.Chat.Migrations.M20230608_deleted_contacts -import Simplex.Chat.Migrations.M20230618_favorite_chats -import Simplex.Chat.Migrations.M20230621_chat_item_moderations -import Simplex.Chat.Migrations.M20230705_delivery_receipts -import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses -import Simplex.Chat.Migrations.M20230814_indexes -import Simplex.Chat.Migrations.M20230827_file_encryption -import Simplex.Chat.Migrations.M20230829_connections_chat_vrange -import Simplex.Chat.Migrations.M20230903_connections_to_subscribe -import Simplex.Chat.Migrations.M20230913_member_contacts -import Simplex.Chat.Migrations.M20230914_member_probes -import Simplex.Chat.Migrations.M20230926_contact_status -import Simplex.Chat.Migrations.M20231002_conn_initiated -import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash -import Simplex.Chat.Migrations.M20231010_member_settings -import Simplex.Chat.Migrations.M20231019_indexes -import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received -import Simplex.Chat.Migrations.M20231107_indexes -import Simplex.Chat.Migrations.M20231113_group_forward -import Simplex.Chat.Migrations.M20231114_remote_control -import Simplex.Chat.Migrations.M20231126_remote_ctrl_address -import Simplex.Chat.Migrations.M20231207_chat_list_pagination -import Simplex.Chat.Migrations.M20231214_item_content_tag -import Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries -import Simplex.Chat.Migrations.M20240102_note_folders -import Simplex.Chat.Migrations.M20240104_members_profile_update -import Simplex.Chat.Migrations.M20240115_block_member_for_all -import Simplex.Chat.Migrations.M20240122_indexes -import Simplex.Chat.Migrations.M20240214_redirect_file_id -import Simplex.Chat.Migrations.M20240222_app_settings -import Simplex.Chat.Migrations.M20240226_users_restrict -import Simplex.Chat.Migrations.M20240228_pq -import Simplex.Chat.Migrations.M20240313_drop_agent_ack_cmd_id -import Simplex.Chat.Migrations.M20240324_custom_data -import Simplex.Chat.Migrations.M20240402_item_forwarded -import Simplex.Chat.Migrations.M20240430_ui_theme -import Simplex.Chat.Migrations.M20240501_chat_deleted -import Simplex.Chat.Migrations.M20240510_chat_items_via_proxy -import Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays -import Simplex.Chat.Migrations.M20240528_quota_err_counter -import Simplex.Chat.Migrations.M20240827_calls_uuid -import Simplex.Chat.Migrations.M20240920_user_order -import Simplex.Chat.Migrations.M20241008_indexes -import Simplex.Chat.Migrations.M20241010_contact_requests_contact_id -import Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id -import Simplex.Chat.Migrations.M20241027_server_operators -import Simplex.Chat.Migrations.M20241125_indexes -import Simplex.Chat.Migrations.M20241128_business_chats -import Simplex.Chat.Migrations.M20241205_business_chat_members -import Simplex.Chat.Migrations.M20241222_operator_conditions -import Simplex.Chat.Migrations.M20241223_chat_tags -import Simplex.Chat.Migrations.M20241230_reports -import Simplex.Chat.Migrations.M20250105_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20220101_initial +import Simplex.Chat.Store.SQLite.Migrations.M20220122_v1_1 +import Simplex.Chat.Store.SQLite.Migrations.M20220205_chat_item_status +import Simplex.Chat.Store.SQLite.Migrations.M20220210_deduplicate_contact_requests +import Simplex.Chat.Store.SQLite.Migrations.M20220224_messages_fks +import Simplex.Chat.Store.SQLite.Migrations.M20220301_smp_servers +import Simplex.Chat.Store.SQLite.Migrations.M20220302_profile_images +import Simplex.Chat.Store.SQLite.Migrations.M20220304_msg_quotes +import Simplex.Chat.Store.SQLite.Migrations.M20220321_chat_item_edited +import Simplex.Chat.Store.SQLite.Migrations.M20220404_files_status_fields +import Simplex.Chat.Store.SQLite.Migrations.M20220514_profiles_user_id +import Simplex.Chat.Store.SQLite.Migrations.M20220626_auto_reply +import Simplex.Chat.Store.SQLite.Migrations.M20220702_calls +import Simplex.Chat.Store.SQLite.Migrations.M20220715_groups_chat_item_id +import Simplex.Chat.Store.SQLite.Migrations.M20220811_chat_items_indices +import Simplex.Chat.Store.SQLite.Migrations.M20220812_incognito_profiles +import Simplex.Chat.Store.SQLite.Migrations.M20220818_chat_notifications +import Simplex.Chat.Store.SQLite.Migrations.M20220822_groups_host_conn_custom_user_profile_id +import Simplex.Chat.Store.SQLite.Migrations.M20220823_delete_broken_group_event_chat_items +import Simplex.Chat.Store.SQLite.Migrations.M20220824_profiles_local_alias +import Simplex.Chat.Store.SQLite.Migrations.M20220909_commands +import Simplex.Chat.Store.SQLite.Migrations.M20220926_connection_alias +import Simplex.Chat.Store.SQLite.Migrations.M20220928_settings +import Simplex.Chat.Store.SQLite.Migrations.M20221001_shared_msg_id_indices +import Simplex.Chat.Store.SQLite.Migrations.M20221003_delete_broken_integrity_error_chat_items +import Simplex.Chat.Store.SQLite.Migrations.M20221004_idx_msg_deliveries_message_id +import Simplex.Chat.Store.SQLite.Migrations.M20221011_user_contact_links_group_id +import Simplex.Chat.Store.SQLite.Migrations.M20221012_inline_files +import Simplex.Chat.Store.SQLite.Migrations.M20221019_unread_chat +import Simplex.Chat.Store.SQLite.Migrations.M20221021_auto_accept__group_links +import Simplex.Chat.Store.SQLite.Migrations.M20221024_contact_used +import Simplex.Chat.Store.SQLite.Migrations.M20221025_chat_settings +import Simplex.Chat.Store.SQLite.Migrations.M20221029_group_link_id +import Simplex.Chat.Store.SQLite.Migrations.M20221112_server_password +import Simplex.Chat.Store.SQLite.Migrations.M20221115_server_cfg +import Simplex.Chat.Store.SQLite.Migrations.M20221129_delete_group_feature_items +import Simplex.Chat.Store.SQLite.Migrations.M20221130_delete_item_deleted +import Simplex.Chat.Store.SQLite.Migrations.M20221209_verified_connection +import Simplex.Chat.Store.SQLite.Migrations.M20221210_idxs +import Simplex.Chat.Store.SQLite.Migrations.M20221211_group_description +import Simplex.Chat.Store.SQLite.Migrations.M20221212_chat_items_timed +import Simplex.Chat.Store.SQLite.Migrations.M20221214_live_message +import Simplex.Chat.Store.SQLite.Migrations.M20221222_chat_ts +import Simplex.Chat.Store.SQLite.Migrations.M20221223_idx_chat_items_item_status +import Simplex.Chat.Store.SQLite.Migrations.M20221230_idxs +import Simplex.Chat.Store.SQLite.Migrations.M20230107_connections_auth_err_counter +import Simplex.Chat.Store.SQLite.Migrations.M20230111_users_agent_user_id +import Simplex.Chat.Store.SQLite.Migrations.M20230117_fkey_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20230118_recreate_smp_servers +import Simplex.Chat.Store.SQLite.Migrations.M20230129_drop_chat_items_group_idx +import Simplex.Chat.Store.SQLite.Migrations.M20230206_item_deleted_by_group_member_id +import Simplex.Chat.Store.SQLite.Migrations.M20230303_group_link_role +import Simplex.Chat.Store.SQLite.Migrations.M20230317_hidden_profiles +import Simplex.Chat.Store.SQLite.Migrations.M20230318_file_description +import Simplex.Chat.Store.SQLite.Migrations.M20230321_agent_file_deleted +import Simplex.Chat.Store.SQLite.Migrations.M20230328_files_protocol +import Simplex.Chat.Store.SQLite.Migrations.M20230402_protocol_servers +import Simplex.Chat.Store.SQLite.Migrations.M20230411_extra_xftp_file_descriptions +import Simplex.Chat.Store.SQLite.Migrations.M20230420_rcv_files_to_receive +import Simplex.Chat.Store.SQLite.Migrations.M20230422_profile_contact_links +import Simplex.Chat.Store.SQLite.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages +import Simplex.Chat.Store.SQLite.Migrations.M20230505_chat_item_versions +import Simplex.Chat.Store.SQLite.Migrations.M20230511_reactions +import Simplex.Chat.Store.SQLite.Migrations.M20230519_item_deleted_ts +import Simplex.Chat.Store.SQLite.Migrations.M20230526_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20230529_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20230608_deleted_contacts +import Simplex.Chat.Store.SQLite.Migrations.M20230618_favorite_chats +import Simplex.Chat.Store.SQLite.Migrations.M20230621_chat_item_moderations +import Simplex.Chat.Store.SQLite.Migrations.M20230705_delivery_receipts +import Simplex.Chat.Store.SQLite.Migrations.M20230721_group_snd_item_statuses +import Simplex.Chat.Store.SQLite.Migrations.M20230814_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20230827_file_encryption +import Simplex.Chat.Store.SQLite.Migrations.M20230829_connections_chat_vrange +import Simplex.Chat.Store.SQLite.Migrations.M20230903_connections_to_subscribe +import Simplex.Chat.Store.SQLite.Migrations.M20230913_member_contacts +import Simplex.Chat.Store.SQLite.Migrations.M20230914_member_probes +import Simplex.Chat.Store.SQLite.Migrations.M20230926_contact_status +import Simplex.Chat.Store.SQLite.Migrations.M20231002_conn_initiated +import Simplex.Chat.Store.SQLite.Migrations.M20231009_via_group_link_uri_hash +import Simplex.Chat.Store.SQLite.Migrations.M20231010_member_settings +import Simplex.Chat.Store.SQLite.Migrations.M20231019_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20231030_xgrplinkmem_received +import Simplex.Chat.Store.SQLite.Migrations.M20231107_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20231113_group_forward +import Simplex.Chat.Store.SQLite.Migrations.M20231114_remote_control +import Simplex.Chat.Store.SQLite.Migrations.M20231126_remote_ctrl_address +import Simplex.Chat.Store.SQLite.Migrations.M20231207_chat_list_pagination +import Simplex.Chat.Store.SQLite.Migrations.M20231214_item_content_tag +import Simplex.Chat.Store.SQLite.Migrations.M20231215_recreate_msg_deliveries +import Simplex.Chat.Store.SQLite.Migrations.M20240102_note_folders +import Simplex.Chat.Store.SQLite.Migrations.M20240104_members_profile_update +import Simplex.Chat.Store.SQLite.Migrations.M20240115_block_member_for_all +import Simplex.Chat.Store.SQLite.Migrations.M20240122_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20240214_redirect_file_id +import Simplex.Chat.Store.SQLite.Migrations.M20240222_app_settings +import Simplex.Chat.Store.SQLite.Migrations.M20240226_users_restrict +import Simplex.Chat.Store.SQLite.Migrations.M20240228_pq +import Simplex.Chat.Store.SQLite.Migrations.M20240313_drop_agent_ack_cmd_id +import Simplex.Chat.Store.SQLite.Migrations.M20240324_custom_data +import Simplex.Chat.Store.SQLite.Migrations.M20240402_item_forwarded +import Simplex.Chat.Store.SQLite.Migrations.M20240430_ui_theme +import Simplex.Chat.Store.SQLite.Migrations.M20240501_chat_deleted +import Simplex.Chat.Store.SQLite.Migrations.M20240510_chat_items_via_proxy +import Simplex.Chat.Store.SQLite.Migrations.M20240515_rcv_files_user_approved_relays +import Simplex.Chat.Store.SQLite.Migrations.M20240528_quota_err_counter +import Simplex.Chat.Store.SQLite.Migrations.M20240827_calls_uuid +import Simplex.Chat.Store.SQLite.Migrations.M20240920_user_order +import Simplex.Chat.Store.SQLite.Migrations.M20241008_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20241010_contact_requests_contact_id +import Simplex.Chat.Store.SQLite.Migrations.M20241023_chat_item_autoincrement_id +import Simplex.Chat.Store.SQLite.Migrations.M20241027_server_operators +import Simplex.Chat.Store.SQLite.Migrations.M20241125_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20241128_business_chats +import Simplex.Chat.Store.SQLite.Migrations.M20241205_business_chat_members +import Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions +import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags +import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports +import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] diff --git a/src/Simplex/Chat/Migrations/M20220101_initial.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220101_initial.hs similarity index 99% rename from src/Simplex/Chat/Migrations/M20220101_initial.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220101_initial.hs index 2568b0b672..601a90d5d9 100644 --- a/src/Simplex/Chat/Migrations/M20220101_initial.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220101_initial.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220101_initial where +module Simplex.Chat.Store.SQLite.Migrations.M20220101_initial where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220122_v1_1.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220122_v1_1.hs similarity index 99% rename from src/Simplex/Chat/Migrations/M20220122_v1_1.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220122_v1_1.hs index 157f97c333..c84bbdd03f 100644 --- a/src/Simplex/Chat/Migrations/M20220122_v1_1.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220122_v1_1.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220122_v1_1 where +module Simplex.Chat.Store.SQLite.Migrations.M20220122_v1_1 where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220205_chat_item_status.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220205_chat_item_status.hs index 6baca156fb..fe1a6382eb 100644 --- a/src/Simplex/Chat/Migrations/M20220205_chat_item_status.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220205_chat_item_status.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220205_chat_item_status where +module Simplex.Chat.Store.SQLite.Migrations.M20220205_chat_item_status where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220210_deduplicate_contact_requests.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220210_deduplicate_contact_requests.hs index e2c26e35e0..1c97f82dc0 100644 --- a/src/Simplex/Chat/Migrations/M20220210_deduplicate_contact_requests.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220210_deduplicate_contact_requests.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests where +module Simplex.Chat.Store.SQLite.Migrations.M20220210_deduplicate_contact_requests where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220224_messages_fks.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220224_messages_fks.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20220224_messages_fks.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220224_messages_fks.hs index 9bb5db57a5..b9842dd33d 100644 --- a/src/Simplex/Chat/Migrations/M20220224_messages_fks.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220224_messages_fks.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220224_messages_fks where +module Simplex.Chat.Store.SQLite.Migrations.M20220224_messages_fks where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220301_smp_servers.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20220301_smp_servers.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220301_smp_servers.hs index 774f2e0168..91bd3194ca 100644 --- a/src/Simplex/Chat/Migrations/M20220301_smp_servers.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220301_smp_servers.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220301_smp_servers where +module Simplex.Chat.Store.SQLite.Migrations.M20220301_smp_servers where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220302_profile_images.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220302_profile_images.hs similarity index 79% rename from src/Simplex/Chat/Migrations/M20220302_profile_images.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220302_profile_images.hs index 72c22b89cb..f6a9444ce1 100644 --- a/src/Simplex/Chat/Migrations/M20220302_profile_images.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220302_profile_images.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220302_profile_images where +module Simplex.Chat.Store.SQLite.Migrations.M20220302_profile_images where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220304_msg_quotes.hs similarity index 94% rename from src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220304_msg_quotes.hs index 129c3616a0..fa0df67b10 100644 --- a/src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220304_msg_quotes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220304_msg_quotes where +module Simplex.Chat.Store.SQLite.Migrations.M20220304_msg_quotes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220321_chat_item_edited.hs similarity index 76% rename from src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220321_chat_item_edited.hs index 7a77f00262..5e11ef7519 100644 --- a/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220321_chat_item_edited.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220321_chat_item_edited where +module Simplex.Chat.Store.SQLite.Migrations.M20220321_chat_item_edited where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220404_files_status_fields.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220404_files_status_fields.hs index 40623a3be6..f38fe1163a 100644 --- a/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220404_files_status_fields.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220404_files_status_fields where +module Simplex.Chat.Store.SQLite.Migrations.M20220404_files_status_fields where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220514_profiles_user_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220514_profiles_user_id.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20220514_profiles_user_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220514_profiles_user_id.hs index 239f124576..b4b99a27a2 100644 --- a/src/Simplex/Chat/Migrations/M20220514_profiles_user_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220514_profiles_user_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220514_profiles_user_id where +module Simplex.Chat.Store.SQLite.Migrations.M20220514_profiles_user_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220626_auto_reply.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220626_auto_reply.hs similarity index 85% rename from src/Simplex/Chat/Migrations/M20220626_auto_reply.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220626_auto_reply.hs index 6ac72ac804..e85a6438ae 100644 --- a/src/Simplex/Chat/Migrations/M20220626_auto_reply.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220626_auto_reply.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220626_auto_reply where +module Simplex.Chat.Store.SQLite.Migrations.M20220626_auto_reply where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220702_calls.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220702_calls.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20220702_calls.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220702_calls.hs index 4cbf3dbad6..fc0bdd568c 100644 --- a/src/Simplex/Chat/Migrations/M20220702_calls.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220702_calls.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220702_calls where +module Simplex.Chat.Store.SQLite.Migrations.M20220702_calls where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220715_groups_chat_item_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220715_groups_chat_item_id.hs similarity index 78% rename from src/Simplex/Chat/Migrations/M20220715_groups_chat_item_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220715_groups_chat_item_id.hs index 0fa5310bfb..44ed8d2e6e 100644 --- a/src/Simplex/Chat/Migrations/M20220715_groups_chat_item_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220715_groups_chat_item_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220715_groups_chat_item_id where +module Simplex.Chat.Store.SQLite.Migrations.M20220715_groups_chat_item_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220811_chat_items_indices.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220811_chat_items_indices.hs index a43617d439..18eabbfacd 100644 --- a/src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220811_chat_items_indices.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220811_chat_items_indices where +module Simplex.Chat.Store.SQLite.Migrations.M20220811_chat_items_indices where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220812_incognito_profiles.hs similarity index 89% rename from src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220812_incognito_profiles.hs index e03eda2358..59ff18caf8 100644 --- a/src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220812_incognito_profiles.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220812_incognito_profiles where +module Simplex.Chat.Store.SQLite.Migrations.M20220812_incognito_profiles where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220818_chat_notifications.hs similarity index 78% rename from src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220818_chat_notifications.hs index ffb2b15967..42c439e8bc 100644 --- a/src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220818_chat_notifications.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220818_chat_notifications where +module Simplex.Chat.Store.SQLite.Migrations.M20220818_chat_notifications where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs index bbadbd5524..1cd49ccf26 100644 --- a/src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id where +module Simplex.Chat.Store.SQLite.Migrations.M20220822_groups_host_conn_custom_user_profile_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220823_delete_broken_group_event_chat_items.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220823_delete_broken_group_event_chat_items.hs index 40657f3421..f4f8df826a 100644 --- a/src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220823_delete_broken_group_event_chat_items.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items where +module Simplex.Chat.Store.SQLite.Migrations.M20220823_delete_broken_group_event_chat_items where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220824_profiles_local_alias.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220824_profiles_local_alias.hs index f0b0ca8385..9252bc43fb 100644 --- a/src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220824_profiles_local_alias.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220824_profiles_local_alias where +module Simplex.Chat.Store.SQLite.Migrations.M20220824_profiles_local_alias where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220909_commands.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220909_commands.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20220909_commands.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220909_commands.hs index 745dff4165..3cc359d56b 100644 --- a/src/Simplex/Chat/Migrations/M20220909_commands.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220909_commands.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220909_commands where +module Simplex.Chat.Store.SQLite.Migrations.M20220909_commands where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220926_connection_alias.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220926_connection_alias.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20220926_connection_alias.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220926_connection_alias.hs index ede7cc3cfc..7b56ba5fb9 100644 --- a/src/Simplex/Chat/Migrations/M20220926_connection_alias.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220926_connection_alias.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220926_connection_alias where +module Simplex.Chat.Store.SQLite.Migrations.M20220926_connection_alias where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20220928_settings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20220928_settings.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20220928_settings.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20220928_settings.hs index 56b3613b05..a159ef1cc4 100644 --- a/src/Simplex/Chat/Migrations/M20220928_settings.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20220928_settings.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20220928_settings where +module Simplex.Chat.Store.SQLite.Migrations.M20220928_settings where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221001_shared_msg_id_indices.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221001_shared_msg_id_indices.hs similarity index 85% rename from src/Simplex/Chat/Migrations/M20221001_shared_msg_id_indices.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221001_shared_msg_id_indices.hs index 10ac0fa5e1..61c5800a1c 100644 --- a/src/Simplex/Chat/Migrations/M20221001_shared_msg_id_indices.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221001_shared_msg_id_indices.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221001_shared_msg_id_indices where +module Simplex.Chat.Store.SQLite.Migrations.M20221001_shared_msg_id_indices where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs similarity index 75% rename from src/Simplex/Chat/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs index 2d451766cd..1843f3316a 100644 --- a/src/Simplex/Chat/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221003_delete_broken_integrity_error_chat_items.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221003_delete_broken_integrity_error_chat_items where +module Simplex.Chat.Store.SQLite.Migrations.M20221003_delete_broken_integrity_error_chat_items where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221004_idx_msg_deliveries_message_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221004_idx_msg_deliveries_message_id.hs similarity index 76% rename from src/Simplex/Chat/Migrations/M20221004_idx_msg_deliveries_message_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221004_idx_msg_deliveries_message_id.hs index 0e53923b58..8dba932549 100644 --- a/src/Simplex/Chat/Migrations/M20221004_idx_msg_deliveries_message_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221004_idx_msg_deliveries_message_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221004_idx_msg_deliveries_message_id where +module Simplex.Chat.Store.SQLite.Migrations.M20221004_idx_msg_deliveries_message_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221011_user_contact_links_group_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221011_user_contact_links_group_id.hs similarity index 81% rename from src/Simplex/Chat/Migrations/M20221011_user_contact_links_group_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221011_user_contact_links_group_id.hs index 4ad6fcb8dc..a8c64d32b0 100644 --- a/src/Simplex/Chat/Migrations/M20221011_user_contact_links_group_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221011_user_contact_links_group_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221011_user_contact_links_group_id where +module Simplex.Chat.Store.SQLite.Migrations.M20221011_user_contact_links_group_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221012_inline_files.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221012_inline_files.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20221012_inline_files.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221012_inline_files.hs index 4b069f0882..cb765d4e64 100644 --- a/src/Simplex/Chat/Migrations/M20221012_inline_files.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221012_inline_files.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221012_inline_files where +module Simplex.Chat.Store.SQLite.Migrations.M20221012_inline_files where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221019_unread_chat.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221019_unread_chat.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20221019_unread_chat.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221019_unread_chat.hs index db24db947b..c8bccb81d3 100644 --- a/src/Simplex/Chat/Migrations/M20221019_unread_chat.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221019_unread_chat.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221019_unread_chat where +module Simplex.Chat.Store.SQLite.Migrations.M20221019_unread_chat where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221021_auto_accept__group_links.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221021_auto_accept__group_links.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20221021_auto_accept__group_links.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221021_auto_accept__group_links.hs index cb945cce97..54042855d9 100644 --- a/src/Simplex/Chat/Migrations/M20221021_auto_accept__group_links.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221021_auto_accept__group_links.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221021_auto_accept__group_links where +module Simplex.Chat.Store.SQLite.Migrations.M20221021_auto_accept__group_links where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221024_contact_used.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221024_contact_used.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20221024_contact_used.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221024_contact_used.hs index 6f677f1c77..de0164d81b 100644 --- a/src/Simplex/Chat/Migrations/M20221024_contact_used.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221024_contact_used.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221024_contact_used where +module Simplex.Chat.Store.SQLite.Migrations.M20221024_contact_used where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221025_chat_settings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221025_chat_settings.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20221025_chat_settings.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221025_chat_settings.hs index 712902e85c..0e768e8fb1 100644 --- a/src/Simplex/Chat/Migrations/M20221025_chat_settings.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221025_chat_settings.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221025_chat_settings where +module Simplex.Chat.Store.SQLite.Migrations.M20221025_chat_settings where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221029_group_link_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221029_group_link_id.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20221029_group_link_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221029_group_link_id.hs index da290e4158..5d3cf7ee3c 100644 --- a/src/Simplex/Chat/Migrations/M20221029_group_link_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221029_group_link_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221029_group_link_id where +module Simplex.Chat.Store.SQLite.Migrations.M20221029_group_link_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221112_server_password.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221112_server_password.hs similarity index 75% rename from src/Simplex/Chat/Migrations/M20221112_server_password.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221112_server_password.hs index ee8d0e470d..4afc63fe1c 100644 --- a/src/Simplex/Chat/Migrations/M20221112_server_password.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221112_server_password.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221112_server_password where +module Simplex.Chat.Store.SQLite.Migrations.M20221112_server_password where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221115_server_cfg.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221115_server_cfg.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20221115_server_cfg.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221115_server_cfg.hs index 409da91db0..1def0c4c17 100644 --- a/src/Simplex/Chat/Migrations/M20221115_server_cfg.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221115_server_cfg.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221115_server_cfg where +module Simplex.Chat.Store.SQLite.Migrations.M20221115_server_cfg where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221129_delete_group_feature_items.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221129_delete_group_feature_items.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20221129_delete_group_feature_items.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221129_delete_group_feature_items.hs index 7baf6b584e..5e631ab88c 100644 --- a/src/Simplex/Chat/Migrations/M20221129_delete_group_feature_items.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221129_delete_group_feature_items.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221129_delete_group_feature_items where +module Simplex.Chat.Store.SQLite.Migrations.M20221129_delete_group_feature_items where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221130_delete_item_deleted.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221130_delete_item_deleted.hs similarity index 78% rename from src/Simplex/Chat/Migrations/M20221130_delete_item_deleted.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221130_delete_item_deleted.hs index 487cb7dceb..f64c3a2b56 100644 --- a/src/Simplex/Chat/Migrations/M20221130_delete_item_deleted.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221130_delete_item_deleted.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221130_delete_item_deleted where +module Simplex.Chat.Store.SQLite.Migrations.M20221130_delete_item_deleted where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221209_verified_connection.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221209_verified_connection.hs similarity index 80% rename from src/Simplex/Chat/Migrations/M20221209_verified_connection.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221209_verified_connection.hs index f7e4a8aee9..0643c3f873 100644 --- a/src/Simplex/Chat/Migrations/M20221209_verified_connection.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221209_verified_connection.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221209_verified_connection where +module Simplex.Chat.Store.SQLite.Migrations.M20221209_verified_connection where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221210_idxs.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221210_idxs.hs similarity index 85% rename from src/Simplex/Chat/Migrations/M20221210_idxs.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221210_idxs.hs index cd18611a53..cea66cf117 100644 --- a/src/Simplex/Chat/Migrations/M20221210_idxs.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221210_idxs.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221210_idxs where +module Simplex.Chat.Store.SQLite.Migrations.M20221210_idxs where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221211_group_description.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221211_group_description.hs similarity index 76% rename from src/Simplex/Chat/Migrations/M20221211_group_description.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221211_group_description.hs index ba406862cb..8b0ca88f67 100644 --- a/src/Simplex/Chat/Migrations/M20221211_group_description.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221211_group_description.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221211_group_description where +module Simplex.Chat.Store.SQLite.Migrations.M20221211_group_description where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221212_chat_items_timed.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221212_chat_items_timed.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20221212_chat_items_timed.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221212_chat_items_timed.hs index b82b66f3d4..edfcd39c70 100644 --- a/src/Simplex/Chat/Migrations/M20221212_chat_items_timed.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221212_chat_items_timed.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221212_chat_items_timed where +module Simplex.Chat.Store.SQLite.Migrations.M20221212_chat_items_timed where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221214_live_message.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221214_live_message.hs similarity index 75% rename from src/Simplex/Chat/Migrations/M20221214_live_message.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221214_live_message.hs index 959ec75ae4..ff64defef3 100644 --- a/src/Simplex/Chat/Migrations/M20221214_live_message.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221214_live_message.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221214_live_message where +module Simplex.Chat.Store.SQLite.Migrations.M20221214_live_message where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221222_chat_ts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221222_chat_ts.hs similarity index 81% rename from src/Simplex/Chat/Migrations/M20221222_chat_ts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221222_chat_ts.hs index 9a83c81821..1a4025b1ae 100644 --- a/src/Simplex/Chat/Migrations/M20221222_chat_ts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221222_chat_ts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221222_chat_ts where +module Simplex.Chat.Store.SQLite.Migrations.M20221222_chat_ts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221223_idx_chat_items_item_status.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221223_idx_chat_items_item_status.hs similarity index 75% rename from src/Simplex/Chat/Migrations/M20221223_idx_chat_items_item_status.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221223_idx_chat_items_item_status.hs index f29b4a9d5c..d834396df2 100644 --- a/src/Simplex/Chat/Migrations/M20221223_idx_chat_items_item_status.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221223_idx_chat_items_item_status.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status where +module Simplex.Chat.Store.SQLite.Migrations.M20221223_idx_chat_items_item_status where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20221230_idxs.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20221230_idxs.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20221230_idxs.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20221230_idxs.hs index dbe84357aa..ca58d0cb70 100644 --- a/src/Simplex/Chat/Migrations/M20221230_idxs.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20221230_idxs.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20221230_idxs where +module Simplex.Chat.Store.SQLite.Migrations.M20221230_idxs where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230107_connections_auth_err_counter.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230107_connections_auth_err_counter.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20230107_connections_auth_err_counter.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230107_connections_auth_err_counter.hs index b3c724e938..3b5062144a 100644 --- a/src/Simplex/Chat/Migrations/M20230107_connections_auth_err_counter.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230107_connections_auth_err_counter.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230107_connections_auth_err_counter where +module Simplex.Chat.Store.SQLite.Migrations.M20230107_connections_auth_err_counter where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230111_users_agent_user_id.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230111_users_agent_user_id.hs index 531c776a33..9fd108809c 100644 --- a/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230111_users_agent_user_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230111_users_agent_user_id where +module Simplex.Chat.Store.SQLite.Migrations.M20230111_users_agent_user_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230117_fkey_indexes.hs similarity index 98% rename from src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230117_fkey_indexes.hs index 5986863093..50f6cf4a21 100644 --- a/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230117_fkey_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230117_fkey_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20230117_fkey_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230118_recreate_smp_servers.hs similarity index 93% rename from src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230118_recreate_smp_servers.hs index 6253a3a37e..19e60aa792 100644 --- a/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230118_recreate_smp_servers.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230118_recreate_smp_servers where +module Simplex.Chat.Store.SQLite.Migrations.M20230118_recreate_smp_servers where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230129_drop_chat_items_group_idx.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230129_drop_chat_items_group_idx.hs similarity index 73% rename from src/Simplex/Chat/Migrations/M20230129_drop_chat_items_group_idx.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230129_drop_chat_items_group_idx.hs index c45f513765..be31997235 100644 --- a/src/Simplex/Chat/Migrations/M20230129_drop_chat_items_group_idx.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230129_drop_chat_items_group_idx.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx where +module Simplex.Chat.Store.SQLite.Migrations.M20230129_drop_chat_items_group_idx where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230206_item_deleted_by_group_member_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230206_item_deleted_by_group_member_id.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20230206_item_deleted_by_group_member_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230206_item_deleted_by_group_member_id.hs index 085e7f7525..9c3d01795a 100644 --- a/src/Simplex/Chat/Migrations/M20230206_item_deleted_by_group_member_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230206_item_deleted_by_group_member_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id where +module Simplex.Chat.Store.SQLite.Migrations.M20230206_item_deleted_by_group_member_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230303_group_link_role.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230303_group_link_role.hs similarity index 78% rename from src/Simplex/Chat/Migrations/M20230303_group_link_role.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230303_group_link_role.hs index ae67e7d776..cf25fb4f6e 100644 --- a/src/Simplex/Chat/Migrations/M20230303_group_link_role.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230303_group_link_role.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230303_group_link_role where +module Simplex.Chat.Store.SQLite.Migrations.M20230303_group_link_role where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230317_hidden_profiles.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230317_hidden_profiles.hs index 65e9cfeadd..0106d29118 100644 --- a/src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230317_hidden_profiles.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230317_hidden_profiles where +module Simplex.Chat.Store.SQLite.Migrations.M20230317_hidden_profiles where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230318_file_description.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230318_file_description.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20230318_file_description.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230318_file_description.hs index 39f56b2a48..3bb15037ed 100644 --- a/src/Simplex/Chat/Migrations/M20230318_file_description.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230318_file_description.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230318_file_description where +module Simplex.Chat.Store.SQLite.Migrations.M20230318_file_description where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230321_agent_file_deleted.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230321_agent_file_deleted.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20230321_agent_file_deleted.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230321_agent_file_deleted.hs index 97c213ea48..2a54f05c4c 100644 --- a/src/Simplex/Chat/Migrations/M20230321_agent_file_deleted.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230321_agent_file_deleted.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230321_agent_file_deleted where +module Simplex.Chat.Store.SQLite.Migrations.M20230321_agent_file_deleted where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230328_files_protocol.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230328_files_protocol.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20230328_files_protocol.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230328_files_protocol.hs index 5690378301..7ea2b4e34f 100644 --- a/src/Simplex/Chat/Migrations/M20230328_files_protocol.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230328_files_protocol.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230328_files_protocol where +module Simplex.Chat.Store.SQLite.Migrations.M20230328_files_protocol where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230402_protocol_servers.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230402_protocol_servers.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20230402_protocol_servers.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230402_protocol_servers.hs index bffe7ac813..be2d0b96b2 100644 --- a/src/Simplex/Chat/Migrations/M20230402_protocol_servers.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230402_protocol_servers.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230402_protocol_servers where +module Simplex.Chat.Store.SQLite.Migrations.M20230402_protocol_servers where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230411_extra_xftp_file_descriptions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230411_extra_xftp_file_descriptions.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20230411_extra_xftp_file_descriptions.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230411_extra_xftp_file_descriptions.hs index 9bfd773c44..739770d84f 100644 --- a/src/Simplex/Chat/Migrations/M20230411_extra_xftp_file_descriptions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230411_extra_xftp_file_descriptions.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230411_extra_xftp_file_descriptions where +module Simplex.Chat.Store.SQLite.Migrations.M20230411_extra_xftp_file_descriptions where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230420_rcv_files_to_receive.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230420_rcv_files_to_receive.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20230420_rcv_files_to_receive.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230420_rcv_files_to_receive.hs index 0b6329bc6d..cd97e16c03 100644 --- a/src/Simplex/Chat/Migrations/M20230420_rcv_files_to_receive.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230420_rcv_files_to_receive.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230420_rcv_files_to_receive where +module Simplex.Chat.Store.SQLite.Migrations.M20230420_rcv_files_to_receive where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230422_profile_contact_links.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230422_profile_contact_links.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20230422_profile_contact_links.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230422_profile_contact_links.hs index ee7ff053d5..f9513acff1 100644 --- a/src/Simplex/Chat/Migrations/M20230422_profile_contact_links.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230422_profile_contact_links.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230422_profile_contact_links where +module Simplex.Chat.Store.SQLite.Migrations.M20230422_profile_contact_links where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs index 009b537b6c..c51db6905b 100644 --- a/src/Simplex/Chat/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230504_recreate_msg_delivery_events_cleanup_messages.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages where +module Simplex.Chat.Store.SQLite.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230505_chat_item_versions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230505_chat_item_versions.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20230505_chat_item_versions.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230505_chat_item_versions.hs index 7e2e0f7719..bfc675c87c 100644 --- a/src/Simplex/Chat/Migrations/M20230505_chat_item_versions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230505_chat_item_versions.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230505_chat_item_versions where +module Simplex.Chat.Store.SQLite.Migrations.M20230505_chat_item_versions where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230511_reactions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs similarity index 96% rename from src/Simplex/Chat/Migrations/M20230511_reactions.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs index f01954a373..17ecb97649 100644 --- a/src/Simplex/Chat/Migrations/M20230511_reactions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230511_reactions where +module Simplex.Chat.Store.SQLite.Migrations.M20230511_reactions where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230519_item_deleted_ts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230519_item_deleted_ts.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20230519_item_deleted_ts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230519_item_deleted_ts.hs index 20d5fd7a8d..c05687347c 100644 --- a/src/Simplex/Chat/Migrations/M20230519_item_deleted_ts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230519_item_deleted_ts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230519_item_deleted_ts where +module Simplex.Chat.Store.SQLite.Migrations.M20230519_item_deleted_ts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230526_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230526_indexes.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20230526_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230526_indexes.hs index 56c61b521b..19a9985c80 100644 --- a/src/Simplex/Chat/Migrations/M20230526_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230526_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230526_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20230526_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230529_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230529_indexes.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20230529_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230529_indexes.hs index afb12a5c6d..d112c861e5 100644 --- a/src/Simplex/Chat/Migrations/M20230529_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230529_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230529_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20230529_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230608_deleted_contacts.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230608_deleted_contacts.hs index b7193300df..354fa49ca4 100644 --- a/src/Simplex/Chat/Migrations/M20230608_deleted_contacts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230608_deleted_contacts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230608_deleted_contacts where +module Simplex.Chat.Store.SQLite.Migrations.M20230608_deleted_contacts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230618_favorite_chats.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230618_favorite_chats.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20230618_favorite_chats.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230618_favorite_chats.hs index 66f65a926b..4905093b0f 100644 --- a/src/Simplex/Chat/Migrations/M20230618_favorite_chats.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230618_favorite_chats.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230618_favorite_chats where +module Simplex.Chat.Store.SQLite.Migrations.M20230618_favorite_chats where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230621_chat_item_moderations.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230621_chat_item_moderations.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20230621_chat_item_moderations.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230621_chat_item_moderations.hs index 449e21e209..015a22f8b2 100644 --- a/src/Simplex/Chat/Migrations/M20230621_chat_item_moderations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230621_chat_item_moderations.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230621_chat_item_moderations where +module Simplex.Chat.Store.SQLite.Migrations.M20230621_chat_item_moderations where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230705_delivery_receipts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230705_delivery_receipts.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20230705_delivery_receipts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230705_delivery_receipts.hs index ec59209d6b..1f4a13cc56 100644 --- a/src/Simplex/Chat/Migrations/M20230705_delivery_receipts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230705_delivery_receipts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230705_delivery_receipts where +module Simplex.Chat.Store.SQLite.Migrations.M20230705_delivery_receipts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230721_group_snd_item_statuses.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230721_group_snd_item_statuses.hs index 8453da88f5..6ce99450a4 100644 --- a/src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230721_group_snd_item_statuses.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230721_group_snd_item_statuses where +module Simplex.Chat.Store.SQLite.Migrations.M20230721_group_snd_item_statuses where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230814_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230814_indexes.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20230814_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230814_indexes.hs index a7419037ef..5e5b2122ca 100644 --- a/src/Simplex/Chat/Migrations/M20230814_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230814_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230814_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20230814_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230827_file_encryption.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20230827_file_encryption.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230827_file_encryption.hs index 2e659cac84..2378df1b61 100644 --- a/src/Simplex/Chat/Migrations/M20230827_file_encryption.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230827_file_encryption.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230827_file_encryption where +module Simplex.Chat.Store.SQLite.Migrations.M20230827_file_encryption where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230829_connections_chat_vrange.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230829_connections_chat_vrange.hs index 2588553a92..66d4f32d70 100644 --- a/src/Simplex/Chat/Migrations/M20230829_connections_chat_vrange.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230829_connections_chat_vrange.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230829_connections_chat_vrange where +module Simplex.Chat.Store.SQLite.Migrations.M20230829_connections_chat_vrange where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230903_connections_to_subscribe.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230903_connections_to_subscribe.hs index 48ad8dbf86..449e94510d 100644 --- a/src/Simplex/Chat/Migrations/M20230903_connections_to_subscribe.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230903_connections_to_subscribe.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230903_connections_to_subscribe where +module Simplex.Chat.Store.SQLite.Migrations.M20230903_connections_to_subscribe where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230913_member_contacts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230913_member_contacts.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20230913_member_contacts.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230913_member_contacts.hs index b116373518..b3202745fd 100644 --- a/src/Simplex/Chat/Migrations/M20230913_member_contacts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230913_member_contacts.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230913_member_contacts where +module Simplex.Chat.Store.SQLite.Migrations.M20230913_member_contacts where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230914_member_probes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230914_member_probes.hs similarity index 98% rename from src/Simplex/Chat/Migrations/M20230914_member_probes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230914_member_probes.hs index 8772b6cdad..f5725c3f39 100644 --- a/src/Simplex/Chat/Migrations/M20230914_member_probes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230914_member_probes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230914_member_probes where +module Simplex.Chat.Store.SQLite.Migrations.M20230914_member_probes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20230926_contact_status.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230926_contact_status.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20230926_contact_status.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20230926_contact_status.hs index b6c5dd9557..0ec499e5f6 100644 --- a/src/Simplex/Chat/Migrations/M20230926_contact_status.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230926_contact_status.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20230926_contact_status where +module Simplex.Chat.Store.SQLite.Migrations.M20230926_contact_status where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231002_conn_initiated.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231002_conn_initiated.hs index a0f6009af2..c7c84717bd 100644 --- a/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231002_conn_initiated.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231002_conn_initiated where +module Simplex.Chat.Store.SQLite.Migrations.M20231002_conn_initiated where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231009_via_group_link_uri_hash.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231009_via_group_link_uri_hash.hs index 41c9887a04..87111c77d4 100644 --- a/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231009_via_group_link_uri_hash.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash where +module Simplex.Chat.Store.SQLite.Migrations.M20231009_via_group_link_uri_hash where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231010_member_settings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231010_member_settings.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20231010_member_settings.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231010_member_settings.hs index e31203e572..faf6639ce3 100644 --- a/src/Simplex/Chat/Migrations/M20231010_member_settings.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231010_member_settings.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231010_member_settings where +module Simplex.Chat.Store.SQLite.Migrations.M20231010_member_settings where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231019_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231019_indexes.hs similarity index 93% rename from src/Simplex/Chat/Migrations/M20231019_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231019_indexes.hs index 40412e1778..cb1f5ec104 100644 --- a/src/Simplex/Chat/Migrations/M20231019_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231019_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231019_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20231019_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231030_xgrplinkmem_received.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231030_xgrplinkmem_received.hs index cf4aee2531..1a82aa6d70 100644 --- a/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231030_xgrplinkmem_received.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231030_xgrplinkmem_received where +module Simplex.Chat.Store.SQLite.Migrations.M20231030_xgrplinkmem_received where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231107_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231107_indexes.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20231107_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231107_indexes.hs index a4c9c5295a..07e6ce5888 100644 --- a/src/Simplex/Chat/Migrations/M20231107_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231107_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231107_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20231107_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231113_group_forward.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231113_group_forward.hs similarity index 97% rename from src/Simplex/Chat/Migrations/M20231113_group_forward.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231113_group_forward.hs index f23387f011..b83a2c780d 100644 --- a/src/Simplex/Chat/Migrations/M20231113_group_forward.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231113_group_forward.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231113_group_forward where +module Simplex.Chat.Store.SQLite.Migrations.M20231113_group_forward where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231114_remote_control.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231114_remote_control.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20231114_remote_control.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231114_remote_control.hs index e716b2aa63..f002c9dd3d 100644 --- a/src/Simplex/Chat/Migrations/M20231114_remote_control.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231114_remote_control.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231114_remote_control where +module Simplex.Chat.Store.SQLite.Migrations.M20231114_remote_control where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231126_remote_ctrl_address.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231126_remote_ctrl_address.hs index 343e4ca6fa..98a464219c 100644 --- a/src/Simplex/Chat/Migrations/M20231126_remote_ctrl_address.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231126_remote_ctrl_address.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231126_remote_ctrl_address where +module Simplex.Chat.Store.SQLite.Migrations.M20231126_remote_ctrl_address where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231207_chat_list_pagination.hs similarity index 92% rename from src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231207_chat_list_pagination.hs index 9a8944c5c5..f02be82919 100644 --- a/src/Simplex/Chat/Migrations/M20231207_chat_list_pagination.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231207_chat_list_pagination.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231207_chat_list_pagination where +module Simplex.Chat.Store.SQLite.Migrations.M20231207_chat_list_pagination where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231214_item_content_tag.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231214_item_content_tag.hs index cd4cd136e5..06ef294702 100644 --- a/src/Simplex/Chat/Migrations/M20231214_item_content_tag.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231214_item_content_tag.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231214_item_content_tag where +module Simplex.Chat.Store.SQLite.Migrations.M20231214_item_content_tag where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20231215_recreate_msg_deliveries.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20231215_recreate_msg_deliveries.hs similarity index 98% rename from src/Simplex/Chat/Migrations/M20231215_recreate_msg_deliveries.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20231215_recreate_msg_deliveries.hs index 4b39606d8d..fa2d55e7bb 100644 --- a/src/Simplex/Chat/Migrations/M20231215_recreate_msg_deliveries.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20231215_recreate_msg_deliveries.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries where +module Simplex.Chat.Store.SQLite.Migrations.M20231215_recreate_msg_deliveries where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240102_note_folders.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240102_note_folders.hs similarity index 94% rename from src/Simplex/Chat/Migrations/M20240102_note_folders.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240102_note_folders.hs index 02ad741662..f06b3aa0ed 100644 --- a/src/Simplex/Chat/Migrations/M20240102_note_folders.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240102_note_folders.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240102_note_folders where +module Simplex.Chat.Store.SQLite.Migrations.M20240102_note_folders where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240104_members_profile_update.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240104_members_profile_update.hs index 5591c4bdcd..9e9f813a22 100644 --- a/src/Simplex/Chat/Migrations/M20240104_members_profile_update.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240104_members_profile_update.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240104_members_profile_update where +module Simplex.Chat.Store.SQLite.Migrations.M20240104_members_profile_update where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240115_block_member_for_all.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240115_block_member_for_all.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240115_block_member_for_all.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240115_block_member_for_all.hs index af2448e42c..9a43ffa55e 100644 --- a/src/Simplex/Chat/Migrations/M20240115_block_member_for_all.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240115_block_member_for_all.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240115_block_member_for_all where +module Simplex.Chat.Store.SQLite.Migrations.M20240115_block_member_for_all where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240122_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240122_indexes.hs similarity index 93% rename from src/Simplex/Chat/Migrations/M20240122_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240122_indexes.hs index 7b708f8bbe..cefc5eda7b 100644 --- a/src/Simplex/Chat/Migrations/M20240122_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240122_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240122_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20240122_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240214_redirect_file_id.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240214_redirect_file_id.hs index da8f4d413b..010cee5ca7 100644 --- a/src/Simplex/Chat/Migrations/M20240214_redirect_file_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240214_redirect_file_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240214_redirect_file_id where +module Simplex.Chat.Store.SQLite.Migrations.M20240214_redirect_file_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240222_app_settings.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240222_app_settings.hs similarity index 82% rename from src/Simplex/Chat/Migrations/M20240222_app_settings.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240222_app_settings.hs index e7fda06a2e..caa9b8ab77 100644 --- a/src/Simplex/Chat/Migrations/M20240222_app_settings.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240222_app_settings.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240222_app_settings where +module Simplex.Chat.Store.SQLite.Migrations.M20240222_app_settings where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240226_users_restrict.hs similarity index 89% rename from src/Simplex/Chat/Migrations/M20240226_users_restrict.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240226_users_restrict.hs index a68923142c..eb1bc2bfea 100644 --- a/src/Simplex/Chat/Migrations/M20240226_users_restrict.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240226_users_restrict.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240226_users_restrict where +module Simplex.Chat.Store.SQLite.Migrations.M20240226_users_restrict where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240228_pq.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240228_pq.hs similarity index 93% rename from src/Simplex/Chat/Migrations/M20240228_pq.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240228_pq.hs index c496d33b4b..5be3dcc458 100644 --- a/src/Simplex/Chat/Migrations/M20240228_pq.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240228_pq.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240228_pq where +module Simplex.Chat.Store.SQLite.Migrations.M20240228_pq where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240313_drop_agent_ack_cmd_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240313_drop_agent_ack_cmd_id.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20240313_drop_agent_ack_cmd_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240313_drop_agent_ack_cmd_id.hs index c14f08447e..6c5d82ab80 100644 --- a/src/Simplex/Chat/Migrations/M20240313_drop_agent_ack_cmd_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240313_drop_agent_ack_cmd_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240313_drop_agent_ack_cmd_id where +module Simplex.Chat.Store.SQLite.Migrations.M20240313_drop_agent_ack_cmd_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240324_custom_data.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240324_custom_data.hs similarity index 85% rename from src/Simplex/Chat/Migrations/M20240324_custom_data.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240324_custom_data.hs index bc1c4807eb..e084920ab0 100644 --- a/src/Simplex/Chat/Migrations/M20240324_custom_data.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240324_custom_data.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240324_custom_data where +module Simplex.Chat.Store.SQLite.Migrations.M20240324_custom_data where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240402_item_forwarded.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240402_item_forwarded.hs index 850c8be2d9..a32f210e3c 100644 --- a/src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240402_item_forwarded.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240402_item_forwarded where +module Simplex.Chat.Store.SQLite.Migrations.M20240402_item_forwarded where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240430_ui_theme.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240430_ui_theme.hs similarity index 88% rename from src/Simplex/Chat/Migrations/M20240430_ui_theme.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240430_ui_theme.hs index 1f4b9805cf..a646582a89 100644 --- a/src/Simplex/Chat/Migrations/M20240430_ui_theme.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240430_ui_theme.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240430_ui_theme where +module Simplex.Chat.Store.SQLite.Migrations.M20240430_ui_theme where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240501_chat_deleted.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240501_chat_deleted.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240501_chat_deleted.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240501_chat_deleted.hs index a7faf33472..de8135b066 100644 --- a/src/Simplex/Chat/Migrations/M20240501_chat_deleted.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240501_chat_deleted.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240501_chat_deleted where +module Simplex.Chat.Store.SQLite.Migrations.M20240501_chat_deleted where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240510_chat_items_via_proxy.hs similarity index 86% rename from src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240510_chat_items_via_proxy.hs index 3c32034344..a2fc2cef85 100644 --- a/src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240510_chat_items_via_proxy.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240510_chat_items_via_proxy where +module Simplex.Chat.Store.SQLite.Migrations.M20240510_chat_items_via_proxy where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240515_rcv_files_user_approved_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240515_rcv_files_user_approved_relays.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240515_rcv_files_user_approved_relays.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240515_rcv_files_user_approved_relays.hs index cd4f647685..341b6c2c41 100644 --- a/src/Simplex/Chat/Migrations/M20240515_rcv_files_user_approved_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240515_rcv_files_user_approved_relays.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays where +module Simplex.Chat.Store.SQLite.Migrations.M20240515_rcv_files_user_approved_relays where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240528_quota_err_counter.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240528_quota_err_counter.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20240528_quota_err_counter.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240528_quota_err_counter.hs index ea1f3a78e7..c4d121e068 100644 --- a/src/Simplex/Chat/Migrations/M20240528_quota_err_counter.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240528_quota_err_counter.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240528_quota_err_counter where +module Simplex.Chat.Store.SQLite.Migrations.M20240528_quota_err_counter where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240827_calls_uuid.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240827_calls_uuid.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240827_calls_uuid.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240827_calls_uuid.hs index eb1e8db65a..1d24eabb77 100644 --- a/src/Simplex/Chat/Migrations/M20240827_calls_uuid.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240827_calls_uuid.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240827_calls_uuid where +module Simplex.Chat.Store.SQLite.Migrations.M20240827_calls_uuid where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20240920_user_order.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20240920_user_order.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20240920_user_order.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20240920_user_order.hs index 29fd1532f2..02fcf37245 100644 --- a/src/Simplex/Chat/Migrations/M20240920_user_order.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20240920_user_order.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20240920_user_order where +module Simplex.Chat.Store.SQLite.Migrations.M20240920_user_order where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241008_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241008_indexes.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20241008_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241008_indexes.hs index 94cffa8d74..a6a905a703 100644 --- a/src/Simplex/Chat/Migrations/M20241008_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241008_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241008_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20241008_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241010_contact_requests_contact_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241010_contact_requests_contact_id.hs similarity index 87% rename from src/Simplex/Chat/Migrations/M20241010_contact_requests_contact_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241010_contact_requests_contact_id.hs index 24e7f3a98e..b7a9b74d14 100644 --- a/src/Simplex/Chat/Migrations/M20241010_contact_requests_contact_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241010_contact_requests_contact_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241010_contact_requests_contact_id where +module Simplex.Chat.Store.SQLite.Migrations.M20241010_contact_requests_contact_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241023_chat_item_autoincrement_id.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241023_chat_item_autoincrement_id.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20241023_chat_item_autoincrement_id.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241023_chat_item_autoincrement_id.hs index 7f1e272026..03b5c40ed3 100644 --- a/src/Simplex/Chat/Migrations/M20241023_chat_item_autoincrement_id.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241023_chat_item_autoincrement_id.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241023_chat_item_autoincrement_id where +module Simplex.Chat.Store.SQLite.Migrations.M20241023_chat_item_autoincrement_id where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241027_server_operators.hs similarity index 96% rename from src/Simplex/Chat/Migrations/M20241027_server_operators.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241027_server_operators.hs index 1316e3c006..462ab09f5d 100644 --- a/src/Simplex/Chat/Migrations/M20241027_server_operators.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241027_server_operators.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241027_server_operators where +module Simplex.Chat.Store.SQLite.Migrations.M20241027_server_operators where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241125_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241125_indexes.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20241125_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241125_indexes.hs index 2115de09a3..e05b111e99 100644 --- a/src/Simplex/Chat/Migrations/M20241125_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241125_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241125_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20241125_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241128_business_chats.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241128_business_chats.hs similarity index 91% rename from src/Simplex/Chat/Migrations/M20241128_business_chats.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241128_business_chats.hs index 2b3be38030..486250295a 100644 --- a/src/Simplex/Chat/Migrations/M20241128_business_chats.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241128_business_chats.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241128_business_chats where +module Simplex.Chat.Store.SQLite.Migrations.M20241128_business_chats where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241205_business_chat_members.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241205_business_chat_members.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20241205_business_chat_members.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241205_business_chat_members.hs index 5d019d73e1..fa0cbe36da 100644 --- a/src/Simplex/Chat/Migrations/M20241205_business_chat_members.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241205_business_chat_members.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241205_business_chat_members where +module Simplex.Chat.Store.SQLite.Migrations.M20241205_business_chat_members where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs similarity index 84% rename from src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs index c0c4304313..761d883ead 100644 --- a/src/Simplex/Chat/Migrations/M20241222_operator_conditions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241222_operator_conditions.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241222_operator_conditions where +module Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs similarity index 95% rename from src/Simplex/Chat/Migrations/M20241223_chat_tags.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs index a83be7549d..4c2d4d1745 100644 --- a/src/Simplex/Chat/Migrations/M20241223_chat_tags.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241223_chat_tags.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241223_chat_tags where +module Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20241230_reports.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20241230_reports.hs similarity index 83% rename from src/Simplex/Chat/Migrations/M20241230_reports.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20241230_reports.hs index 7d605824f5..60c2b51525 100644 --- a/src/Simplex/Chat/Migrations/M20241230_reports.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20241230_reports.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20241230_reports where +module Simplex.Chat.Store.SQLite.Migrations.M20241230_reports where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/M20250105_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs similarity index 90% rename from src/Simplex/Chat/Migrations/M20250105_indexes.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs index dd01f21389..09e3246292 100644 --- a/src/Simplex/Chat/Migrations/M20250105_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs @@ -1,6 +1,6 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Migrations.M20250105_indexes where +module Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) diff --git a/src/Simplex/Chat/Migrations/chat_lint.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql similarity index 100% rename from src/Simplex/Chat/Migrations/chat_lint.sql rename to src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql similarity index 100% rename from src/Simplex/Chat/Migrations/chat_schema.sql rename to src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index a2b8fbdf6b..5b56b67704 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} @@ -27,9 +28,6 @@ import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Database.SQLite.Simple (NamedParam (..), Only (..), Query, SQLError, (:.) (..)) -import qualified Database.SQLite.Simple as SQL -import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Remote.Types @@ -39,7 +37,8 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..)) import qualified Simplex.Messaging.Crypto.Ratchet as CR @@ -48,6 +47,15 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (allFinally) import Simplex.Messaging.Version import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, SqlError, (:.) (..)) +import Database.PostgreSQL.Simple.Errors (constraintViolation) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, SQLError, (:.) (..)) +import qualified Database.SQLite.Simple as SQL +import Database.SQLite.Simple.QQ (sql) +#endif data ChatLockEntity = CLInvitation ByteString @@ -137,14 +145,32 @@ data StoreError $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) insertedRowId :: DB.Connection -> IO Int64 -insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" +insertedRowId db = fromOnly . head <$> DB.query_ db q + where +#if defined(dbPostgres) + q = "SELECT lastval()" +#else + q = "SELECT last_insert_rowid()" +#endif checkConstraint :: StoreError -> ExceptT StoreError IO a -> ExceptT StoreError IO a checkConstraint err action = ExceptT $ runExceptT action `E.catch` (pure . Left . handleSQLError err) +#if defined(dbPostgres) +type SQLError = SqlError +#endif + +constraintError :: SQLError -> Bool +#if defined(dbPostgres) +constraintError = isJust . constraintViolation +#else +constraintError e = SQL.sqlError e == SQL.ErrorConstraint +#endif +{-# INLINE constraintError #-} + handleSQLError :: StoreError -> SQLError -> StoreError handleSQLError err e - | SQL.sqlError e == SQL.ErrorConstraint = err + | constraintError e = err | otherwise = SEInternalError $ show e storeFinally :: ExceptT StoreError IO a -> ExceptT StoreError IO b -> ExceptT StoreError IO a @@ -168,12 +194,12 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, Int, Maybe VersionChat, VersionChat, VersionChat) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, BoolInt, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, BoolInt, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, Int, Maybe VersionChat, VersionChat, VersionChat) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe BoolInt, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe BoolInt, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) toConnection :: VersionRangeChat -> ConnectionRow -> Connection -toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = +toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, @@ -227,9 +253,9 @@ createConnection_ db userId connType entityId acId connStatus connChatVersion pe conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, customUserProfileId, connStatus, connType) + ( (userId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, customUserProfileId, connStatus, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) - :. (connChatVersion, minV, maxV, subMode == SMOnlyCreate, pqSup, pqSup) + :. (connChatVersion, minV, maxV, BI (subMode == SMOnlyCreate), pqSup, pqSup) ) connId <- insertedRowId db pure @@ -269,7 +295,7 @@ createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, imag INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) VALUES (?,?,?,?,?,?,?) |] - (displayName, fullName, image, userId, Just True, createdAt, createdAt) + (displayName, fullName, image, userId, Just (BI True), createdAt, createdAt) insertedRowId db updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> PQEncryption -> IO () @@ -366,37 +392,37 @@ createContact_ db userId Profile {displayName, fullName, image, contactLink, pre DB.execute db "INSERT INTO contacts (contact_profile_id, local_display_name, user_id, via_group, created_at, updated_at, chat_ts, contact_used) VALUES (?,?,?,?,?,?,?,?)" - (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, contactUsed) + (profileId, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI contactUsed) contactId <- insertedRowId db pure $ Right (ldn, contactId, profileId) deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO () deleteUnusedIncognitoProfileById_ db User {userId} profileId = - DB.executeNamed + DB.execute db [sql| DELETE FROM contact_profiles - WHERE user_id = :user_id AND contact_profile_id = :profile_id AND incognito = 1 + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 AND 1 NOT IN ( SELECT 1 FROM connections - WHERE user_id = :user_id AND custom_user_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND custom_user_profile_id = ? LIMIT 1 ) AND 1 NOT IN ( SELECT 1 FROM group_members - WHERE user_id = :user_id AND member_profile_id = :profile_id LIMIT 1 + WHERE user_id = ? AND member_profile_id = ? LIMIT 1 ) |] - [":user_id" := userId, ":profile_id" := profileId] + (userId, profileId, userId, profileId, userId, profileId) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, Bool, Maybe UIThemeEntityOverrides, Bool, Maybe CustomData) +type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData) type ContactRow = Only ContactId :. ContactRow' toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) :. connRow) = +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toMaybeConnection vr connRow - chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} @@ -434,8 +460,8 @@ userQuery = JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, Bool, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User -toUser ((userId, auId, userContactId, profileId, activeUser, activeOrder, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = +toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User +toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder, displayName, fullName, image, contactLink, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash, userMemberProfileUpdatedAt, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences = userPreferences, localAlias = ""} @@ -462,15 +488,15 @@ withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreate getLdnSuffix :: IO Int getLdnSuffix = maybe 0 ((+ 1) . fromOnly) . listToMaybe - <$> DB.queryNamed + <$> DB.query db [sql| SELECT ldn_suffix FROM display_names - WHERE user_id = :user_id AND ldn_base = :display_name + WHERE user_id = ? AND ldn_base = ? ORDER BY ldn_suffix DESC LIMIT 1 |] - [":user_id" := userId, ":display_name" := displayName] + (userId, displayName) tryCreateName :: Int -> Int -> IO (Either StoreError a) tryCreateName _ 0 = pure $ Left SEDuplicateName tryCreateName ldnSuffix attempts = do @@ -479,7 +505,7 @@ withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreate E.try (insertName ldn currentTs) >>= \case Right () -> action ldn Left e - | SQL.sqlError e == SQL.ErrorConstraint -> tryCreateName (ldnSuffix + 1) (attempts - 1) + | constraintError e -> tryCreateName (ldnSuffix + 1) (attempts - 1) | otherwise -> E.throwIO e where insertName ldn ts = @@ -511,7 +537,7 @@ createWithRandomBytes' size gVar create = tryCreate 3 liftIO (E.try $ create id') >>= \case Right x -> liftEither x Left e - | SQL.sqlError e == SQL.ErrorConstraint -> tryCreate (n - 1) + | constraintError e -> tryCreate (n - 1) | otherwise -> throwError . SEInternalError $ show e encodedRandomBytes :: TVar ChaChaDRG -> Int -> IO ByteString @@ -549,21 +575,21 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} - chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} businessChat = toBusinessChatInfo businessRow in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, uiThemes, customData} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 2ff9e60699..829bdc3d31 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE NamedFieldPuns #-} @@ -6,14 +7,8 @@ module Simplex.Chat.Terminal where -import Control.Exception (handle, throwIO) import Control.Monad -import qualified Data.ByteArray as BA import qualified Data.List.NonEmpty as L -import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) -import Database.SQLite.Simple (SQLError (..)) -import qualified Database.SQLite.Simple as DB import Simplex.Chat (defaultChatConfig, operatorSimpleXChat) import Simplex.Chat.Controller import Simplex.Chat.Core @@ -21,12 +16,21 @@ import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Library.Commands (_defaultNtfServers) import Simplex.Chat.Operators import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) +#if !defined(dbPostgres) +import Control.Exception (handle, throwIO) +import qualified Data.ByteArray as BA +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Database.SQLite.Simple (SQLError (..)) +import qualified Database.SQLite.Simple as DB import System.IO (hFlush, hSetEcho, stdin, stdout) +#endif terminalChatConfig :: ChatConfig terminalChatConfig = @@ -61,7 +65,14 @@ terminalChatConfig = simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () simplexChatTerminal cfg options t = run options where - run opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {dbKey}} = +#if defined(dbPostgres) + run opts = + simplexChatCore cfg opts $ \u cc -> do + ct <- newChatTerminal t opts + when (firstTime cc) . printToTerminal ct $ chatWelcome u + runChatTerminal ct cc opts +#else + run opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {dbOptions}} = handle checkDBKeyError . simplexChatCore cfg opts $ \u cc -> do ct <- newChatTerminal t opts when (firstTime cc) . printToTerminal ct $ chatWelcome u @@ -70,7 +81,7 @@ simplexChatTerminal cfg options t = run options checkDBKeyError :: SQLError -> IO () checkDBKeyError e = case sqlError e of DB.ErrorNotADatabase -> do - putStrLn $ "Database file is invalid or " <> if BA.null dbKey then "encrypted." else "you passed an incorrect encryption key." + putStrLn $ "Database file is invalid or " <> if BA.null (dbKey dbOptions) then "encrypted." else "you passed an incorrect encryption key." run =<< getKeyOpts _ -> throwIO e getKeyOpts :: IO ChatOpts @@ -81,7 +92,8 @@ simplexChatTerminal cfg options t = run options key <- getLine hSetEcho stdin True putStrLn "" - pure opts {coreOptions = coreOptions {dbKey = BA.convert $ encodeUtf8 $ T.pack key}} + pure opts {coreOptions = coreOptions {dbOptions = dbOptions {dbKey = BA.convert $ encodeUtf8 $ T.pack key}}} +#endif runChatTerminal :: ChatTerminal -> ChatController -> ChatOpts -> IO () runChatTerminal ct cc opts = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc opts, runInputLoop ct cc] diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 073c604009..bf48d1d4f5 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} @@ -25,9 +26,6 @@ import Data.Maybe (isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import Database.SQLite.Simple (Only (..)) -import qualified Database.SQLite.Simple as SQL -import Database.SQLite.Simple.QQ (sql) import GHC.Weak (deRefWeak) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands @@ -36,12 +34,19 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Styled import Simplex.Chat.Terminal.Output import Simplex.Chat.Types (User (..)) -import Simplex.Messaging.Agent.Store.SQLite.Common (DBStore, withTransaction) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (catchAll_, safeDecodeUtf8, whenM) import System.Exit (exitSuccess) import System.Terminal hiding (insertChars) import UnliftIO.STM +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, ToRow) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, ToRow) +import Database.SQLite.Simple.QQ (sql) +#endif getKey :: MonadTerminal m => m (Key, Modifiers) getKey = @@ -321,7 +326,7 @@ updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputStr getNameSfxs table pfx = getNameSfxs_ pfx (userId, pfx <> "%") $ "SELECT local_display_name FROM " <> table <> " WHERE user_id = ? AND local_display_name LIKE ?" - getNameSfxs_ :: SQL.ToRow p => Text -> p -> SQL.Query -> IO [String] + getNameSfxs_ :: ToRow p => Text -> p -> Query -> IO [String] getNameSfxs_ pfx ps q = withTransaction st (\db -> hasPfx pfx . map fromOnly <$> DB.query db q ps) `catchAll_` pure [] commands = diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs index b0eb4dac88..aa9adb059f 100644 --- a/src/Simplex/Chat/Terminal/Main.hs +++ b/src/Simplex/Chat/Terminal/Main.hs @@ -13,6 +13,7 @@ import Network.Socket import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) import Simplex.Chat.Core import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Terminal import Simplex.Chat.View (serializeChatResponse, smpProxyModeStr) import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (..)) @@ -56,11 +57,11 @@ simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServer putStrLn $ serializeChatResponse (rh, Just user) ts tz rh r welcome :: ChatConfig -> ChatOpts -> IO () -welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = +welcome ChatConfig {presetServers = PresetServers {netCfg}} ChatOpts {coreOptions = CoreChatOpts {dbOptions, simpleNetCfg = SimpleNetCfg {socksProxy, socksMode, smpProxyMode_, smpProxyFallback_}}} = mapM_ putStrLn [ versionString versionNumber, - "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db", + "db: " <> dbString dbOptions, maybe "direct network connection - use `/network` command or `-x` CLI option to connect via SOCKS5 at :9050" ((\sp -> "using SOCKS5 proxy " <> sp <> if socksMode == SMOnion then " for onion servers ONLY." else " for ALL servers.") . show) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index dbabc7c262..8e9fbf55f4 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1,17 +1,21 @@ {-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilyDependencies #-} @@ -29,6 +33,7 @@ import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString, pack, unpack) +import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.Maybe (isJust) import Data.Text (Text) @@ -37,11 +42,6 @@ import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) import Data.Word (Word16) -import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (FromField (..), returnError) -import Database.SQLite.Simple.Internal (Field (..)) -import Database.SQLite.Simple.Ok -import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -49,13 +49,23 @@ import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent.Protocol (ACorrId, AEventTag (..), AEvtTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) +import Simplex.Messaging.Agent.Store.DB (Binary (..)) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (ResultError (..)) +import Database.PostgreSQL.Simple.FromField (FromField(..), FieldParser, returnError) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple (ResultError (..)) +import Database.SQLite.Simple.FromField (FromField (..), FieldParser, returnError) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif class IsContact a where contactId' :: a -> ContactId @@ -98,7 +108,7 @@ instance ToJSON AgentUserId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentUserId where fromField f = AgentUserId <$> fromField f +deriving newtype instance FromField AgentUserId instance ToField AgentUserId where toField (AgentUserId uId) = toField uId @@ -131,10 +141,9 @@ data NewUser = NewUser newtype B64UrlByteString = B64UrlByteString ByteString deriving (Eq, Show) + deriving newtype (FromField) -instance FromField B64UrlByteString where fromField f = B64UrlByteString <$> fromField f - -instance ToField B64UrlByteString where toField (B64UrlByteString m) = toField m +instance ToField B64UrlByteString where toField (B64UrlByteString m) = toField $ Binary m instance StrEncoding B64UrlByteString where strEncode (B64UrlByteString m) = strEncode m @@ -195,9 +204,9 @@ instance ToJSON CustomData where instance FromJSON CustomData where parseJSON = J.withObject "CustomData" (pure . CustomData) -instance ToField CustomData where toField (CustomData v) = toField $ J.encode v +instance ToField CustomData where toField (CustomData v) = toField . Binary . LB.toStrict $ J.encode v -instance FromField CustomData where fromField = fromBlobField_ J.eitherDecodeStrict +instance FromField CustomData where fromField = blobFieldDecoder J.eitherDecodeStrict contactConn :: Contact -> Maybe Connection contactConn Contact {activeConn} = activeConn @@ -316,10 +325,9 @@ data UserContactRequest = UserContactRequest newtype XContactId = XContactId ByteString deriving (Eq, Show) + deriving newtype (FromField) -instance FromField XContactId where fromField f = XContactId <$> fromField f - -instance ToField XContactId where toField (XContactId m) = toField m +instance ToField XContactId where toField (XContactId m) = toField $ Binary m instance StrEncoding XContactId where strEncode (XContactId m) = strEncode m @@ -335,10 +343,9 @@ instance ToJSON XContactId where newtype ConnReqUriHash = ConnReqUriHash {unConnReqUriHash :: ByteString} deriving (Eq, Show) + deriving newtype (FromField) -instance FromField ConnReqUriHash where fromField f = ConnReqUriHash <$> fromField f - -instance ToField ConnReqUriHash where toField (ConnReqUriHash m) = toField m +instance ToField ConnReqUriHash where toField (ConnReqUriHash m) = toField $ Binary m instance StrEncoding ConnReqUriHash where strEncode (ConnReqUriHash m) = strEncode m @@ -457,13 +464,16 @@ msgFilterIntP = \case 2 -> Just MFMentions _ -> Just MFAll -fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a -fromIntField_ fromInt = \case - f@(Field (SQLInteger i) _) -> - case fromInt i of - Just x -> Ok x - _ -> returnError ConversionFailed f ("invalid integer: " <> show i) - f -> returnError ConversionFailed f "expecting SQLInteger column type" +fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> FieldParser a +#if defined(dbPostgres) +fromIntField_ fromInt f val = fromField f val >>= parseInt +#else +fromIntField_ fromInt f = fromField f >>= parseInt +#endif + where + parseInt i = case fromInt i of + Just x -> pure x + _ -> returnError ConversionFailed f $ "invalid integer: " <> show i featureAllowed :: SChatFeature f -> (PrefEnabled -> Bool) -> Contact -> Bool featureAllowed feature forWhom Contact {mergedPreferences} = @@ -593,16 +603,15 @@ instance ToJSON ImageData where instance ToField ImageData where toField (ImageData t) = toField t -instance FromField ImageData where fromField = fmap ImageData . fromField +deriving newtype instance FromField ImageData data CReqClientData = CRDataGroup {groupLinkId :: GroupLinkId} newtype GroupLinkId = GroupLinkId {unGroupLinkId :: ByteString} -- used to identify invitation via group link deriving (Eq, Show) + deriving newtype (FromField) -instance FromField GroupLinkId where fromField f = GroupLinkId <$> fromField f - -instance ToField GroupLinkId where toField (GroupLinkId g) = toField g +instance ToField GroupLinkId where toField (GroupLinkId g) = toField $ Binary g instance StrEncoding GroupLinkId where strEncode (GroupLinkId g) = strEncode g @@ -679,7 +688,7 @@ data MemberRestrictionStatus | MRSUnknown Text deriving (Eq, Show) -instance FromField MemberRestrictionStatus where fromField = fromBlobField_ strDecode +instance FromField MemberRestrictionStatus where fromField = blobFieldDecoder strDecode instance ToField MemberRestrictionStatus where toField = toField . strEncode @@ -808,10 +817,9 @@ data NewGroupMember = NewGroupMember newtype MemberId = MemberId {unMemberId :: ByteString} deriving (Eq, Show) + deriving newtype (FromField) -instance FromField MemberId where fromField f = MemberId <$> fromField f - -instance ToField MemberId where toField (MemberId m) = toField m +instance ToField MemberId where toField (MemberId m) = toField $ Binary m instance StrEncoding MemberId where strEncode (MemberId m) = strEncode m @@ -1162,6 +1170,9 @@ liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft newtype AgentConnId = AgentConnId ConnId deriving (Eq, Ord, Show) + deriving newtype (FromField) + +instance ToField AgentConnId where toField (AgentConnId m) = toField $ Binary m instance StrEncoding AgentConnId where strEncode (AgentConnId connId) = strEncode connId @@ -1175,12 +1186,11 @@ instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentConnId where fromField f = AgentConnId <$> fromField f - -instance ToField AgentConnId where toField (AgentConnId m) = toField m - newtype AgentSndFileId = AgentSndFileId SndFileId deriving (Eq, Show) + deriving newtype (FromField) + +instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField $ Binary m instance StrEncoding AgentSndFileId where strEncode (AgentSndFileId connId) = strEncode connId @@ -1194,12 +1204,11 @@ instance ToJSON AgentSndFileId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentSndFileId where fromField f = AgentSndFileId <$> fromField f - -instance ToField AgentSndFileId where toField (AgentSndFileId m) = toField m - newtype AgentRcvFileId = AgentRcvFileId RcvFileId deriving (Eq, Show) + deriving newtype (FromField) + +instance ToField AgentRcvFileId where toField (AgentRcvFileId m) = toField $ Binary m instance StrEncoding AgentRcvFileId where strEncode (AgentRcvFileId connId) = strEncode connId @@ -1213,10 +1222,6 @@ instance ToJSON AgentRcvFileId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentRcvFileId where fromField f = AgentRcvFileId <$> fromField f - -instance ToField AgentRcvFileId where toField (AgentRcvFileId m) = toField m - newtype AgentInvId = AgentInvId InvitationId deriving (Eq, Show) @@ -1232,7 +1237,7 @@ instance ToJSON AgentInvId where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField AgentInvId where fromField f = AgentInvId <$> fromField f +deriving newtype instance FromField AgentInvId instance ToField AgentInvId where toField (AgentInvId m) = toField m diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 8465caeee0..bc5eadac3a 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -29,14 +30,18 @@ import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Records.Compat import Simplex.Chat.Types.Shared -import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data ChatFeature = CFTimedMessages @@ -678,7 +683,7 @@ data FeatureAllowed | FANo -- do not allow deriving (Eq, Show) -instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode +instance FromField FeatureAllowed where fromField = blobFieldDecoder strDecode instance ToField FeatureAllowed where toField = toField . strEncode @@ -704,7 +709,7 @@ instance ToJSON FeatureAllowed where data GroupFeatureEnabled = FEOn | FEOff deriving (Eq, Show) -instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode +instance FromField GroupFeatureEnabled where fromField = blobFieldDecoder strDecode instance ToField GroupFeatureEnabled where toField = toField . strEncode diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index 4601fe4e4a..b70ae81974 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} @@ -6,11 +7,16 @@ module Simplex.Chat.Types.Shared where import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (blobFieldDecoder) +import Simplex.Messaging.Util ((<$?>)) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) -import Simplex.Chat.Types.Util -import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Util ((<$?>)) +#endif data GroupMemberRole = GRObserver -- connects to all group members and receives all messages, can't send messages @@ -21,7 +27,7 @@ data GroupMemberRole | GROwner -- + delete and change group information, add/remove/change roles for Owners deriving (Eq, Show, Ord) -instance FromField GroupMemberRole where fromField = fromBlobField_ strDecode +instance FromField GroupMemberRole where fromField = blobFieldDecoder strDecode instance ToField GroupMemberRole where toField = toField . strEncode diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index cc5290aa69..460076649e 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} @@ -13,12 +14,17 @@ import qualified Data.Aeson.TH as JQ import Data.Char (toLower) import Data.Maybe (fromMaybe) import Data.Text (Text) -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_) import Simplex.Messaging.Util (decodeJSON, encodeJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif data UITheme = UITheme { themeId :: Text, diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs index 47edf8eaf8..afea178e41 100644 --- a/src/Simplex/Chat/Types/Util.hs +++ b/src/Simplex/Chat/Types/Util.hs @@ -1,24 +1,8 @@ -{-# LANGUAGE LambdaCase #-} - module Simplex.Chat.Types.Util where import qualified Data.Aeson as J import qualified Data.Aeson.Types as JT -import Data.ByteString (ByteString) -import Data.Typeable -import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (FieldParser, returnError) -import Database.SQLite.Simple.Internal (Field (..)) -import Database.SQLite.Simple.Ok (Ok (Ok)) import Simplex.Messaging.Encoding.String textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode - -fromBlobField_ :: Typeable k => (ByteString -> Either String k) -> FieldParser k -fromBlobField_ p = \case - f@(Field (SQLBlob b) _) -> - case p b of - Right k -> Ok k - Left e -> returnError ConversionFailed f ("could not parse field: " ++ e) - f -> returnError ConversionFailed f "expecting SQLBlob column type" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d363c95461..2c736d9269 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} @@ -58,7 +59,6 @@ import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) @@ -73,6 +73,9 @@ import Simplex.Messaging.Util (safeDecodeUtf8, tshow) import Simplex.Messaging.Version hiding (version) import Simplex.RemoteControl.Types (RCCtrlAddress (..), RCErrorType (..)) import System.Console.ANSI.Types +#if !defined(dbPostgres) +import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +#endif type CurrentTime = UTCTime @@ -390,6 +393,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRRemoteCtrlStopped {rcStopReason} -> viewRemoteCtrlStopped rcStopReason CRContactPQEnabled u c (CR.PQEncryption pqOn) -> ttyUser u [ttyContact' c <> ": " <> (if pqOn then "quantum resistant" else "standard") <> " end-to-end encryption enabled"] CRSQLResult rows -> map plain rows +#if !defined(dbPostgres) + CRArchiveExported archiveErrs -> if null archiveErrs then ["ok"] else ["archive export errors: " <> plain (show archiveErrs)] + CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRSlowSQLQueries {chatQueries, agentQueries} -> let viewQuery SlowSQLQuery {query, queryStats = SlowQueryStats {count, timeMax, timeAvg}} = ("count: " <> sShow count) @@ -397,6 +403,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe <> (" :: avg: " <> sShow timeAvg <> " ms") <> (" :: " <> plain (T.unwords $ T.lines query)) in ("Chat queries" : map viewQuery chatQueries) <> [""] <> ("Agent queries" : map viewQuery agentQueries) +#endif CRDebugLocks {chatLockName, chatEntityLocks, agentLocks} -> [ maybe "no chat lock" (("chat lock: " <>) . plain) chatLockName, "chat entity locks: " <> viewJSON chatEntityLocks, @@ -441,8 +448,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError True logLevel testView e CRChatError u e -> ttyUser' u $ viewChatError False logLevel testView e CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError False logLevel testView) errs - CRArchiveExported archiveErrs -> if null archiveErrs then ["ok"] else ["archive export errors: " <> plain (show archiveErrs)] - CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] CRAppSettings as -> ["app settings: " <> viewJSON as] CRTimedAction _ _ -> [] CRCustomChatResponse u r -> ttyUser' u $ map plain $ T.lines r @@ -2216,8 +2221,7 @@ viewChatError isCmd logLevel testView = \case CMD PROHIBITED cxt -> [withConnEntity <> plain ("error: command is prohibited, " <> cxt)] SMP _ SMP.AUTH -> [ withConnEntity - <> "error: connection authorization failed - this could happen if connection was deleted,\ - \ secured with different credentials, or due to a bug - please re-create the connection" + <> "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" ] BROKER _ NETWORK | not isCmd -> [] BROKER _ TIMEOUT | not isCmd -> [] diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index 95c80d6345..7d10f6a34a 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -13,9 +14,12 @@ import Control.Exception (bracket) import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Core import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB import Simplex.Chat.Types (Profile (..)) -import System.FilePath (()) import Test.Hspec hiding (it) +#if !defined(dbPostgres) +import System.FilePath (()) +#endif broadcastBotTests :: SpecWith FilePath broadcastBotTests = do @@ -33,7 +37,17 @@ broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadc mkBotOpts :: FilePath -> [KnownContact] -> BroadcastBotOpts mkBotOpts tmp publishers = BroadcastBotOpts - { coreOptions = testCoreOpts {dbFilePrefix = tmp botDbPrefix}, + { coreOptions = + testCoreOpts + { dbOptions = + (dbOptions testCoreOpts) +#if defined(dbPostgres) + {dbSchemaPrefix = "client_" <> botDbPrefix} +#else + {dbFilePrefix = tmp botDbPrefix} +#endif + + }, publishers, welcomeMessage = defaultWelcomeMessage publishers, prohibitedMessage = defaultWelcomeMessage publishers diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 9775dddd5f..0aa508994a 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -20,6 +21,7 @@ import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Core import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB import Simplex.Chat.Types (Profile (..)) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import System.FilePath (()) @@ -67,7 +69,17 @@ directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", im mkDirectoryOpts :: FilePath -> [KnownContact] -> DirectoryOpts mkDirectoryOpts tmp superUsers = DirectoryOpts - { coreOptions = testCoreOpts {dbFilePrefix = tmp serviceDbPrefix}, + { coreOptions = + testCoreOpts + { dbOptions = + (dbOptions testCoreOpts) +#if defined(dbPostgres) + {dbSchemaPrefix = "client_" <> serviceDbPrefix} +#else + {dbFilePrefix = tmp serviceDbPrefix} +#endif + + }, adminUsers = [], superUsers, directoryLog = Just $ tmp "directory_service.log", diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 42f139f05d..1c107d5d3f 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -18,7 +19,6 @@ import Control.Exception (bracket, bracket_) import Control.Monad import Control.Monad.Except import Control.Monad.Reader -import Data.ByteArray (ScrubbedBytes) import Data.Functor (($>)) import Data.List (dropWhileEnd, find) import Data.Maybe (isNothing) @@ -29,6 +29,7 @@ import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatControlle import Simplex.Chat.Core import Simplex.Chat.Library.Commands import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion) import Simplex.Chat.Store import Simplex.Chat.Store.Profiles @@ -43,9 +44,9 @@ import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval -import Simplex.Messaging.Agent.Store.SQLite (closeDBStore) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import Simplex.Messaging.Agent.Store (closeStore) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) @@ -59,14 +60,31 @@ import Simplex.Messaging.Transport.Server (ServerCredentials (..), defaultTransp import Simplex.Messaging.Version import Simplex.Messaging.Version.Internal import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive) -import System.FilePath (()) import qualified System.Terminal as C import System.Terminal.Internal (VirtualTerminal (..), VirtualTerminalSettings (..), withVirtualTerminal) import System.Timeout (timeout) import Test.Hspec (Expectation, HasCallStack, shouldReturn) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) +#else +import Data.ByteArray (ScrubbedBytes) +import System.FilePath (()) +#endif -testDBPrefix :: FilePath -testDBPrefix = "tests/tmp/test" +#if defined(dbPostgres) +testDBName :: String +testDBName = "test_chat_db" + +testDBUser :: String +testDBUser = "test_chat_user" + +testDBConnectInfo :: ConnectInfo +testDBConnectInfo = + defaultConnectInfo { + connectUser = testDBUser, + connectDatabase = testDBName + } +#endif serverPort :: ServiceName serverPort = "7001" @@ -93,9 +111,20 @@ testOpts = testCoreOpts :: CoreChatOpts testCoreOpts = CoreChatOpts - { dbFilePrefix = "./simplex_v1", - dbKey = "", - -- dbKey = "this is a pass-phrase to encrypt the database", + { + dbOptions = ChatDbOpts +#if defined(dbPostgres) + { dbName = testDBName, + dbUser = testDBUser, + -- dbSchemaPrefix is not used in tests (except bot tests where it's redefined), + -- instead different schema prefix is passed per client so that single test database is used + dbSchemaPrefix = "" +#else + { dbFilePrefix = "./simplex_v1", -- dbFilePrefix is not used in tests (except bot tests where it's redefined) + dbKey = "", -- dbKey = "this is a pass-phrase to encrypt the database", + vacuumOnMigration = True +#endif + }, smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001"], xftpServers = ["xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002"], simpleNetCfg = defaultSimpleNetCfg, @@ -106,12 +135,13 @@ testCoreOpts = logFile = Nothing, tbqSize = 16, highlyAvailable = False, - yesToUpMigrations = False, - vacuumOnMigration = True + yesToUpMigrations = False } +#if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts -getTestOpts maintenance dbKey = testOpts {maintenance, coreOptions = testCoreOpts {dbKey}} +getTestOpts maintenance dbKey = testOpts {maintenance, coreOptions = testCoreOpts {dbOptions = (dbOptions testCoreOpts) {dbKey}}} +#endif termSettings :: VirtualTerminalSettings termSettings = @@ -248,18 +278,33 @@ groupLinkViaContactVRange :: VersionRangeChat groupLinkViaContactVRange = mkVersionRange (VersionChat 1) (VersionChat 2) createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey, vacuumOnMigration}} dbPrefix profile = do - Right db@ChatDatabase {chatStore, agentStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError vacuumOnMigration - withTransaction agentStore (`DB.execute_` "INSERT INTO users (user_id) VALUES (1);") +createTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix profile = do + Right db@ChatDatabase {chatStore, agentStore} <- createDatabase tmp coreOptions dbPrefix + insertUser agentStore Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True startTestChat_ db cfg opts user startTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> IO TestCC -startTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey, vacuumOnMigration}} dbPrefix = do - Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey False MCError vacuumOnMigration +startTestChat tmp cfg opts@ChatOpts {coreOptions} dbPrefix = do + Right db@ChatDatabase {chatStore} <- createDatabase tmp coreOptions dbPrefix Just user <- find activeUser <$> withTransaction chatStore getUsers startTestChat_ db cfg opts user +createDatabase :: FilePath -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) +#if defined(dbPostgres) +createDatabase _tmp CoreChatOpts {dbOptions} dbPrefix = do + createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} MCError + +insertUser :: DBStore -> IO () +insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users DEFAULT VALUES") +#else +createDatabase tmp CoreChatOpts {dbOptions} dbPrefix = do + createChatDatabase dbOptions {dbFilePrefix = tmp dbPrefix} MCError + +insertUser :: DBStore -> IO () +insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)") +#endif + startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure @@ -278,7 +323,7 @@ stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, c uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent - closeDBStore chatStore + closeStore chatStore threadDelay 200000 withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a diff --git a/tests/ChatTests/ChatList.hs b/tests/ChatTests/ChatList.hs index de12caf648..dd88aac54e 100644 --- a/tests/ChatTests/ChatList.hs +++ b/tests/ChatTests/ChatList.hs @@ -193,9 +193,9 @@ testPaginationAllChatTypes = _ts6 <- iso8601Show <$> getCurrentTime - -- * (notes) + -- \* (notes) createCCNoteFolder alice - alice /* "psst" + alice >* "psst" ts7 <- iso8601Show <$> getCurrentTime diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 960b4bd96e..917fc36395 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -21,7 +22,6 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (intercalate) import qualified Data.Text as T -import Database.SQLite.Simple (Only (..)) import Simplex.Chat.AppSettings (defaultAppSettings) import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call @@ -29,19 +29,24 @@ import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) -import System.FilePath (()) import Test.Hspec hiding (it) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.Store (agentStoreFile, chatStoreFile) +import System.FilePath (()) +#endif chatDirectTests :: SpecWith FilePath chatDirectTests = do @@ -106,10 +111,12 @@ chatDirectTests = do xit'' "curr/v5" $ testFullAsyncSlow testCfg testCfgSlow describe "webrtc calls api" $ do it "negotiate call" testNegotiateCall +#if !defined(dbPostgres) describe "maintenance mode" $ do it "start/stop/export/import chat" testMaintenanceMode it "export/import chat with files" testMaintenanceModeWithFiles it "encrypt/decrypt database" testDatabaseEncryption +#endif describe "coordination between app and NSE" $ do it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do @@ -142,10 +149,13 @@ chatDirectTests = do sameVerificationCode "123 456 789" "12345 6789" `shouldBe` True it "mark contact verified" testMarkContactVerified it "mark group member verified" testMarkGroupMemberVerified +#if !defined(dbPostgres) + -- TODO [postgres] restore from outdated db backup (same as in agent) describe "message errors" $ do it "show message decryption error" testMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testSyncRatchet it "synchronize ratchets, reset connection code" testSyncRatchetCodeReset +#endif describe "message reactions" $ do it "set message reactions" testSetMessageReactions describe "delivery receipts" $ do @@ -1363,6 +1373,7 @@ testNegotiateCall = bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "incoming call: accepted")]) alice <## "bob accepted your WebRTC video call (e2e encrypted)" repeatM_ 3 $ getTermLine alice + threadDelay 100000 alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: accepted")]) -- alice confirms call by sending WebRTC answer alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) @@ -1480,6 +1491,7 @@ testMaintenanceModeWithFiles tmp = withXFTPServer $ do -- works after full restart withTestChat tmp "alice" $ \alice -> testChatWorking alice bob +#if !defined(dbPostgres) testDatabaseEncryption :: HasCallStack => FilePath -> IO () testDatabaseEncryption tmp = do withNewTestChat tmp "bob" bobProfile $ \bob -> do @@ -1527,6 +1539,7 @@ testDatabaseEncryption tmp = do alice <## "ok" withTestChat tmp "alice" $ \alice -> do testChatWorking alice bob +#endif testSubscribeAppNSE :: HasCallStack => FilePath -> IO () testSubscribeAppNSE tmp = @@ -2730,6 +2743,7 @@ testMarkGroupMemberVerified = | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" +#if !defined(dbPostgres) testMsgDecryptError :: HasCallStack => FilePath -> IO () testMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do @@ -2867,6 +2881,7 @@ testSyncRatchetCodeReset tmp = connVerified | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" +#endif testSetMessageReactions :: HasCallStack => FilePath -> IO () testSetMessageReactions = diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index f347e1a396..44c211d2e0 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -224,7 +224,7 @@ testForwardNotesToContact = createCCNoteFolder alice connectUsers alice cath - alice /* "hi" + alice >* "hi" alice `send` "@cath <- * hi" alice <# "@cath hi" @@ -237,7 +237,7 @@ testForwardNotesToGroup = createCCNoteFolder alice createGroup2 "team" alice cath - alice /* "hi" + alice >* "hi" alice `send` "#team <- * hi" alice <# "#team hi" @@ -248,7 +248,7 @@ testForwardNotesToNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do createCCNoteFolder alice - alice /* "hi" + alice >* "hi" alice `send` "* <- * hi" alice <# "* hi" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index f14a041f67..5cb89d9417 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} @@ -16,22 +17,26 @@ import Control.Monad (forM_, void, when) import qualified Data.ByteString.Char8 as B import Data.List (intercalate, isInfixOf) import qualified Data.Text as T -import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport +import Test.Hspec hiding (it) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import System.Directory (copyFile) import System.FilePath (()) -import Test.Hspec hiding (it) +#endif chatGroupTests :: SpecWith FilePath chatGroupTests = do @@ -104,10 +109,13 @@ chatGroupTests = do it "group link without contact - known group" testPlanGroupLinkNoContactKnown it "group link without contact - connecting" testPlanGroupLinkNoContactConnecting it "group link without contact - connecting (slow handshake)" testPlanGroupLinkNoContactConnectingSlow +#if !defined(dbPostgres) + -- TODO [postgres] restore from outdated db backup (same as in agent) describe "group message errors" $ do it "show message decryption error" testGroupMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testGroupSyncRatchet it "synchronize ratchets, reset connection code" testGroupSyncRatchetCodeReset +#endif describe "group message reactions" $ do it "set group message reactions" testSetGroupMessageReactions describe "group delivery receipts" $ do @@ -3549,6 +3557,7 @@ testPlanGroupLinkNoContactConnectingSlow tmp = do bob ##> ("/c " <> gLink) bob <## "group link: connecting to group #team" +#if !defined(dbPostgres) testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () testGroupMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do @@ -3692,6 +3701,7 @@ testGroupSyncRatchetCodeReset tmp = connVerified | verified = "connection verified" | otherwise = "connection not verified, use /code command to see security code" +#endif testSetGroupMessageReactions :: HasCallStack => FilePath -> IO () testSetGroupMessageReactions = diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index c17b893be1..594e6e47b0 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -33,7 +33,7 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/contacts" -- not a contact - alice /* "keep in mind" + alice >* "keep in mind" alice ##> "/tail" alice <# "* keep in mind" alice ##> "/chats" @@ -50,7 +50,7 @@ testNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do alice ##> "/tail" alice ##> "/chats" - alice /* "ahoy!" + alice >* "ahoy!" alice ##> "/_update item *1 2 text Greetings." alice ##> "/tail *" alice <# "* Greetings." @@ -59,7 +59,7 @@ testUserNotes :: FilePath -> IO () testUserNotes tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do createCCNoteFolder alice - alice /* "keep in mind" + alice >* "keep in mind" alice ##> "/tail" alice <# "* keep in mind" @@ -78,9 +78,9 @@ testPreviewsPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice - createCCNoteFolder alice tsS <- iso8601Show <$> getCurrentTime - alice /* "first" + alice >* "first" tsM <- iso8601Show <$> getCurrentTime - alice /* "last" + alice >* "last" tsE <- iso8601Show <$> getCurrentTime -- there's only one folder that got updated after tsM and before tsE @@ -95,10 +95,10 @@ testChatPagination :: FilePath -> IO () testChatPagination tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do createCCNoteFolder alice - alice /* "hello world" - alice /* "memento mori" - alice /* "knock-knock" - alice /* "who's there?" + alice >* "hello world" + alice >* "memento mori" + alice >* "knock-knock" + alice >* "who's there?" alice #$> ("/_get chat *1 count=100", chat, [(1, "hello world"), (1, "memento mori"), (1, "knock-knock"), (1, "who's there?")]) alice #$> ("/_get chat *1 count=1", chat, [(1, "who's there?")]) @@ -184,7 +184,7 @@ testOtherFiles = ] bob <## "completed receiving file 1 (test.jpg) from alice" - bob /* "test" + bob >* "test" bob ##> "/tail *" bob <# "* test" bob ##> "/clear *" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 0730d32866..699565af23 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -1121,16 +1122,20 @@ testPlanAddressContactViaAddress = bob ##> ("/c " <> cLink) connecting alice bob - bob ##> "/_delete @2 notify=off" + bob ##> "/delete @alice" bob <## "alice: contact is deleted" - alice ##> "/_delete @2 notify=off" + alice ##> "/delete @bob" alice <## "bob: contact is deleted" void $ withCCUser bob $ \user -> withCCTransaction bob $ \db -> runExceptT $ createContact db user profile bob @@@ [("@alice", "")] -- GUI api +#if defined(dbPostgres) + bob ##> "/_connect contact 1 4" +#else bob ##> "/_connect contact 1 2" +#endif connecting alice bob where connecting alice bob = do @@ -1264,9 +1269,10 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil \alice bob cath -> do alice ##> "/ad" cLink <- getContactLink alice True + -- GUI /_accept api bob ##> ("/c " <> cLink) alice <#? bob - alice ##> "/accept incognito bob" + alice ##> "/_accept incognito=on 1" alice <## "bob (Bob): accepting contact request, you can send messages to contact" aliceIncognitoBob <- getTermLine alice concurrentlyN_ @@ -1291,10 +1297,10 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil alice ##> "/contacts" (alice ("/c " <> cLink) alice <#? cath - alice ##> "/_accept incognito=on 1" + alice ##> "/accept incognito cath" alice <## "cath (Catherine): accepting contact request, you can send messages to contact" aliceIncognitoCath <- getTermLine alice concurrentlyN_ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 90d744766f..24178125ee 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -21,7 +22,6 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T -import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) import Simplex.Chat.Protocol @@ -32,9 +32,8 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Client.Main (xftpClientCLI) -import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) -import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB -import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction) +import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff) import Simplex.Messaging.Encoding.String @@ -46,6 +45,11 @@ import System.Info (os) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec import UnliftIO (timeout) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +#endif defaultPrefs :: Maybe Preferences defaultPrefs = Just $ toChatPrefs defaultChatPrefs @@ -363,8 +367,8 @@ cc <##.. ls = do unless prefix $ print ("expected to start from one of: " <> show ls, ", got: " <> l) prefix `shouldBe` True -(/*) :: HasCallStack => TestCC -> String -> IO () -cc /* note = do +(>*) :: HasCallStack => TestCC -> String -> IO () +cc >* note = do cc `send` ("/* " <> note) (dropTime <$> getTermLine cc) `shouldReturn` ("* " <> note) diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs new file mode 100644 index 0000000000..32e90cf754 --- /dev/null +++ b/tests/JSONFixtures.hs @@ -0,0 +1,62 @@ +{-# LANGUAGE OverloadedStrings #-} + +module JSONFixtures where + +import qualified Data.ByteString.Lazy.Char8 as LB + +noActiveUserSwift :: LB.ByteString +noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" + +noActiveUserTagged :: LB.ByteString +noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" + +activeUserExistsSwift :: LB.ByteString +activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" + +activeUserExistsTagged :: LB.ByteString +activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" + +activeUserSwift :: LB.ByteString +activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" + +activeUserTagged :: LB.ByteString +activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" + +chatStartedSwift :: LB.ByteString +chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" + +chatStartedTagged :: LB.ByteString +chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" + +networkStatusesSwift :: LB.ByteString +networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" + +networkStatusesTagged :: LB.ByteString +networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" + +userJSON :: LB.ByteString +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" + +memberSubSummarySwift :: LB.ByteString +memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" + +memberSubSummaryTagged :: LB.ByteString +memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" + +userContactSubSummarySwift :: LB.ByteString +userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" + +userContactSubSummaryTagged :: LB.ByteString +userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" + +pendingSubSummarySwift :: LB.ByteString +pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" + +pendingSubSummaryTagged :: LB.ByteString +pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" + +parsedMarkdownSwift :: LB.ByteString +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" + +parsedMarkdownTagged :: LB.ByteString +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index a17a69fae8..400db87b36 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -12,7 +12,7 @@ import Data.ByteString.Builder (toLazyByteString) import qualified Data.ByteString.Lazy.Char8 as LB import GHC.Generics (Generic) import Generic.Random (genericArbitraryU) -import MobileTests +import JSONFixtures import Simplex.Chat.Remote.Protocol (owsf2tagged) import Simplex.Messaging.Parsers import Test.Hspec diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 905f927f37..730b0d8649 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -26,6 +26,7 @@ import Foreign.Ptr import Foreign.StablePtr import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) +import JSONFixtures import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Mobile import Simplex.Chat.Mobile.File @@ -79,12 +80,6 @@ noActiveUser = noActiveUserTagged #endif -noActiveUserSwift :: LB.ByteString -noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" - -noActiveUserTagged :: LB.ByteString -noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" - activeUserExists :: LB.ByteString activeUserExists = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -93,12 +88,6 @@ activeUserExists = activeUserExistsTagged #endif -activeUserExistsSwift :: LB.ByteString -activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" - -activeUserExistsTagged :: LB.ByteString -activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" - activeUser :: LB.ByteString activeUser = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -107,12 +96,6 @@ activeUser = activeUserTagged #endif -activeUserSwift :: LB.ByteString -activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" - -activeUserTagged :: LB.ByteString -activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" - chatStarted :: LB.ByteString chatStarted = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -121,12 +104,6 @@ chatStarted = chatStartedTagged #endif -chatStartedSwift :: LB.ByteString -chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" - -chatStartedTagged :: LB.ByteString -chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" - networkStatuses :: LB.ByteString networkStatuses = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -135,12 +112,6 @@ networkStatuses = networkStatusesTagged #endif -networkStatusesSwift :: LB.ByteString -networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" - -networkStatusesTagged :: LB.ByteString -networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" - memberSubSummary :: LB.ByteString memberSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -149,12 +120,6 @@ memberSubSummary = memberSubSummaryTagged #endif -memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" - -memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" - userContactSubSummary :: LB.ByteString userContactSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -163,12 +128,6 @@ userContactSubSummary = userContactSubSummaryTagged #endif -userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" - -userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" - pendingSubSummary :: LB.ByteString pendingSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -177,15 +136,6 @@ pendingSubSummary = pendingSubSummaryTagged #endif -pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" - -pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" - -userJSON :: LB.ByteString -userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" - parsedMarkdown :: LB.ByteString parsedMarkdown = #if defined(darwin_HOST_OS) && defined(swiftJSON) @@ -194,12 +144,6 @@ parsedMarkdown = parsedMarkdownTagged #endif -parsedMarkdownSwift :: LB.ByteString -parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" - -parsedMarkdownTagged :: LB.ByteString -parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" - testChatApiNoUser :: FilePath -> IO () testChatApiNoUser tmp = do let dbPrefix = tmp "1" diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index e51a938252..dd4032e274 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -12,10 +12,10 @@ import qualified Data.Aeson as J import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.Map.Strict as M -import Simplex.Chat.Archive (archiveFilesFolder) import Simplex.Chat.Controller (versionNumber) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File +import Simplex.Chat.Remote (remoteFilesFolder) import Simplex.Chat.Remote.Types import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String (strEncode) @@ -214,7 +214,7 @@ remoteStoreFileTest = rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) desktopHostStore <- case M.lookup (RHId 1) rhs of - Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath remoteFilesFolder _ -> fail "Host session 1 should be started" desktop ##> "/store remote file 1 tests/fixtures/test.pdf" desktop <## "file test.pdf stored on remote host 1" @@ -338,7 +338,7 @@ remoteCLIFileTest = testChat3 aliceProfile aliceDesktopProfile bobProfile $ \mob rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) desktopHostStore <- case M.lookup (RHId 1) rhs of - Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath archiveFilesFolder + Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath remoteFilesFolder _ -> fail "Host session 1 should be started" mobileName <- userName mobile diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index a2794ca0f7..1f7f6af8a3 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,8 +10,8 @@ import Data.List (dropWhileEnd) import Data.Maybe (fromJust, isJust) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store -import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, createDBStore) import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, createDBStore) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Util (ifM, whenM) import System.Directory (doesFileExist, removeFile) @@ -22,7 +22,7 @@ testDB :: FilePath testDB = "tests/tmp/test_chat.db" appSchema :: FilePath -appSchema = "src/Simplex/Chat/Migrations/chat_schema.sql" +appSchema = "src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql" -- Some indexes found by `.lint fkey-indexes` are not added to schema, explanation: -- @@ -38,7 +38,7 @@ appSchema = "src/Simplex/Chat/Migrations/chat_schema.sql" -- EXPLAIN QUERY PLAN DELETE FROM group_members; -- (uses idx_connections_group_member) appLint :: FilePath -appLint = "src/Simplex/Chat/Migrations/chat_lint.sql" +appLint = "src/Simplex/Chat/Store/SQLite/Migrations/chat_lint.sql" testSchema :: FilePath testSchema = "tests/tmp/test_agent_schema.sql" diff --git a/tests/Test.hs b/tests/Test.hs index 079c583a6e..042f699d3f 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE CPP #-} + import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient @@ -8,38 +10,56 @@ import Data.Time.Clock.System import JSONTests import MarkdownTests import MessageBatching -import MobileTests import ProtocolTests import OperatorTests import RandomServers import RemoteTests -import SchemaDump import Test.Hspec hiding (it) import UnliftIO.Temporary (withTempDirectory) import ValidNames import ViewTests +#if defined(dbPostgres) +import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropAllSchemasExceptSystem, dropDatabaseAndUser) +#else +import MobileTests +import SchemaDump import WebRTCTests +#endif main :: IO () main = do setLogLevel LogError - withGlobalLogging logCfg . hspec $ do - describe "Schema dump" schemaDumpTest - describe "SimpleX chat markdown" markdownTests - describe "JSON Tests" jsonTests - describe "SimpleX chat view" viewTests - describe "SimpleX chat protocol" protocolTests - around tmpBracket $ describe "WebRTC encryption" webRTCTests - describe "Valid names" validNameTests - describe "Message batching" batchingTests - describe "Operators" operatorTests - describe "Random servers" randomServersTests - around testBracket $ do - describe "Mobile API Tests" mobileTests - describe "SimpleX chat client" chatTests - xdescribe'' "SimpleX Broadcast bot" broadcastBotTests - xdescribe'' "SimpleX Directory service bot" directoryServiceTests - describe "Remote session" remoteTests + withGlobalLogging logCfg . hspec +#if defined(dbPostgres) + . beforeAll_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) + . afterAll_ (dropDatabaseAndUser testDBConnectInfo) +#endif + $ do +-- TODO [postgres] schema dump for postgres +#if !defined(dbPostgres) + describe "Schema dump" schemaDumpTest + around tmpBracket $ describe "WebRTC encryption" webRTCTests +#endif + describe "SimpleX chat markdown" markdownTests + describe "JSON Tests" jsonTests + describe "SimpleX chat view" viewTests + describe "SimpleX chat protocol" protocolTests + describe "Valid names" validNameTests + describe "Message batching" batchingTests + describe "Operators" operatorTests + describe "Random servers" randomServersTests + around testBracket +#if defined(dbPostgres) + . after_ (dropAllSchemasExceptSystem testDBConnectInfo) +#endif + $ do +#if !defined(dbPostgres) + describe "Mobile API Tests" mobileTests +#endif + describe "SimpleX chat client" chatTests + xdescribe'' "SimpleX Broadcast bot" broadcastBotTests + xdescribe'' "SimpleX Directory service bot" directoryServiceTests + describe "Remote session" remoteTests where testBracket test = withSmpServer $ tmpBracket test tmpBracket test = do From 5289d86254f69585beb3acdc51983038f06fa415 Mon Sep 17 00:00:00 2001 From: Diogo Date: Fri, 10 Jan 2025 12:03:38 +0000 Subject: [PATCH 54/95] android, desktop: prevent swipe reply to reports (#5499) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 64b7cfe9a1..745e77268b 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 @@ -1085,7 +1085,7 @@ fun BoxScope.ChatItemsList( val dismissState = rememberDismissState(initialValue = DismissValue.Default) { if (it == DismissValue.DismissedToStart) { itemScope.launch { - if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) { + if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { From d5ce770f41340807db3833d50f66078e8c9a6c28 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 10 Jan 2025 20:38:05 +0700 Subject: [PATCH 55/95] android, desktop: non-transparent background in some cases (#5502) --- .../kotlin/chat/simplex/common/App.kt | 4 ++-- .../kotlin/chat/simplex/common/AppLock.kt | 2 +- .../chat/simplex/common/views/SplashView.kt | 4 ++-- .../views/helpers/LocalAuthentication.kt | 2 +- .../views/usersettings/PrivacySettings.kt | 10 ++++---- .../common/views/usersettings/SettingsView.kt | 24 ++++++++++--------- 6 files changed, 24 insertions(+), 22 deletions(-) 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 fc17c49c7e..1542c35e58 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 @@ -114,7 +114,7 @@ fun MainScreen() { @Composable fun AuthView() { - Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -223,7 +223,7 @@ fun MainScreen() { if (chatModel.controller.appPrefs.performLA.get() && AppLock.laFailed.value) { AuthView() } else { - SplashView() + SplashView(true) ModalManager.fullscreen.showPasscodeInView() } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index c93fabec8b..d6f9640cb9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -113,7 +113,7 @@ object AppLock { val appPrefs = ChatController.appPrefs ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { ChatModel.showAuthScreen.value = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt index 5265f3187b..a049230f27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt @@ -6,11 +6,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable -fun SplashView() { +fun SplashView(nonTransparent: Boolean = false) { Surface( Modifier .fillMaxSize(), - color = MaterialTheme.colors.background, + color = if (nonTransparent) MaterialTheme.colors.background.copy(1f) else MaterialTheme.colors.background, contentColor = LocalContentColor.current ) { // Image( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt index 28f6320ee7..1f2b5485f2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt @@ -51,7 +51,7 @@ fun authenticateWithPasscode( close() completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled))) } - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { LocalAuthView(ChatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && ChatController.appPrefs.selfDestruct.get()) { close() completed(it) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 9ec2d29843..5978f812a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -422,7 +422,7 @@ fun SimplexLockView( } LAMode.PASSCODE -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { laLockDelay.set(30) @@ -466,7 +466,7 @@ fun SimplexLockView( when (laResult) { LAResult.Success -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( reason = generalGetString(MR.strings.la_app_passcode), submit = { @@ -490,7 +490,7 @@ fun SimplexLockView( when (laResult) { LAResult.Success -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, @@ -525,7 +525,7 @@ fun SimplexLockView( } LAMode.PASSCODE -> { ModalManager.fullscreen.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( submit = { laLockDelay.set(30) @@ -638,7 +638,7 @@ private fun EnableSelfDestruct( selfDestruct: SharedPreference, close: () -> Unit ) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, title = generalGetString(MR.strings.set_passcode), reason = generalGetString(MR.strings.enabled_self_destruct_passcode), submit = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 51a0ffad8d..5bd45ccaab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -444,17 +444,19 @@ fun doWithAuth(title: String, desc: String, block: () -> Unit) { runAuth(title, desc, onFinishAuth) } } - Box( - Modifier.fillMaxSize().background(MaterialTheme.colors.background), - contentAlignment = Alignment.Center - ) { - SimpleButton( - stringResource(MR.strings.auth_unlock), - icon = painterResource(MR.images.ic_lock), - click = { - runAuth(title, desc, onFinishAuth) - } - ) + Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(MR.strings.auth_unlock), + icon = painterResource(MR.images.ic_lock), + click = { + runAuth(title, desc, onFinishAuth) + } + ) + } } } } From 3f116c01d36d1b4dccf143582aae84975f651d7d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 10 Jan 2025 13:58:02 +0000 Subject: [PATCH 56/95] core: fix encoding --- src/Simplex/Chat/Protocol.hs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 89664d66f7..577cb6293d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -48,7 +48,6 @@ import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding @@ -515,9 +514,7 @@ instance ToJSON MsgContentTag where toJSON = strToJSON toEncoding = strToJEncoding -instance FromField MsgContentTag where fromField = fromBlobField_ strDecode - -instance ToField MsgContentTag where toField = toField . strEncode +instance ToField MsgContentTag where toField = toField . safeDecodeUtf8 . strEncode data MsgContainer = MCSimple ExtMsgContent From c8c6a832dd27a2047e7c99fee8939cd228fea259 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 10 Jan 2025 19:41:01 +0000 Subject: [PATCH 57/95] core: fix report count when loading chat (#5505) * core: fix report count when loading chat * remove "deleted" parameter from api --- src/Simplex/Chat/Controller.hs | 8 +-- src/Simplex/Chat/Library/Commands.hs | 3 +- src/Simplex/Chat/Messages.hs | 3 +- src/Simplex/Chat/Protocol.hs | 2 +- src/Simplex/Chat/Store/Messages.hs | 70 +++++++++---------- .../Postgres/Migrations/M20241220_initial.hs | 7 -- .../SQLite/Migrations/M20250105_indexes.hs | 2 - .../Store/SQLite/Migrations/chat_schema.sql | 7 -- tests/ChatTests/Groups.hs | 12 ---- 9 files changed, 36 insertions(+), 78 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ff534f252f..799c4ea1fc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -301,7 +301,7 @@ data ChatCommand | APIGetAppSettings (Maybe AppSettings) | APIGetChatTags UserId | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} - | APIGetChat ChatRef (Maybe ContentFilter) ChatPagination (Maybe String) + | APIGetChat ChatRef (Maybe MsgContentTag) ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatRef ChatItemId | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} @@ -878,12 +878,6 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False -data ContentFilter = ContentFilter - { mcTag :: MsgContentTag, - deleted :: Maybe Bool - } - deriving (Show) - data ChatPagination = CPLast Int | CPAfter ChatItemId Int diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index fa79db81d1..8a4b036e59 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3593,7 +3593,7 @@ chatCommandP = <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) <*> (A.space *> jsonP <|> pure clqNoFilters) ), - "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> optional (contentFilterP <* A.space) <*> chatPaginationP <*> optional (" search=" *> stringP)), + "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), @@ -3968,7 +3968,6 @@ chatCommandP = ct -> ChatName ct <$> displayName chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName chatRefP = ChatRef <$> chatTypeP <*> A.decimal - contentFilterP = ContentFilter <$> ("content=" *> strP) <*> optional (" deleted=" *> onOffP) msgCountP = A.space *> A.decimal <|> pure 10 ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal) ciTTL = diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 3fd927daff..6fc6b52884 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -318,14 +318,13 @@ deriving instance Show AChat data ChatStats = ChatStats { unreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API reportsCount :: Int, -- returned both in /_get chat initial API and in /_get chats API - archivedReportsCount :: Int, -- only returned in /_get chat initial API minUnreadItemId :: ChatItemId, unreadChat :: Bool } deriving (Show) emptyChatStats :: ChatStats -emptyChatStats = ChatStats 0 0 0 0 False +emptyChatStats = ChatStats 0 0 0 False data NavigationInfo = NavigationInfo { afterUnread :: Int, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 753c753168..4bddd5f6b4 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -54,7 +54,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b5c2acc36a..f10659bcf8 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -141,7 +141,7 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime (..), getCurrentTime) -import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), ContentFilter (..), PaginationByTime (..)) +import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..)) import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent @@ -560,7 +560,7 @@ data AChatPreviewData = forall c. ChatTypeI c => ACPD (SChatType c) (ChatPreview type ChatStatsRow = (Int, Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats -toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, reportsCount, archivedReportsCount = 0, minUnreadItemId, unreadChat} +toChatStats (unreadCount, reportsCount, minUnreadItemId, BI unreadChat) = ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = @@ -1197,12 +1197,12 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe ContentFilter -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChat db vr user groupId contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId case pagination of - CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g contentFilter count search + CPLast count -> liftIO $ (,Nothing) <$> getGroupChatLast_ db user g contentFilter count search emptyChatStats CPAfter afterId count -> (,Nothing) <$> getGroupChatAfter_ db user g contentFilter afterId count search CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g contentFilter beforeId count search CPAround aroundId count -> getGroupChatAround_ db user g contentFilter aroundId count search @@ -1210,20 +1210,18 @@ getGroupChat db vr user groupId contentFilter pagination search_ = do unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getGroupChatInitial_ db user g contentFilter count -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> Int -> String -> IO (Chat 'CTGroup) -getGroupChatLast_ db user g contentFilter count search = do +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> IO (Chat 'CTGroup) +getGroupChatLast_ db user g contentFilter count search stats = do ciIds <- getGroupChatItemIDs db user g contentFilter GRLast count search ts <- getCurrentTime cis <- mapM (safeGetGroupItem db user g ts) ciIds - pure $ Chat (GroupChat g) (reverse cis) emptyChatStats + pure $ Chat (GroupChat g) (reverse cis) stats data GroupItemIDsRange = GRLast | GRAfter UTCTime ChatItemId | GRBefore UTCTime ChatItemId -getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> GroupItemIDsRange -> Int -> String -> IO [ChatItemId] +getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> IO [ChatItemId] getGroupChatItemIDs db User {userId} GroupInfo {groupId} contentFilter range count search = case contentFilter of - Just ContentFilter {mcTag, deleted} -> case deleted of - Just deleted' -> idsQuery (baseCond <> " AND msg_content_tag = ? AND item_deleted = ? ") (userId, groupId, mcTag, BI deleted') - Nothing -> idsQuery (baseCond <> " AND msg_content_tag = ? ") (userId, groupId, mcTag) + Just mcTag -> idsQuery (baseCond <> " AND msg_content_tag = ? ") (userId, groupId, mcTag) Nothing -> idsQuery baseCond (userId, groupId) where baseQuery = " SELECT chat_item_id FROM chat_items WHERE " @@ -1295,7 +1293,7 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatAfter_ db user g@GroupInfo {groupId} contentFilter afterId count search = do afterCI <- getGroupChatItem db user groupId afterId let range = GRAfter (chatItemTs afterCI) (cChatItemId afterCI) @@ -1304,7 +1302,7 @@ getGroupChatAfter_ db user g@GroupInfo {groupId} contentFilter afterId count sea cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g) cis emptyChatStats -getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatBefore_ db user g@GroupInfo {groupId} contentFilter beforeId count search = do beforeCI <- getGroupChatItem db user groupId beforeId let range = GRBefore (chatItemTs beforeCI) (cChatItemId beforeCI) @@ -1313,12 +1311,12 @@ getGroupChatBefore_ db user g@GroupInfo {groupId} contentFilter beforeId count s cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g) (reverse cis) emptyChatStats -getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround_ db user g contentFilter aroundId count search = do stats <- liftIO $ getGroupStats_ db user g getGroupChatAround' db user g contentFilter aroundId count search stats -getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround' db user g@GroupInfo {groupId} contentFilter aroundId count search stats = do aroundCI <- getGroupChatItem db user groupId aroundId let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) @@ -1336,26 +1334,28 @@ getGroupChatAround' db user g@GroupInfo {groupId} contentFilter aroundId count s [] -> pure $ NavigationInfo 0 0 cis -> getGroupNavInfo_ db user g (last cis) -getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) -getGroupChatInitial_ db user g contentFilter count = +getGroupChatInitial_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Int -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatInitial_ db user g contentFilter count = do liftIO (getGroupMinUnreadId_ db user g contentFilter) >>= \case Just minUnreadItemId -> do - unreadCount <- liftIO $ getGroupUnreadCount_ db user g Nothing - reportsCount <- liftIO $ getGroupReportsCount_ db user g False - archivedReportsCount <- liftIO $ getGroupReportsCount_ db user g True - let stats = ChatStats {unreadCount, reportsCount, archivedReportsCount, minUnreadItemId, unreadChat = False} + stats <- liftIO $ getStats minUnreadItemId =<< getGroupUnreadCount_ db user g Nothing getGroupChatAround' db user g contentFilter minUnreadItemId count "" stats - Nothing -> liftIO $ (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g contentFilter count "" + Nothing -> liftIO $ do + stats <- getStats 0 0 + (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g contentFilter count "" stats + where + getStats minUnreadItemId unreadCount = do + reportsCount <- getGroupReportsCount_ db user g False + pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} getGroupStats_ :: DB.Connection -> User -> GroupInfo -> IO ChatStats getGroupStats_ db user g = do minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g Nothing unreadCount <- getGroupUnreadCount_ db user g Nothing reportsCount <- getGroupReportsCount_ db user g False - archivedReportsCount <- getGroupReportsCount_ db user g True - pure ChatStats {unreadCount, reportsCount, archivedReportsCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, reportsCount, minUnreadItemId, unreadChat = False} -getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> IO (Maybe ChatItemId) +getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO (Maybe ChatItemId) getGroupMinUnreadId_ db user g contentFilter = fmap join . maybeFirstRow fromOnly $ queryUnreadGroupItems db user g contentFilter baseQuery orderLimit @@ -1363,7 +1363,7 @@ getGroupMinUnreadId_ db user g contentFilter = baseQuery = "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? " orderLimit = " ORDER BY item_ts ASC, chat_item_id ASC LIMIT 1" -getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> IO Int +getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> IO Int getGroupUnreadCount_ db user g contentFilter = fromOnly . head <$> queryUnreadGroupItems db user g contentFilter baseQuery "" where @@ -1377,20 +1377,14 @@ getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" (userId, groupId, MCReport_, BI archived) -queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe ContentFilter -> Query -> Query -> IO [r] +queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe MsgContentTag -> Query -> Query -> IO [r] queryUnreadGroupItems db User {userId} GroupInfo {groupId} contentFilter baseQuery orderLimit = case contentFilter of - Just ContentFilter {mcTag, deleted} -> case deleted of - Just deleted' -> - DB.query - db - (baseQuery <> " AND msg_content_tag = ? AND item_deleted = ? AND item_status = ? " <> orderLimit) - (userId, groupId, mcTag, BI deleted', CISRcvNew) - Nothing -> - DB.query - db - (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) - (userId, groupId, mcTag, CISRcvNew) + Just mcTag -> + DB.query + db + (baseQuery <> " AND msg_content_tag = ? AND item_status = ? " <> orderLimit) + (userId, groupId, mcTag, CISRcvNew) Nothing -> DB.query db diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index 24624cdf37..c9b36b8d2a 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -1002,11 +1002,4 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items( msg_content_tag, item_ts ); -CREATE INDEX idx_chat_items_groups_msg_content_tag_item_deleted_item_ts ON chat_items( - user_id, - group_id, - msg_content_tag, - item_deleted, - item_ts -); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs index 09e3246292..2f73dd91b5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs @@ -9,12 +9,10 @@ m20250105_indexes :: Query m20250105_indexes = [sql| CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items(user_id, group_id, msg_content_tag, item_ts); -CREATE INDEX idx_chat_items_groups_msg_content_tag_item_deleted_item_ts ON chat_items(user_id, group_id, msg_content_tag, item_deleted, item_ts); |] down_m20250105_indexes :: Query down_m20250105_indexes = [sql| DROP INDEX idx_chat_items_groups_msg_content_tag_item_ts; -DROP INDEX idx_chat_items_groups_msg_content_tag_item_deleted_item_ts; |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 870feba6b5..45693f4219 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -968,10 +968,3 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items( msg_content_tag, item_ts ); -CREATE INDEX idx_chat_items_groups_msg_content_tag_item_deleted_item_ts ON chat_items( - user_id, - group_id, - msg_content_tag, - item_deleted, - item_ts -); diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 5cb89d9417..b739b40b7d 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -6611,14 +6611,8 @@ testGroupMemberReports = (cath ("/_get chat #1 content=report count=100", chat, [(0, "report content")]) - alice #$> ("/_get chat #1 content=report deleted=off count=100", chat, [(0, "report content")]) - alice #$> ("/_get chat #1 content=report deleted=on count=100", chat, []) bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content")]) - bob #$> ("/_get chat #1 content=report deleted=off count=100", chat, [(0, "report content")]) - bob #$> ("/_get chat #1 content=report deleted=on count=100", chat, []) dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content")]) - dan #$> ("/_get chat #1 content=report deleted=off count=100", chat, [(1, "report content")]) - dan #$> ("/_get chat #1 content=report deleted=on count=100", chat, []) alice ##> "\\\\ #jokes cath inappropriate joke" concurrentlyN_ [ do @@ -6633,11 +6627,5 @@ testGroupMemberReports = dan <## "#jokes: 1 messages deleted by member alice" ] alice #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by you]")]) - alice #$> ("/_get chat #1 content=report deleted=off count=100", chat, []) - alice #$> ("/_get chat #1 content=report deleted=on count=100", chat, [(0, "report content [marked deleted by you]")]) bob #$> ("/_get chat #1 content=report count=100", chat, [(0, "report content [marked deleted by alice]")]) - bob #$> ("/_get chat #1 content=report deleted=off count=100", chat, []) - bob #$> ("/_get chat #1 content=report deleted=on count=100", chat, [(0, "report content [marked deleted by alice]")]) dan #$> ("/_get chat #1 content=report count=100", chat, [(1, "report content [marked deleted by alice]")]) - dan #$> ("/_get chat #1 content=report deleted=off count=100", chat, []) - dan #$> ("/_get chat #1 content=report deleted=on count=100", chat, [(1, "report content [marked deleted by alice]")]) From 94815bf644a99836813ef28045bd98cda4c60458 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Jan 2025 02:41:33 +0700 Subject: [PATCH 58/95] android, desktop: reports dashboard (#5471) * android, desktop: reports dashboard * changes * changes * unneeded updates and fixes * changes * api change * item moderated/deleted * a lot of changes * changes * reports tag and icon in ChatList * archived by * increasing counter when new report arrives * refactor * groupInfo button and closing when needed * fix archived by * reorder * simplify * rename * filled flag * Revert "filled flag" This reverts commit 8b5da851018dcf0f6ab5d3e64ea84daaea75aebf. * removed support of archived page and counter * fix closing modal * show search button in bar without menu * removed content filter * no icon * Revert "no icon" This reverts commit 86c725b53ecb1e0373769940e08efe6602112bb8. * fix tags * unlogs * unlogs * chat item statuses * background color * refactor * refactor --------- Co-authored-by: Evgeny Poberezkin --- .../platform/ScrollableColumn.android.kt | 2 + .../simplex/common/platform/UI.android.kt | 3 + .../kotlin/chat/simplex/common/App.kt | 4 +- .../chat/simplex/common/model/ChatModel.kt | 402 +++++++++------ .../chat/simplex/common/model/SimpleXAPI.kt | 157 +++++- .../common/platform/ScrollableColumn.kt | 2 + .../chat/simplex/common/views/TerminalView.kt | 5 +- .../simplex/common/views/chat/ChatInfoView.kt | 1 + .../common/views/chat/ChatItemsLoader.kt | 36 +- .../common/views/chat/ChatItemsMerger.kt | 6 +- .../simplex/common/views/chat/ChatView.kt | 461 +++++++++++++----- .../simplex/common/views/chat/ComposeView.kt | 2 +- .../views/chat/SelectableChatItemToolbars.kt | 11 +- .../views/chat/group/AddGroupMembersView.kt | 4 + .../views/chat/group/GroupChatInfoView.kt | 29 +- .../views/chat/group/GroupMemberInfoView.kt | 45 +- .../views/chat/group/GroupPreferences.kt | 3 + .../views/chat/group/GroupProfileView.kt | 1 + .../views/chat/group/GroupReportsView.kt | 106 ++++ .../views/chat/group/WelcomeMessageView.kt | 1 + .../views/chat/item/CIChatFeatureView.kt | 5 +- .../common/views/chat/item/ChatItemView.kt | 23 +- .../common/views/chat/item/FramedItemView.kt | 98 ++-- .../views/chat/item/MarkedDeletedItemView.kt | 26 +- .../views/chatlist/ChatListNavLinkView.kt | 54 +- .../common/views/chatlist/ChatListView.kt | 28 +- .../common/views/chatlist/ChatPreviewView.kt | 16 +- .../common/views/chatlist/TagListView.kt | 3 +- .../common/views/database/DatabaseView.kt | 6 + .../common/views/helpers/DefaultTopAppBar.kt | 3 +- .../simplex/common/views/helpers/ModalView.kt | 41 +- .../common/views/newchat/AddGroupView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 6 + .../resources/MR/images/ic_flag_filled.svg | 1 + .../kotlin/chat/simplex/common/DesktopApp.kt | 4 + .../platform/ScrollableColumn.desktop.kt | 10 +- 36 files changed, 1171 insertions(+), 436 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt index 60197f3851..b3d8e9b52f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt @@ -29,6 +29,7 @@ actual fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, fillMaxSize: Boolean, content: LazyListScope.() -> Unit @@ -92,6 +93,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, content: LazyListScope.() -> Unit ) { 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 1a4d0b72e9..a1698ae28a 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 @@ -81,6 +81,9 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { chatModel.chatId.value = null chatItems.clearAndNotify() } + withChats { + chatItems.clearAndNotify() + } } } else { // ChatList, nothing to do. Maybe to show other view except ChatList 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 1542c35e58..ba1eda8a7c 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 @@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow) { .graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() } ) Box2@{ currentChatId.value?.let { - ChatView(currentChatId, onComposed) + ChatView(currentChatId, reportsView = false, onComposed = onComposed) } } } @@ -393,7 +393,7 @@ fun CenterPartOfScreen() { ModalManager.center.showInView() } } - else -> ChatView(currentChatId) {} + else -> ChatView(currentChatId, reportsView = false) {} } } 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 4c6d693f63..a9599cebc3 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 @@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration -import chat.simplex.common.model.ChatModel.chatItemsChangesListener import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -59,28 +58,18 @@ object ChatModel { val ctrlInitInProgress = mutableStateOf(false) val dbMigrationInProgress = mutableStateOf(false) val incompleteInitializedDbRemoved = mutableStateOf(false) - private val _chats = mutableStateOf(SnapshotStateList()) - val chats: State> = _chats // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) // current chat val chatId = mutableStateOf(null) - /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on. - * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages]. - * If you use api call to get the items, use just [add] instead of [addAndNotify]. - * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ - private val _chatItems = mutableStateOf(SnapshotStateList()) - val chatItems: State> = _chatItems - // declaration of chatsContext should be after any other variable that is directly attached to ChatsContext class, otherwise, strange crash with NullPointerException for "this" parameter in random functions - private val chatsContext = ChatsContext() - // set listener here that will be notified on every add/delete of a chat item - var chatItemsChangesListener: ChatItemsChangesListener? = null - val chatState = ActiveChatState() + val chatsContext = ChatsContext(null) + val reportsChatsContext = ChatsContext(MsgContentTag.Report) + // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions + val chats: State> = chatsContext.chats // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) - val chatItemStatuses = mutableMapOf() val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) @@ -178,6 +167,36 @@ object ChatModel { // return true if you handled the click var centerPanelBackgroundClickHandler: (() -> Boolean)? = null + fun chatsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { + null -> chatsContext.chats + MsgContentTag.Report -> reportsChatsContext.chats + else -> TODO() + } + + fun chatItemsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { + null -> chatsContext.chatItems + MsgContentTag.Report -> reportsChatsContext.chatItems + else -> TODO() + } + + fun chatStateForContent(contentTag: MsgContentTag?): ActiveChatState = when(contentTag) { + null -> chatsContext.chatState + MsgContentTag.Report -> reportsChatsContext.chatState + else -> TODO() + } + + fun chatItemsChangesListenerForContent(contentTag: MsgContentTag?): ChatItemsChangesListener? = when(contentTag) { + null -> chatsContext.chatItemsChangesListener + MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener + else -> TODO() + } + + fun setChatItemsChangeListenerForContent(listener: ChatItemsChangesListener?, contentTag: MsgContentTag?) = when(contentTag) { + null -> chatsContext.chatItemsChangesListener = listener + MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener = listener + else -> TODO() + } + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -210,7 +229,7 @@ object ChatModel { for (chat in chats.value.filter { it.remoteHostId == rhId }) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chat.chatInfo)) { + if (presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)) { newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1 } } @@ -250,17 +269,17 @@ object ChatModel { } } - fun addPresetChatTags(chatInfo: ChatInfo) { + private fun addPresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chatInfo)) { + if (presetTagMatchesChat(tag, chatInfo, chatStats)) { presetTags[tag] = (presetTags[tag] ?: 0) + 1 } } } - fun removePresetChatTags(chatInfo: ChatInfo) { + fun removePresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chatInfo)) { + if (presetTagMatchesChat(tag, chatInfo, chatStats)) { val count = presetTags[tag] if (count != null) { presetTags[tag] = maxOf(0, count - 1) @@ -269,27 +288,6 @@ object ChatModel { } } - fun markChatTagRead(chat: Chat) { - if (chat.unreadTag) { - chat.chatInfo.chatTags?.let { tags -> - markChatTagRead_(chat, tags) - } - } - } - - fun updateChatTagRead(chat: Chat, wasUnread: Boolean) { - val tags = chat.chatInfo.chatTags ?: return - val nowUnread = chat.unreadTag - - if (nowUnread && !wasUnread) { - tags.forEach { tag -> - unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 - } - } else if (!nowUnread && wasUnread) { - markChatTagRead_(chat, tags) - } - } - fun moveChatTagUnread(chat: Chat, oldTags: List?, newTags: List) { if (chat.unreadTag) { oldTags?.forEach { t -> @@ -304,18 +302,6 @@ object ChatModel { } } } - - private fun markChatTagRead_(chat: Chat, tags: List) { - for (tag in tags) { - val count = unreadTags[tag] - if (count != null) { - unreadTags[tag] = maxOf(0, count - 1) - } - } - } - - // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens - fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null // TODO pass rhId? fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } @@ -340,13 +326,35 @@ object ChatModel { } // running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread - suspend fun withChats(action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) { - chatsContext.action() + suspend fun withChats(contentTag: MsgContentTag? = null, action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) { + when { + contentTag == null -> chatsContext.action() + contentTag == MsgContentTag.Report -> reportsChatsContext.action() + else -> TODO() + } } - class ChatsContext { - val chats = _chats - val chatItems = _chatItems + suspend fun withReportsChatsIfOpen(action: suspend ChatsContext.() -> T) = withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { + reportsChatsContext.action() + } + } + + class ChatsContext(private val contentTag: MsgContentTag?) { + val chats = mutableStateOf(SnapshotStateList()) + /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on. + * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages]. + * If you use api call to get the items, use just [add] instead of [addAndNotify]. + * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ + val chatItems = mutableStateOf(SnapshotStateList()) + val chatItemStatuses = mutableMapOf() + // set listener here that will be notified on every add/delete of a chat item + var chatItemsChangesListener: ChatItemsChangesListener? = null + val chatState = ActiveChatState() + + fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null + fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } + private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) @@ -385,6 +393,13 @@ object ChatModel { } } + fun updateChatStats(rhId: Long?, chatId: ChatId, chatStats: Chat.ChatStats) { + val i = getChatIndex(rhId, chatId) + if (i >= 0) { + chats[i] = chats[i].copy(chatStats = chatStats) + } + } + suspend fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection)) suspend fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed) @@ -402,7 +417,7 @@ object ChatModel { updateChatInfo(rhId, cInfo) } else if (addMissing) { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) - addPresetChatTags(cInfo) + addPresetChatTags(cInfo, Chat.ChatStats()) } } @@ -463,7 +478,7 @@ object ChatModel { else chat.chatStats ) - updateChatTagRead(chats[i], wasUnread) + updateChatTagReadNoContentTag(chats[i], wasUnread) if (appPlatform.isDesktop && cItem.chatDir.sent) { reorderChat(chats[i], 0) @@ -479,9 +494,9 @@ object ChatModel { // Prevent situation when chat item already in the list received from backend if (chatItems.value.none { it.id == cItem.id }) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem) + chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem, contentTag) } else { - chatItems.addAndNotify(cItem) + chatItems.addAndNotify(cItem, contentTag) } } } @@ -500,7 +515,7 @@ object ChatModel { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) if (pItem.isRcvNew && !cItem.isRcvNew) { // status changed from New to Read, update counter - decreaseCounterInChat(rhId, cInfo.id) + decreaseCounterInChatNoContentTag(rhId, cInfo.id) } } res = false @@ -526,7 +541,7 @@ object ChatModel { } else { cItem } - chatItems.addAndNotify(ci) + chatItems.addAndNotify(ci, contentTag) true } } else { @@ -551,7 +566,7 @@ object ChatModel { fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { if (cItem.isRcvNew) { - decreaseCounterInChat(rhId, cInfo.id) + decreaseCounterInChatNoContentTag(rhId, cInfo.id) } // update previews val i = getChatIndex(rhId, cInfo.id) @@ -590,9 +605,9 @@ object ChatModel { } } - val popChatCollector = PopChatCollector() + val popChatCollector = PopChatCollector(contentTag) - class PopChatCollector { + class PopChatCollector(contentTag: MsgContentTag?) { private val subject = MutableSharedFlow() private var remoteHostId: Long? = null private val chatsToPop = mutableMapOf() @@ -602,7 +617,7 @@ object ChatModel { subject .throttleLatest(2000) .collect { - withChats { + withChats(contentTag) { chats.replaceAll(popCollectedChats()) } } @@ -640,11 +655,10 @@ object ChatModel { } } - fun markChatItemsRead(remoteHostId: Long?, chatInfo: ChatInfo, itemIds: List? = null) { - val cInfo = chatInfo - val markedRead = markItemsReadInCurrentChat(chatInfo, itemIds) + fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List? = null) { + val markedRead = markItemsReadInCurrentChat(id, itemIds) // update preview - val chatIdx = getChatIndex(remoteHostId, cInfo.id) + val chatIdx = getChatIndex(remoteHostId, id) if (chatIdx >= 0) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id @@ -655,12 +669,47 @@ object ChatModel { chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount) ) - updateChatTagRead(chats[chatIdx], wasUnread) + updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) } } } - private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) { + private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Int { + var markedRead = 0 + if (chatId.value == id) { + val items = chatItems.value + var i = items.lastIndex + val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf() + val markedReadIds = mutableSetOf() + while (i >= 0) { + val item = items[i] + if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) { + val newItem = item.withStatus(CIStatus.RcvRead()) + items[i] = newItem + if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { + items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( + deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) + ) + } + markedReadIds.add(item.id) + markedRead++ + if (itemIds != null) { + itemIdsFromRange.remove(item.id) + // already set all needed items as read, can finish the loop + if (itemIdsFromRange.isEmpty()) break + } + } + i-- + } + chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) + } + return markedRead + } + + private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return @@ -673,21 +722,21 @@ object ChatModel { unreadCount = unreadCount, ) ) - updateChatTagRead(chats[chatIndex], wasUnread) + updateChatTagReadNoContentTag(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { - var removed: ChatInfo? = null + var removed: Chat? = null chats.removeAll { val found = it.id == id && it.remoteHostId == rhId if (found) { - removed = it.chatInfo + removed = it } found } removed?.let { - removePresetChatTags(it) + removePresetChatTags(it.chatInfo, it.chatStats) } } @@ -741,9 +790,92 @@ object ChatModel { upsertGroupMember(rhId, groupInfo, updatedMember) } } - } - private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } + fun increaseUnreadCounter(rhId: Long?, user: UserLike) { + changeUnreadCounterNoContentTag(rhId, user, 1) + } + + fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { + changeUnreadCounterNoContentTag(rhId, user, -by) + } + + private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } + if (i != -1) { + users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) + } + } + + fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + val tags = chat.chatInfo.chatTags ?: return + val nowUnread = chat.unreadTag + + if (nowUnread && !wasUnread) { + tags.forEach { tag -> + unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 + } + } else if (!nowUnread && wasUnread) { + markChatTagReadNoContentTag_(chat, tags) + } + } + + fun markChatTagRead(chat: Chat) { + if (chat.unreadTag) { + chat.chatInfo.chatTags?.let { tags -> + markChatTagReadNoContentTag_(chat, tags) + } + } + } + + private fun markChatTagReadNoContentTag_(chat: Chat, tags: List) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + for (tag in tags) { + val count = unreadTags[tag] + if (count != null) { + unreadTags[tag] = maxOf(0, count - 1) + } + } + } + + fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { + changeGroupReportsCounter(rhId, chatId, 1) + } + + fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { + changeGroupReportsCounter(rhId, chatId, -1) + } + + private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { + if (by == 0) return + + val i = getChatIndex(rhId, chatId) + if (i >= 0) { + val chat = chats.value[i] + chats[i] = chat.copy( + chatStats = chat.chatStats.copy( + reportsCount = (chat.chatStats.reportsCount + by).coerceAtLeast(0), + ) + ) + val wasReportsCount = chat.chatStats.reportsCount + val nowReportsCount = chats[i].chatStats.reportsCount + val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0 + changeGroupReportsTagNoContentTag(by) + } + } + + private fun changeGroupReportsTagNoContentTag(by: Int = 0) { + if (by == 0 || contentTag != null) return + presetTags[PresetTagKind.GROUP_REPORTS] = (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by + } + } fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return @@ -773,82 +905,32 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) withChats { - chatItems.addAndNotify(cItem) + chatItems.addAndNotify(cItem, contentTag = null) } return cItem } fun removeLiveDummy() { - if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + if (chatItemsForContent(null).value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { withApi { withChats { - chatItems.removeLastAndNotify() + chatItems.removeLastAndNotify(contentTag = null) } } } } - private fun markItemsReadInCurrentChat(chatInfo: ChatInfo, itemIds: List? = null): Int { - val cInfo = chatInfo - var markedRead = 0 - if (chatId.value == cInfo.id) { - val items = chatItems.value - var i = items.lastIndex - val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf() - val markedReadIds = mutableSetOf() - while (i >= 0) { - val item = items[i] - if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) { - val newItem = item.withStatus(CIStatus.RcvRead()) - items[i] = newItem - if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { - items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( - deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) - ) - } - markedReadIds.add(item.id) - markedRead++ - if (itemIds != null) { - itemIdsFromRange.remove(item.id) - // already set all needed items as read, can finish the loop - if (itemIdsFromRange.isEmpty()) break - } - } - i-- - } - chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) - } - return markedRead - } - - fun increaseUnreadCounter(rhId: Long?, user: UserLike) { - changeUnreadCounter(rhId, user, 1) - } - - fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { - changeUnreadCounter(rhId, user, -by) - } - - private fun changeUnreadCounter(rhId: Long?, user: UserLike, by: Int) { - val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } - if (i != -1) { - users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) - } - } - - fun getChatItemIndexOrNull(cItem: ChatItem): Int? { - val reversedChatItems = chatItems.asReversed() + fun getChatItemIndexOrNull(cItem: ChatItem, reversedChatItems: List): Int? { val index = reversedChatItems.indexOfFirst { it.id == cItem.id } return if (index != -1) index else null } // this function analyses "connected" events and assumes that each member will be there only once - fun getConnectedMemberNames(cItem: ChatItem): Pair> { + fun getConnectedMemberNames(cItem: ChatItem, reversedChatItems: List): Pair> { var count = 0 val ns = mutableListOf() - var idx = getChatItemIndexOrNull(cItem) + var idx = getChatItemIndexOrNull(cItem, reversedChatItems) if (cItem.mergeCategory != null && idx != null) { - val reversedChatItems = chatItems.asReversed() while (idx < reversedChatItems.size) { val ci = reversedChatItems[idx] if (ci.mergeCategory != cItem.mergeCategory) break @@ -865,9 +947,8 @@ object ChatModel { // returns the index of the first item in the same merged group (the first hidden item) // and the previous visible item with another merge category - fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair { + fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?, reversedChatItems: List): Pair { var i = ciIndex ?: return null to null - val reversedChatItems = chatItems.asReversed() val fst = reversedChatItems.lastIndex while (i < fst) { i++ @@ -880,8 +961,7 @@ object ChatModel { } // returns the previous member in the same merge group and the count of members in this group - fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair { - val reversedChatItems = chatItems.asReversed() + fun getPrevHiddenMember(member: GroupMember, range: IntRange, reversedChatItems: List): Pair { var prevMember: GroupMember? = null val names: MutableSet = mutableSetOf() for (i in range) { @@ -1157,7 +1237,13 @@ data class Chat( } @Serializable - data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false) + data class ChatStats( + val unreadCount: Int = 0, + // actual only via getChats() and getChat(.initial), otherwise, zero + val reportsCount: Int = 0, + val minUnreadItemId: Long = 0, + val unreadChat: Boolean = false + ) companion object { val sampleData = Chat( @@ -1677,6 +1763,9 @@ data class GroupInfo ( val canAddMembers: Boolean get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive + val canModerate: Boolean + get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive + companion object { val sampleData = GroupInfo( groupId = 1, @@ -2310,6 +2399,8 @@ data class ChatItem ( else -> false } + val isActiveReport: Boolean get() = isReport && !isDeletedContent && meta.itemDeleted == null + val canBeDeletedForSelf: Boolean get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete @@ -2526,8 +2617,8 @@ fun MutableState>.add(index: Int, elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(index, elem) } } -fun MutableState>.addAndNotify(index: Int, elem: ChatItem) { - value = SnapshotStateList().apply { addAll(value); add(index, elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) } +fun MutableState>.addAndNotify(index: Int, elem: ChatItem, contentTag: MsgContentTag?) { + value = SnapshotStateList().apply { addAll(value); add(index, elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, index) } } fun MutableState>.add(elem: Chat) { @@ -2538,8 +2629,8 @@ fun MutableState>.add(elem: Chat) { fun MutableList.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate) // Adds item to chatItems and notifies a listener about newly added item -fun MutableState>.addAndNotify(elem: ChatItem) { - value = SnapshotStateList().apply { addAll(value); add(elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) } +fun MutableState>.addAndNotify(elem: ChatItem, contentTag: MsgContentTag?) { + value = SnapshotStateList().apply { addAll(value); add(elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, lastIndex) } } fun MutableState>.addAll(index: Int, elems: List) { @@ -2568,7 +2659,8 @@ fun MutableState>.removeAllAndNotify(block: (ChatIte } } if (toRemove.isNotEmpty()) { - chatItemsChangesListener?.removed(toRemove, value) + chatModel.chatsContext.chatItemsChangesListener?.removed(toRemove, value) + chatModel.reportsChatsContext.chatItemsChangesListener?.removed(toRemove, value) } } @@ -2580,7 +2672,7 @@ fun MutableState>.removeAt(index: Int): Chat { return res } -fun MutableState>.removeLastAndNotify() { +fun MutableState>.removeLastAndNotify(contentTag: MsgContentTag?) { val removed: Triple value = SnapshotStateList().apply { addAll(value) @@ -2588,7 +2680,7 @@ fun MutableState>.removeLastAndNotify() { val rem = removeLast() removed = Triple(rem.id, remIndex, rem.isRcvNew) } - chatItemsChangesListener?.removed(listOf(removed), value) + chatModel.chatItemsChangesListenerForContent(contentTag)?.removed(listOf(removed), value) } fun MutableState>.replaceAll(elems: List) { @@ -2602,7 +2694,8 @@ fun MutableState>.clear() { // Removes all chatItems and notifies a listener about it fun MutableState>.clearAndNotify() { value = SnapshotStateList() - chatItemsChangesListener?.cleared() + chatModel.chatsContext.chatItemsChangesListener?.cleared() + chatModel.reportsChatsContext.chatItemsChangesListener?.cleared() } fun State>.asReversed(): MutableList = value.asReversed() @@ -3688,6 +3781,17 @@ object MsgContentSerializer : KSerializer { } } +@Serializable +enum class MsgContentTag { + @SerialName("text") Text, + @SerialName("link") Link, + @SerialName("image") Image, + @SerialName("video") Video, + @SerialName("voice") Voice, + @SerialName("file") File, + @SerialName("report") Report, +} + @Serializable class FormattedText(val text: String, val format: Format? = null) { // TODO make it dependent on simplexLinkMode preference diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 4630d77aa8..f2bd8283bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -18,6 +18,7 @@ import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -893,8 +894,8 @@ object ChatController { return null } - suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair? { - val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair? { + val r = sendCmd(rh, CC.ApiGetChat(type, id, contentTag, pagination, search)) if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) { @@ -1497,6 +1498,9 @@ object ChatController { withChats { clearChat(chat.remoteHostId, updatedChatInfo) } + withChats(MsgContentTag.Report) { + clearChat(chat.remoteHostId, updatedChatInfo) + } ntfManager.cancelNotificationsForChat(chat.chatInfo.id) close?.invoke() } @@ -2402,7 +2406,7 @@ object ChatController { val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { withChats { - if (chatModel.hasChat(rhId, contactRequest.id)) { + if (hasChat(rhId, contactRequest.id)) { updateChatInfo(rhId, cInfo) } else { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) @@ -2412,7 +2416,7 @@ object ChatController { ntfManager.notifyContactRequestReceived(r.user, cInfo) } is CR.ContactUpdated -> { - if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) { + if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) withChats { updateChatInfo(rhId, cInfo) @@ -2424,10 +2428,13 @@ object ChatController { withChats { upsertGroupMember(rhId, r.groupInfo, r.toMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.toMember) + } } } is CR.ContactsMerged -> { - if (active(r.user) && chatModel.hasChat(rhId, r.mergedContact.id)) { + if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } @@ -2472,9 +2479,19 @@ object ChatController { if (active(r.user)) { withChats { addChatItem(rhId, cInfo, cItem) + if (cItem.isActiveReport) { + increaseGroupReportsCounter(rhId, cInfo.id) + } + } + withReportsChatsIfOpen { + if (cItem.isReport) { + addChatItem(rhId, cInfo, cItem) + } } } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { - chatModel.increaseUnreadCounter(rhId, r.user) + withChats { + increaseUnreadCounter(rhId, r.user) + } } val file = cItem.file val mc = cItem.content.msgContent @@ -2497,6 +2514,11 @@ object ChatController { withChats { updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } + withReportsChatsIfOpen { + if (cItem.isReport) { + updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + } + } } } is CR.ChatItemUpdated -> @@ -2506,13 +2528,20 @@ object ChatController { withChats { updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } + withReportsChatsIfOpen { + if (r.reaction.chatReaction.chatItem.isReport) { + updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + } + } } } is CR.ChatItemsDeleted -> { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) { - chatModel.decreaseUnreadCounter(rhId, r.user) + withChats { + decreaseUnreadCounter(rhId, r.user) + } } } return @@ -2541,6 +2570,67 @@ object ChatController { upsertChatItem(rhId, cInfo, toChatItem.chatItem) } } + withReportsChatsIfOpen { + if (cItem.isReport) { + if (toChatItem == null) { + removeChatItem(rhId, cInfo, cItem) + } else { + upsertChatItem(rhId, cInfo, toChatItem.chatItem) + } + } + } + } + } + is CR.GroupChatItemsDeleted -> { + if (!active(r.user)) { + val users = chatController.listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) + return + } + val cInfo = ChatInfo.Group(r.groupInfo) + withChats { + r.chatItemIDs.forEach { itemId -> + val cItem = chatItems.value.firstOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id + if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { + ntfManager.cancelNotificationsForChat(cInfo.id) + ntfManager.displayNotification( + r.user, + cInfo.id, + cInfo.displayName, + generalGetString(MR.strings.marked_deleted_description) + ) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember != r.member_) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + if (cItem.isActiveReport) { + decreaseGroupReportsCounter(rhId, cInfo.id) + } + } + } + withReportsChatsIfOpen { + r.chatItemIDs.forEach { itemId -> + val cItem = chatItems.value.firstOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember != r.member_) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + } } } is CR.ReceivedGroupInvitation -> { @@ -2606,30 +2696,45 @@ object ChatController { withChats { upsertGroupMember(rhId, r.groupInfo, r.deletedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + } } is CR.LeftMember -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberRole -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberRoleUser -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberBlockedForAll -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { @@ -3002,6 +3107,11 @@ object ChatController { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem withChats { upsertChatItem(rh, cInfo, cItem) } + withReportsChatsIfOpen { + if (cItem.isReport) { + upsertChatItem(rh, cInfo, cItem) + } + } } } @@ -3011,10 +3121,14 @@ object ChatController { val notify = { ntfManager.notifyMessageReceived(rh, user, cInfo, cItem) } if (!activeUser(rh, user)) { notify() - } else if (withChats { upsertChatItem(rh, cInfo, cItem) }) { - notify() - } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { - notify() + } else { + val createdChat = withChats { upsertChatItem(rh, cInfo, cItem) } + withReportsChatsIfOpen { if (cItem.content.msgContent is MsgContent.MCReport) { upsertChatItem(rh, cInfo, cItem) } } + if (createdChat) { + notify() + } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { + notify() + } } } @@ -3059,6 +3173,11 @@ object ChatController { chats.clear() popChatCollector.clear() } + withReportsChatsIfOpen { + chatItems.clearAndNotify() + chats.clear() + popChatCollector.clear() + } } val statuses = apiGetNetworkStatuses(rhId) if (statuses != null) { @@ -3205,7 +3324,7 @@ sealed class CC { class ApiGetSettings(val settings: AppSettings): CC() class ApiGetChatTags(val userId: Long): CC() class ApiGetChats(val userId: Long): CC() - class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() + class ApiGetChat(val type: ChatType, val id: Long, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() @@ -3367,7 +3486,14 @@ sealed class CC { is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}" is ApiGetChatTags -> "/_get tags $userId" is ApiGetChats -> "/_get chats $userId pcc=on" - is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + is ApiGetChat -> { + val tag = if (contentTag == null) { + "" + } else { + " content=${contentTag.name.lowercase()}" + } + "/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + } is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) @@ -5540,6 +5666,7 @@ sealed class CR { @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("reactionMembers") class ReactionMembers(val user: UserRef, val memberReactions: List): CR() @Serializable @SerialName("chatItemsDeleted") class ChatItemsDeleted(val user: UserRef, val chatItemDeletions: List, val byUser: Boolean): CR() + @Serializable @SerialName("groupChatItemsDeleted") class GroupChatItemsDeleted(val user: UserRef, val groupInfo: GroupInfo, val chatItemIDs: List, val byUser: Boolean, val member_: GroupMember?): CR() @Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List, val forwardConfirmation: ForwardConfirmation? = null): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @@ -5724,6 +5851,7 @@ sealed class CR { is ChatItemReaction -> "chatItemReaction" is ReactionMembers -> "reactionMembers" is ChatItemsDeleted -> "chatItemsDeleted" + is GroupChatItemsDeleted -> "groupChatItemsDeleted" is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" @@ -5826,7 +5954,7 @@ sealed class CR { is ChatRunning -> noDetails() is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) - is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") + is ApiChat -> withUser(user, "remoteHostId: ${chat.remoteHostId}\nchatInfo: ${chat.chatInfo}\nchatStats: ${chat.chatStats}\nnavInfo: ${navInfo}\nchatItems: ${chat.chatItems}") is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") @@ -5900,6 +6028,7 @@ sealed class CR { is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ReactionMembers -> withUser(user, "memberReactions: ${json.encodeToString(memberReactions)}") is ChatItemsDeleted -> withUser(user, "${chatItemDeletions.map { (deletedChatItem, toChatItem) -> "deletedChatItem: ${json.encodeToString(deletedChatItem)}\ntoChatItem: ${json.encodeToString(toChatItem)}" }} \nbyUser: $byUser") + is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_") is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt index b4e823bd45..e6d4514875 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt @@ -23,6 +23,7 @@ expect fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, additionalBarOffset: State? = null, + additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, // by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here // maxSize (at least maxHeight) is needed for blur on appBars to work correctly @@ -42,6 +43,7 @@ expect fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, additionalBarOffset: State? = null, + additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, content: LazyListScope.() -> Unit ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index c2fd52a58c..67fae65897 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -26,6 +26,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout import chat.simplex.common.views.chatlist.NavigationBarBackground +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @@ -154,12 +155,12 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State) { } } LazyColumnWithScrollBar ( - reverseLayout = true, + state = listState, contentPadding = PaddingValues( top = topPaddingToContent(false), bottom = composeViewHeight.value ), - state = listState, + reverseLayout = true, additionalBarOffset = composeViewHeight ) { items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 9b580edb62..afff6a9561 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -36,6 +36,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 5cbc01271a..6419aa884d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -14,9 +14,10 @@ suspend fun apiLoadSingleMessage( rhId: Long?, chatType: ChatType, apiId: Long, - itemId: Long + itemId: Long, + contentTag: MsgContentTag?, ): ChatItem? = coroutineScope { - val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null + val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null chat.chatItems.firstOrNull() } @@ -24,29 +25,36 @@ suspend fun apiLoadMessages( rhId: Long?, chatType: ChatType, apiId: Long, + contentTag: MsgContentTag?, pagination: ChatPagination, - chatState: ActiveChatState, search: String = "", visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last) || !isActive) return@coroutineScope + val chatState = chatModel.chatStateForContent(contentTag) val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState - val oldItems = chatModel.chatItems.value + val oldItems = chatModel.chatItemsForContent(contentTag).value val newItems = SnapshotStateList() when (pagination) { is ChatPagination.Initial -> { val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList() - withChats { - if (chatModel.getChat(chat.id) == null) { - addChat(chat) + if (contentTag == null) { + // update main chats, not content tagged + withChats { + if (getChat(chat.id) == null) { + addChat(chat) + } else { + updateChatInfo(chat.remoteHostId, chat.chatInfo) + updateChatStats(chat.remoteHostId, chat.id, chat.chatStats) + } } } - withChats { - chatModel.chatItemStatuses.clear() + withChats(contentTag) { + chatItemStatuses.clear() chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id splits.value = newSplits @@ -70,7 +78,7 @@ suspend fun apiLoadMessages( ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) - withChats { + withChats(contentTag) { chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) @@ -89,7 +97,7 @@ suspend fun apiLoadMessages( val indexToAdd = min(indexInCurrentItems + 1, newItems.size) val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) - withChats { + withChats(contentTag) { chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) @@ -104,7 +112,7 @@ suspend fun apiLoadMessages( val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) // currently, items will always be added on top, which is index 0 newItems.addAll(0, chat.chatItems) - withChats { + withChats(contentTag) { chatItems.replaceAll(newItems) splits.value = listOf(chat.chatItems.last().id) + newSplits unreadAfterItemId.value = chat.chatItems.last().id @@ -119,7 +127,7 @@ suspend fun apiLoadMessages( newItems.addAll(oldItems) removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) - withChats { + withChats(contentTag) { chatItems.replaceAll(newItems) unreadAfterNewestLoaded.value = 0 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index fda5c35e01..d318cf05fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.* import chat.simplex.common.model.* -import chat.simplex.common.platform.chatModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -240,14 +239,13 @@ data class ActiveChatState ( } } -fun visibleItemIndexesNonReversed(mergedItems: State, listState: LazyListState): IntRange { +fun visibleItemIndexesNonReversed(mergedItems: State, reversedItemsSize: Int, listState: LazyListState): IntRange { val zero = 0 .. 0 if (listState.layoutInfo.totalItemsCount == 0) return zero val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed() if (newest == null || oldest == null) return zero - val size = chatModel.chatItems.value.size - val range = size - oldest .. size - newest + val range = reversedItemsSize - oldest .. reversedItemsSize - newest if (range.first < 0 || range.last < 0) return zero // visible items mapped to their underlying data structure which is chatModel.chatItems 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 285d823ec1..ff954e0f18 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 @@ -31,8 +31,8 @@ import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.activeCall import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.markChatTagRead import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.group.* @@ -57,10 +57,17 @@ data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val dat @Composable // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts -fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) -> Unit) { - val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } +fun ChatView( + staleChatId: State, + reportsView: Boolean, + scrollToItemId: MutableState = remember { mutableStateOf(null) }, + onComposed: suspend (chatId: String) -> Unit +) { val showSearch = rememberSaveable { mutableStateOf(false) } + // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." + val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } val activeChatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo } } + val activeChatStats = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value if (chatInfo == null || user == null) { @@ -69,6 +76,11 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - ModalManager.end.closeModals() } } else { + val groupReports = remember { derivedStateOf { + val reportsCount = if (activeChatInfo.value is ChatInfo.Group) activeChatStats.value?.reportsCount ?: 0 else 0 + GroupReports(reportsCount, reportsView) } + } + val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(groupReports.value.contentTag).value.asReversed() } } val searchText = rememberSaveable { mutableStateOf("") } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { @@ -94,7 +106,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - .distinctUntilChanged() .filterNotNull() .collect { chatId -> - markUnreadChatAsRead(chatId) + if (!groupReports.value.reportsView) { + markUnreadChatAsRead(chatId) + } showSearch.value = false searchText.value = "" selectedChatItems.value = null @@ -107,11 +121,14 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == activeChatInfo.value?.id }?.chatStats?.unreadCount ?: 0 + chatModel.chatsForContent(if (reportsView) MsgContentTag.Report else null).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } val clipboard = LocalClipboardManager.current - CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) { + CompositionLocalProvider( + LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), + LocalContentTag provides groupReports.value.contentTag + ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { var groupMembersJob: Job = remember { Job() } @@ -119,9 +136,19 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { + val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> + if (searchText.value == value) return@onSearchValueChanged + val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged + if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged + withBGApi { + apiFindMessages(c, value, groupReports.value.toContentTag()) + searchText.value = value + } + } ChatLayout( remoteHostId = remoteHostId, chatInfo = activeChatInfo, + reversedChatItems = reversedChatItems, unreadCount, composeState, composeView = { @@ -150,7 +177,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } else { SelectedItemsBottomToolbar( - chatItems = remember { chatModel.chatItems }.value, + reversedChatItems = reversedChatItems, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { canDeleteForAll -> @@ -211,6 +238,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - ) } }, + groupReports, + scrollToItemId, attachmentOption, attachmentBottomSheetState, searchText, @@ -266,7 +295,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) preloadedLink = link } - GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, { + GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, scrollToItemId, { link = it preloadedLink = it }, close, { showSearch.value = true }) @@ -278,6 +307,17 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, + showGroupReports = { + val info = activeChatInfo.value ?: return@ChatLayout + if (ModalManager.end.hasModalsOpen()) { + ModalManager.end.closeModals() + return@ChatLayout + } + hideKeyboard(view) + scope.launch { + showGroupReportsView(staleChatId, scrollToItemId, info) + } + }, showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) groupMembersJob.cancel() @@ -293,7 +333,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - ModalManager.end.closeModals() + if (!groupReports.value.reportsView) { + ModalManager.end.closeModals() + } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) @@ -301,16 +343,16 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, - loadMessages = { chatId, pagination, chatState, visibleItemIndexes -> + loadMessages = { chatId, pagination, visibleItemIndexes -> val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, chatState, searchText.value, visibleItemIndexes) + apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, groupReports.value.toContentTag(), pagination, searchText.value, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> withBGApi { - val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } + val toDeleteItem = reversedChatItems.value.lastOrNull { it.id == itemId } val toModerate = toDeleteItem?.memberToModerate(chatInfo) val groupInfo = toModerate?.first val groupMember = toModerate?.second @@ -341,6 +383,19 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } else { removeChatItem(chatRh, chatInfo, deletedChatItem) } + val deletedItem = deleted.deletedChatItem.chatItem + if (deletedItem.isActiveReport) { + decreaseGroupReportsCounter(chatRh, chatInfo.id) + } + } + withReportsChatsIfOpen { + if (deletedChatItem.isReport) { + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, deletedChatItem) + } + } } } } @@ -454,6 +509,11 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { updateChatItem(cInfo, updatedCI) } + withReportsChatsIfOpen { + if (cItem.isReport) { + updateChatItem(cInfo, updatedCI) + } + } } } }, @@ -471,7 +531,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - groupMembersJob.cancel() groupMembersJob = scope.launch(Dispatchers.Default) { var initialCiInfo = loadChatItemInfo() ?: return@launch - ModalManager.end.closeModals() + if (!ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { + ModalManager.end.closeModals() + } ModalManager.end.showModalCloseable(endButtons = { ShareButton { clipboard.shareText(itemInfoShareText(chatModel, cItem, initialCiInfo, chatModel.controller.appPrefs.developerTools.get())) @@ -506,7 +568,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo, itemsIds) + markChatItemsRead(chatRh, chatInfo.id, itemsIds) } ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatItemsRead( @@ -516,6 +578,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - itemsIds ) } + withReportsChatsIfOpen { + markChatItemsRead(chatRh, chatInfo.id, itemsIds) + } } }, markChatRead = { @@ -523,7 +588,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo) + markChatItemsRead(chatRh, chatInfo.id) } ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatRead( @@ -532,18 +597,13 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - chatInfo.apiId ) } + withReportsChatsIfOpen { + markChatItemsRead(chatRh, chatInfo.id) + } } }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, - onSearchValueChanged = { value -> - if (searchText.value == value) return@ChatLayout - val c = chatModel.getChat(chatInfo.id) ?: return@ChatLayout - if (chatModel.chatId.value != chatInfo.id) return@ChatLayout - withBGApi { - apiFindMessages(c, value) - searchText.value = value - } - }, + onSearchValueChanged = onSearchValueChanged, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), @@ -600,9 +660,12 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) fun ChatLayout( remoteHostId: State, chatInfo: State, + reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeView: (@Composable () -> Unit), + groupReports: State, + scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, searchValue: State, @@ -611,8 +674,9 @@ fun ChatLayout( selectedChatItems: MutableState?>, back: () -> Unit, info: () -> Unit, + showGroupReports: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -671,7 +735,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, !groupReports.value.reportsView)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -684,8 +748,8 @@ fun ChatLayout( override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, + remoteHostId, chatInfo, reversedChatItems, unreadCount, composeState, composeViewHeight, searchValue, + useLinkPreviews, linkMode, groupReports, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, @@ -693,29 +757,90 @@ fun ChatLayout( } } } - Box( - Modifier - .layoutId(CHAT_COMPOSE_LAYOUT_ID) - .align(Alignment.BottomCenter) - .imePadding() - .navigationBarsPadding() - .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) - ) { - composeView() + if (groupReports.value.reportsView) { + Column( + Modifier + .layoutId(CHAT_COMPOSE_LAYOUT_ID) + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .imePadding() + ) { + AnimatedVisibility(selectedChatItems.value != null) { + if (chatInfo != null) { + SelectedItemsBottomToolbar( + reversedChatItems = reversedChatItems, + selectedChatItems = selectedChatItems, + chatInfo = chatInfo, + deleteItems = { _ -> + val itemIds = selectedChatItems.value + val questionText = generalGetString(MR.strings.delete_messages_cannot_be_undone_warning) + if (itemIds != null) { + deleteMessagesAlertDialog(itemIds.sorted(), questionText = questionText, forAll = false, deleteMessages = { ids, _ -> + deleteMessages(remoteHostId, chatInfo, ids, false, moderate = false) { + selectedChatItems.value = null + } + }) + } + }, + moderateItems = {}, + forwardItems = {} + ) + } + } + if (oneHandUI.value) { + // That's placeholder to take some space for bottom app bar in oneHandUI + Box(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier)) + } + } + } else { + Box( + Modifier + .layoutId(CHAT_COMPOSE_LAYOUT_ID) + .align(Alignment.BottomCenter) + .imePadding() + .navigationBarsPadding() + .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) + ) { + composeView() + } } } if (oneHandUI.value && chatBottomBar.value) { - StatusBarBackground() + if (groupReports.value.showBar) { + ReportedCountToolbar(groupReports, withStatusBar = true, showGroupReports) + } else { + StatusBarBackground() + } } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - Box(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - if (selectedChatItems.value == null) { - if (chatInfo != null) { - ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + if (groupReports.value.reportsView) { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + GroupReportsAppBar(groupReports, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value) + } + } + } + } else { + Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + if (chatInfo != null) { + ChatInfoToolbar(chatInfo, groupReports, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + } + } else { + SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) + } + } + if (groupReports.value.showBar && (!oneHandUI.value || !chatBottomBar.value)) { + ReportedCountToolbar(groupReports, withStatusBar = false, showGroupReports) } - } else { - SelectedItemsTopToolbar(selectedChatItems) } } } @@ -726,6 +851,7 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( chatInfo: ChatInfo, + groupReports: State, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, @@ -747,7 +873,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid) { + if (appPlatform.isAndroid && !groupReports.value.reportsView) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -941,25 +1067,65 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo } } +@Composable +private fun ReportedCountToolbar( + groupReports: State, + withStatusBar: Boolean, + showGroupReports: () -> Unit +) { + Box { + val statusBarPadding = if (withStatusBar) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else 0.dp + Row( + Modifier + .fillMaxWidth() + .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) + .background(MaterialTheme.colors.background) + .clickable(onClick = showGroupReports) + .padding(top = statusBarPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) + Spacer(Modifier.width(4.dp)) + val reports = groupReports.value.reportsCount + Text( + if (reports == 1) { + stringResource(MR.strings.group_reports_active_one) + } else { + stringResource(MR.strings.group_reports_active).format(reports) + }, + style = MaterialTheme.typography.button + ) + } + Divider(Modifier.align(Alignment.BottomStart)) + } +} + @Composable private fun ContactVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } +/** Saves current scroll position when [GroupReports] are open and user opens [ChatItemInfoView], for example, and goes back */ +private var reportsListState: LazyListState? = null + @Composable fun BoxScope.ChatItemsList( remoteHostId: Long?, chatInfo: ChatInfo, + reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeViewHeight: State, searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, + groupReports: State, + scrollToItemId: MutableState, selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -984,10 +1150,9 @@ fun BoxScope.ChatItemsList( showViaProxy: Boolean ) { val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } - val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } - val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatState) } } - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatStateForContent(groupReports.value.contentTag)) } } + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears * */ @@ -996,12 +1161,17 @@ fun BoxScope.ChatItemsList( ) val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) { val index = mergedItems.value.items.indexOfLast { it.hasUnread() } - if (index <= 0) { + val reportsState = reportsListState + if (reportsState != null) { + reportsListState = null + reportsState + } else if (index <= 0) { LazyListState(0, 0) } else { LazyListState(index + 1, -maxHeightForList.value) } }) + SaveReportsStateOnDispose(groupReports, listState) val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } @@ -1011,12 +1181,12 @@ fun BoxScope.ChatItemsList( ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) } if (!loadingMoreItems.value) { - PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), reversedChatItems, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value) return@PreloadItems false try { loadingMoreItems.value = true - loadMessages(chatId, pagination, chatModel.chatState) { - visibleItemIndexesNonReversed(mergedItems, listState.value) + loadMessages(chatId, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } } finally { loadingMoreItems.value = false @@ -1029,21 +1199,33 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val highlightedItems = remember { mutableStateOf(setOf()) } val scope = rememberCoroutineScope() - val scrollToItem: (Long) -> Unit = remember { scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } - val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } - - LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) - SmallScrollOnNewMessage(listState, chatModel.chatItems) + val scrollToItem: (Long) -> Unit = remember { + // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling + if (groupReports.value.reportsView) return@remember { scrollToItemId.value = it } + scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + } + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, groupReports.value.contentTag) } + if (!groupReports.value.reportsView) { + LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { + if (appPlatform.isAndroid) { + ModalManager.end.closeModals() + } + scrollToItem(it) + scrollToItemId.value = null } + } + } + LoadLastItems(loadingMoreItems, remoteHostId, chatInfo, groupReports) + SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) DisposableEffectOnGone( always = { - chatModel.chatItemsChangesListener = recalculateChatStatePositions(chatModel.chatState) + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) }, whenGone = { VideoPlayerHolder.releaseAll() - chatModel.chatItemsChangesListener = null + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) } ) @@ -1065,7 +1247,7 @@ fun BoxScope.ChatItemsList( LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() ) { val provider = { - providerForGallery(chatModel.chatItems.value, cItem.id) { indexInReversed -> + providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> itemScope.launch { listState.value.scrollToItem( min(reversedChatItems.value.lastIndex, indexInReversed + 1), @@ -1090,7 +1272,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) } } @@ -1168,7 +1350,7 @@ fun BoxScope.ChatItemsList( val rangeValue = range.value val (prevMember, memCount) = if (rangeValue != null) { - chatModel.getPrevHiddenMember(member, rangeValue) + chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) } else { null to 1 } @@ -1276,7 +1458,7 @@ fun BoxScope.ChatItemsList( if (selectionVisible) { Box(Modifier.matchParentSize().clickable { val checked = selectedChatItems.value?.contains(cItem.id) == true - selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems) + selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) }) } } @@ -1290,12 +1472,13 @@ fun BoxScope.ChatItemsList( LazyColumnWithScrollBar( Modifier.align(Alignment.BottomCenter), state = listState.value, - reverseLayout = true, contentPadding = PaddingValues( - top = topPaddingToContent(true), + top = topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar), bottom = composeViewHeight.value ), + reverseLayout = true, additionalBarOffset = composeViewHeight, + additionalTopBar = remember { derivedStateOf { groupReports.value.showBar } }, chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -1339,8 +1522,8 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(true)).align(Alignment.TopCenter), mergedItems, listState) + FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, groupReports, markChatRead, listState) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopCenter), mergedItems, listState, groupReports) LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } @@ -1360,14 +1543,14 @@ fun BoxScope.ChatItemsList( } @Composable -private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo) { +private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo, groupReports: State) { LaunchedEffect(remoteHostId, chatInfo.id) { try { loadingMoreItems.value = true - if (chatModel.chatState.totalAfter.value <= 0) return@LaunchedEffect + if (chatModel.chatStateForContent(groupReports.value.contentTag).totalAfter.value <= 0) return@LaunchedEffect delay(500) withContext(Dispatchers.Default) { - apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState) + apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, groupReports.value.toContentTag(), ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } } finally { loadingMoreItems.value = false @@ -1376,20 +1559,20 @@ private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: } @Composable -private fun SmallScrollOnNewMessage(listState: State, chatItems: State>) { +private fun SmallScrollOnNewMessage(listState: State, reversedChatItems: State>) { val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() } LaunchedEffect(Unit) { - var lastTotalItems = listState.value.layoutInfo.totalItemsCount - var lastItemId = chatItems.value.lastOrNull()?.id + var prevTotalItems = listState.value.layoutInfo.totalItemsCount + var newestItemId = reversedChatItems.value.firstOrNull()?.id snapshotFlow { listState.value.layoutInfo.totalItemsCount } .distinctUntilChanged() .drop(1) .collect { - val diff = listState.value.layoutInfo.totalItemsCount - lastTotalItems - val sameLastItem = lastItemId == chatItems.value.lastOrNull()?.id - lastTotalItems = listState.value.layoutInfo.totalItemsCount - lastItemId = chatItems.value.lastOrNull()?.id - if (diff < 1 || diff > 2 || sameLastItem) { + val diff = listState.value.layoutInfo.totalItemsCount - prevTotalItems + val sameNewestItem = newestItemId == reversedChatItems.value.firstOrNull()?.id + prevTotalItems = listState.value.layoutInfo.totalItemsCount + newestItemId = reversedChatItems.value.firstOrNull()?.id + if (diff < 1 || diff > 2 || sameNewestItem) { return@collect } try { @@ -1400,7 +1583,7 @@ private fun SmallScrollOnNewMessage(listState: State, chatItems: } } catch (e: CancellationException) { /** - * When you tap and hold a finger on a lazy column with chatItems, and then you receive a message, + * When you tap and hold a finger on a lazy column with reversedChatItems, and then you receive a message, * this coroutine will be canceled with the message "Current mutation had a higher priority" because of animatedScroll. * Which breaks auto-scrolling to bottom. So just ignoring the exception * */ @@ -1440,11 +1623,12 @@ fun BoxScope.FloatingButtons( maxHeight: State, composeViewHeight: State, searchValue: State, + groupReports: State, markChatRead: () -> Unit, listState: State ) { val scope = rememberCoroutineScope() - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 @@ -1490,7 +1674,7 @@ fun BoxScope.FloatingButtons( val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( - Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(true)).align(Alignment.TopEnd), + Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopEnd), topUnreadCount, animatedScrollingInProgress, onClick = { @@ -1512,7 +1696,7 @@ fun BoxScope.FloatingButtons( DefaultDropdownMenu( showDropDown, modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } }, - offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(true)) + offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)) ) { ItemAction( generalGetString(MR.strings.mark_read), @@ -1529,6 +1713,7 @@ fun BoxScope.FloatingButtons( fun PreloadItems( chatId: String, ignoreLoadingRequests: MutableSet, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1539,8 +1724,8 @@ fun PreloadItems( val chatId = rememberUpdatedState(chatId) val loadItems = rememberUpdatedState(loadItems) val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) - PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, mergedItems, listState, remaining, loadItems) - PreloadItemsAfter(allowLoad, chatId, mergedItems, listState, remaining, loadItems) + PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, reversedChatItems, mergedItems, listState, remaining, loadItems) + PreloadItemsAfter(allowLoad, chatId, reversedChatItems, mergedItems, listState, remaining, loadItems) } @Composable @@ -1548,6 +1733,7 @@ private fun PreloadItemsBefore( allowLoad: State, chatId: State, ignoreLoadingRequests: State>, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1560,9 +1746,9 @@ private fun PreloadItemsBefore( val splits = mergedItems.value.splits val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = chatModel.chatItems.value + val items = reversedChatItems.value if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { - lastIndexToLoadFrom = items.lastIndex + lastIndexToLoadFrom = 0 } if (allowLoad.value && lastIndexToLoadFrom != null) { items.getOrNull(items.lastIndex - lastIndexToLoadFrom)?.id @@ -1574,10 +1760,10 @@ private fun PreloadItemsBefore( .filter { !ignoreLoadingRequests.value.contains(it) } .collect { loadFromItemId -> withBGApi { - val sizeWas = chatModel.chatItems.value.size - val firstItemIdWas = chatModel.chatItems.value.firstOrNull()?.id + val sizeWas = reversedChatItems.value.size + val oldestItemIdWas = reversedChatItems.value.lastOrNull()?.id val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - if (triedToLoad && sizeWas == chatModel.chatItems.value.size && firstItemIdWas == chatModel.chatItems.value.firstOrNull()?.id) { + if (triedToLoad && sizeWas == reversedChatItems.value.size && oldestItemIdWas == reversedChatItems.value.lastOrNull()?.id) { ignoreLoadingRequests.value.add(loadFromItemId) } } @@ -1589,6 +1775,7 @@ private fun PreloadItemsBefore( private fun PreloadItemsAfter( allowLoad: MutableState, chatId: State, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1609,12 +1796,12 @@ private fun PreloadItemsAfter( snapshotFlow { listState.value.firstVisibleItemIndex } .distinctUntilChanged() .map { firstVisibleIndex -> - val items = chatModel.chatItems.value + val items = reversedChatItems.value val splits = mergedItems.value.splits val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) { - items.getOrNull(items.lastIndex - split.indexRangeInReversed.first)?.id + items.getOrNull(split.indexRangeInReversed.first)?.id } else { null } @@ -1663,13 +1850,14 @@ private fun TopEndFloatingButton( } @Composable -fun topPaddingToContent(chatView: Boolean): Dp { +fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): Dp { val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val reportsPadding = if (additionalTopBar) AppBarHeight * fontSizeSqrtMultiplier else 0.dp return if (oneHandUI.value && (!chatView || chatBottomBar.value)) { - WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + reportsPadding } else { - AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + reportsPadding } } @@ -1678,12 +1866,13 @@ private fun FloatingDate( modifier: Modifier, mergedItems: State, listState: State, + groupReports: State ) { val isNearBottom = remember(chatModel.chatId) { mutableStateOf(listState.value.firstVisibleItemIndex == 0) } val nearBottomIndex = remember(chatModel.chatId) { mutableStateOf(if (isNearBottom.value) -1 else 0) } val showDate = remember(chatModel.chatId) { mutableStateOf(false) } val density = LocalDensity.current.density - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val fontSizeSqrtMultiplier = fontSizeSqrtMultiplier val lastVisibleItemDate = remember { derivedStateOf { @@ -1767,6 +1956,15 @@ private fun FloatingDate( } } +@Composable +private fun SaveReportsStateOnDispose(groupReports: State, listState: State) { + DisposableEffect(Unit) { + onDispose { + reportsListState = if (groupReports.value.reportsView && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null + } + } +} + @Composable private fun DownloadFilesButton( forwardConfirmation: ForwardConfirmation.FilesNotAccepted, @@ -1893,7 +2091,7 @@ private fun scrollToItem( reversedChatItems: State>, mergedItems: State, listState: State, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, ): (Long) -> Unit = { itemId: Long -> withApi { try { @@ -1907,8 +2105,8 @@ private fun scrollToItem( val pagination = ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2) val oldSize = reversedChatItems.value.size withContext(Dispatchers.Default) { - loadMessages(chatInfo.value.id, pagination, chatModel.chatState) { - visibleItemIndexesNonReversed(mergedItems, listState.value) + loadMessages(chatInfo.value.id, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } } var repeatsLeft = 50 @@ -1939,14 +2137,18 @@ private fun findQuotedItemFromItem( rhId: State, chatInfo: State, scope: CoroutineScope, - scrollToItem: (Long) -> Unit + scrollToItem: (Long) -> Unit, + contentTag: MsgContentTag? ): (Long) -> Unit = { itemId: Long -> scope.launch(Dispatchers.Default) { - val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId) + val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId, contentTag) if (item != null) { withChats { updateChatItem(chatInfo.value, item) } + withReportsChatsIfOpen { + updateChatItem(chatInfo.value, item) + } if (item.quotedItem?.itemId != null) { scrollToItem(item.quotedItem.itemId) } else { @@ -2043,18 +2245,24 @@ private fun SelectedChatItem( ) } -private fun selectUnselectChatItem(select: Boolean, ci: ChatItem, revealed: State, selectedChatItems: MutableState?>) { +private fun selectUnselectChatItem( + select: Boolean, + ci: ChatItem, + revealed: State, + selectedChatItems: MutableState?>, + reversedChatItems: State> +) { val itemIds = mutableSetOf() if (!revealed.value) { - val currIndex = chatModel.getChatItemIndexOrNull(ci) + val currIndex = chatModel.getChatItemIndexOrNull(ci, reversedChatItems.value) val ciCategory = ci.mergeCategory if (currIndex != null && ciCategory != null) { - val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems.value) val range = chatViewItemsRange(currIndex, prevHidden) if (range != null) { - val reversedChatItems = chatModel.chatItems.asReversed() + val reversed = reversedChatItems.value for (i in range) { - itemIds.add(reversedChatItems[i].id) + itemIds.add(reversed[i].id) } } else { itemIds.add(ci.id) @@ -2098,10 +2306,28 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List?, - backgroundGraphicsLayer: GraphicsLayer? + backgroundGraphicsLayer: GraphicsLayer?, + drawWallpaper: Boolean ): Modifier { val wallpaperImage = wallpaper.type.image val wallpaperType = wallpaper.type - val backgroundColor = wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) + val backgroundColor = if (drawWallpaper) wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) else colors.background val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base) return this - .then(if (wallpaperImage != null) + .then(if (wallpaperImage != null && drawWallpaper) Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, backgroundGraphicsLayerSize, backgroundGraphicsLayer) } else Modifier.drawWithCache { onDrawBehind { copyBackgroundToAppBar(backgroundGraphicsLayerSize, backgroundGraphicsLayer) { drawRect(backgroundColor) } } } @@ -2303,7 +2530,7 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf private fun forwardContent(chatItemsIds: List, chatInfo: ChatInfo) { chatModel.chatId.value = null chatModel.sharedContent.value = SharedContent.Forward( - chatModel.chatItems.value.filter { chatItemsIds.contains(it.id) }, + chatModel.chatItemsForContent(null).value.filter { chatItemsIds.contains(it.id) }, chatInfo ) } @@ -2440,9 +2667,12 @@ fun PreviewChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, + reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, + groupReports = remember { mutableStateOf(GroupReports(0, false)) }, + scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), searchValue, @@ -2451,8 +2681,9 @@ fun PreviewChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, + showGroupReports = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _, _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, @@ -2513,9 +2744,12 @@ fun PreviewGroupChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, + reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, + groupReports = remember { mutableStateOf(GroupReports(0, false)) }, + scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), searchValue, @@ -2524,8 +2758,9 @@ fun PreviewGroupChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, + showGroupReports = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _, _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = {}, receiveFile = { _ -> }, 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 01c6faa573..7ca5c873bd 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 @@ -836,7 +836,7 @@ fun ComposeView( fun editPrevMessage() { if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return - val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable } + val lastEditable = chatModel.chatItemsForContent(null).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/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 582a981443..e449831ee0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -21,11 +21,10 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @Composable -fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?>) { +fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?>, onTop: Boolean) { val onBackClicked = { selectedChatItems.value = null } BackHandler(onBack = onBackClicked) val count = selectedChatItems.value?.size ?: 0 - val oneHandUI = remember { appPrefs.oneHandUI.state } DefaultAppBar( navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, title = { @@ -41,7 +40,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?> ) }, onTitleClick = null, - onTop = !oneHandUI.value, + onTop = onTop, onSearchValueChanged = {}, ) } @@ -49,7 +48,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?> @Composable fun SelectedItemsBottomToolbar( chatInfo: ChatInfo, - chatItems: List, + reversedChatItems: State>, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible moderateItems: () -> Unit, @@ -108,8 +107,8 @@ fun SelectedItemsBottomToolbar( } Divider(Modifier.align(Alignment.TopStart)) } - LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) { - recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) + LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) { + recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } } 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 fc637fa381..abfb3895d9 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ChatInfoToolbarTitle import chat.simplex.common.views.helpers.* @@ -64,6 +65,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea withChats { upsertGroupMember(rhId, groupInfo, member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, member) + } } else { break } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 21d678ba50..d82352c5eb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -45,7 +46,7 @@ import kotlinx.coroutines.* const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 @Composable -fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { +fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { BackHandler(onBack = close) // TODO derivedStateOf? val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } @@ -70,6 +71,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin .sortedByDescending { it.memberRole }, developerTools, groupLink, + scrollToItemId, addMembers = { scope.launch(Dispatchers.Default) { setGroupMembers(rhId, groupInfo, chatModel) @@ -198,6 +200,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe withChats { upsertGroupMember(rhId, groupInfo, updatedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, updatedMember) + } } } }, @@ -282,6 +287,7 @@ fun ModalData.GroupChatInfoLayout( members: List, developerTools: Boolean, groupLink: String?, + scrollToItemId: MutableState, addMembers: () -> Unit, showMemberInfo: (GroupMember) -> Unit, editGroupProfile: () -> Unit, @@ -309,12 +315,12 @@ fun ModalData.GroupChatInfoLayout( Box { val oneHandUI = remember { appPrefs.oneHandUI.state } LazyColumnWithScrollBar( + state = listState, contentPadding = if (oneHandUI.value) { PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) } else { PaddingValues(top = topPaddingToContent(false)) - }, - state = listState + } ) { item { Row( @@ -358,6 +364,13 @@ fun ModalData.GroupChatInfoLayout( } val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences GroupPreferencesButton(prefsTitleId, openPreferences) + if (groupInfo.canModerate) { + GroupReportsButton { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) } else { @@ -487,6 +500,15 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) ) } +@Composable +private fun GroupReportsButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_flag), + stringResource(MR.strings.group_reports_member_reports), + click = onClick + ) +} + @Composable private fun SendReceiptsOption(currentUser: User, state: State, onSelected: (SendReceipts) -> Unit) { val values = remember { @@ -737,6 +759,7 @@ fun PreviewGroupChatInfoLayout() { members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, groupLink = null, + scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, ) } 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 657c0923a5..ef1c69a5bb 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -65,6 +66,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -83,7 +87,7 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withBGApi { - apiLoadMessages(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) + apiLoadMessages(rhId, ChatType.Direct, it, null, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) if (chatModel.getContactChat(it) != null) { closeAll() } @@ -142,6 +146,9 @@ fun GroupMemberInfoView( withChats { upsertGroupMember(rhId, groupInfo, mem) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, mem) + } }.onFailure { newRole.value = prevValue } @@ -157,6 +164,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -171,6 +181,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -188,6 +201,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -203,16 +219,16 @@ fun GroupMemberInfoView( verify = { code -> chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> val (verified, existingCode) = r - withChats { - upsertGroupMember( - rhId, - groupInfo, - mem.copy( - activeConn = mem.activeConn?.copy( - connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null - ) - ) + val copy = mem.copy( + activeConn = mem.activeConn?.copy( + connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null ) + ) + withChats { + upsertGroupMember(rhId, groupInfo, copy) + } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, copy) } r } @@ -246,6 +262,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c withChats { upsertGroupMember(rhId, groupInfo, removedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, removedMember) + } } close?.invoke() } @@ -753,6 +772,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem withChats { upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + } } } } @@ -786,6 +808,9 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke withChats { upsertGroupMember(rhId, gInfo, updatedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, gInfo, updatedMember) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 0a807e1d63..3d9f42f929 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -45,6 +45,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> updateGroup(rhId, g) currentPreferences = preferences } + withChats { + updateGroup(rhId, g) + } } afterSave() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index e81722f3f0..3163c109e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt new file mode 100644 index 0000000000..a1ec3ec0a9 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.* + +val LocalContentTag: ProvidableCompositionLocal = staticCompositionLocalOf { null } + +data class GroupReports( + val reportsCount: Int, + val reportsView: Boolean, +) { + val showBar: Boolean = reportsCount > 0 && !reportsView + + fun toContentTag(): MsgContentTag? { + if (!reportsView) return null + return MsgContentTag.Report + } + + val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report +} + +@Composable +private fun GroupReportsView(staleChatId: State, scrollToItemId: MutableState) { + ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {}) +} + +@Composable +fun GroupReportsAppBar( + groupReports: State, + close: () -> Unit, + onSearchValueChanged: (String) -> Unit +) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val showSearch = rememberSaveable { mutableStateOf(false) } + val onBackClicked = { + if (!showSearch.value) { + close() + } else { + onSearchValueChanged("") + showSearch.value = false + } + } + BackHandler(onBack = onBackClicked) + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + fixedTitleText = stringResource(MR.strings.group_reports_member_reports), + onTitleClick = null, + onTop = !oneHandUI.value, + showSearch = showSearch.value, + onSearchValueChanged = onSearchValueChanged, + buttons = { + IconButton({ showSearch.value = true }) { + Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) + } + } + ) + ItemsReload(groupReports) +} + +@Composable +private fun ItemsReload(groupReports: State) { + LaunchedEffect(Unit) { + snapshotFlow { chatModel.chatId.value } + .distinctUntilChanged() + .drop(1) + .filterNotNull() + .map { chatModel.getChat(it) } + .filterNotNull() + .filter { it.chatInfo is ChatInfo.Group } + .collect { chat -> + reloadItems(chat, groupReports) + } + } +} + +suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo) { + openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report) + ModalManager.end.showCustomModal(true, id = ModalViewId.GROUP_REPORTS) { close -> + ModalView({}, showAppBar = false) { + val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) { + GroupReportsView(staleChatId, scrollToItemId) + } else { + LaunchedEffect(Unit) { + close() + } + } + } + } +} + +private suspend fun reloadItems(chat: Chat, groupReports: State) { + val contentFilter = groupReports.value.toContentTag() + apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +} 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 6ebd4b13c3..703d74f225 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 @@ -27,6 +27,7 @@ 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.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatJsonLength diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 9bb3cef1d7..7711ee73af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.platform.onRightClick +import chat.simplex.common.views.chat.group.LocalContentTag @Composable fun CIChatFeatureView( @@ -75,9 +76,9 @@ private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List = arrayListOf() val icons: MutableSet = mutableSetOf() - var i = getChatItemIndexOrNull(chatItem) + val reversedChatItems = m.chatItemsForContent(LocalContentTag.current).value.asReversed() + var i = getChatItemIndexOrNull(chatItem, reversedChatItems) if (i != null) { - val reversedChatItems = m.chatItems.asReversed() while (i < reversedChatItems.size) { val f = featureInfo(reversedChatItems[i], chatInfo) ?: break if (!icons.contains(f.icon)) { 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 58e4a31840..7915d1adf7 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 @@ -30,6 +30,7 @@ import chat.simplex.common.model.ChatModel.currentUser import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -481,7 +482,7 @@ fun ChatItemView( fun ContentItem() { val mc = cItem.content.msgContent if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { @@ -516,8 +517,8 @@ fun ChatItemView( DeleteItemMenu() } - fun mergedGroupEventText(chatItem: ChatItem): String? { - val (count, ns) = chatModel.getConnectedMemberNames(chatItem) + fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): String? { + val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems) val members = when { ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) @@ -536,9 +537,9 @@ fun ChatItemView( } } - fun eventItemViewText(): AnnotatedString { + fun eventItemViewText(reversedChatItems: List): AnnotatedString { val memberDisplayName = cItem.memberDisplayName - val t = mergedGroupEventText(cItem) + val t = mergedGroupEventText(cItem, reversedChatItems) return if (!revealed.value && t != null) { chatEventText(t, cItem.timestampText) } else if (memberDisplayName != null) { @@ -552,12 +553,13 @@ fun ChatItemView( } @Composable fun EventItemView() { - CIEventView(eventItemViewText()) + val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + CIEventView(eventItemViewText(reversedChatItems)) } @Composable fun DeletedItem() { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) @@ -746,20 +748,21 @@ fun DeleteItemAction( deleteMessages: (List) -> Unit, buttonText: String = stringResource(MR.strings.delete_verb), ) { + val contentTag = LocalContentTag.current ItemAction( buttonText, painterResource(MR.images.ic_delete), onClick = { showMenu.value = false if (!revealed.value) { - val currIndex = chatModel.getChatItemIndexOrNull(cItem) + val reversedChatItems = chatModel.chatItemsForContent(contentTag).value.asReversed() + val currIndex = chatModel.getChatItemIndexOrNull(cItem, reversedChatItems) val ciCategory = cItem.mergeCategory if (currIndex != null && ciCategory != null) { - val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems) val range = chatViewItemsRange(currIndex, prevHidden) if (range != null) { val itemIds: ArrayList = arrayListOf() - val reversedChatItems = chatModel.chatItems.asReversed() for (i in range) { itemIds.add(reversedChatItems[i].id) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 2827a649b5..784563dbb2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -128,17 +128,6 @@ fun FramedItemView( Modifier .background(if (sent) sentColor else receivedColor) .fillMaxWidth() - .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = { - if (qi.itemId != null) { - scrollToItem(qi.itemId) - } else { - scrollToQuotedItemFromItem(ci.id) - } - } - ) - .onRightClick { showMenu.value = true } ) { when (qi.content) { is MsgContent.MCImage -> { @@ -216,39 +205,66 @@ fun FramedItemView( .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - if (ci.isReport) { - if (ci.meta.itemDeleted == null) { - FramedItemHeader( - stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), - true, - painterResource(MR.images.ic_flag), - iconColor = Color.Red - ) - } else { - FramedItemHeader(stringResource(MR.strings.report_item_archived), true, painterResource(MR.images.ic_flag)) + @Composable + fun Header() { + if (ci.isReport) { + if (ci.meta.itemDeleted == null) { + FramedItemHeader( + stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), + true, + painterResource(MR.images.ic_flag), + iconColor = Color.Red + ) + } else { + val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName) + } else { + stringResource(MR.strings.report_item_archived) + } + FramedItemHeader(text, true, painterResource(MR.images.ic_flag)) + } + } else if (ci.meta.itemDeleted != null) { + when (ci.meta.itemDeleted) { + is CIDeleted.Moderated -> { + FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + } + is CIDeleted.Blocked -> { + FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.BlockedByAdmin -> { + FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.Deleted -> { + FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } + } + } else if (ci.meta.isLive) { + FramedItemHeader(stringResource(MR.strings.live), false) } - } else if (ci.meta.itemDeleted != null) { - when (ci.meta.itemDeleted) { - is CIDeleted.Moderated -> { - FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) - } - is CIDeleted.Blocked -> { - FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.BlockedByAdmin -> { - FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.Deleted -> { - FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) - } - } - } else if (ci.meta.isLive) { - FramedItemHeader(stringResource(MR.strings.live), false) } if (ci.quotedItem != null) { - ciQuoteView(ci.quotedItem) - } else if (ci.meta.itemForwarded != null) { - FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + Column( + Modifier + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { + if (ci.quotedItem.itemId != null) { + scrollToItem(ci.quotedItem.itemId) + } else { + scrollToQuotedItemFromItem(ci.id) + } + } + ) + .onRightClick { showMenu.value = true } + ) { + Header() + ciQuoteView(ci.quotedItem) + } + } else { + Header() + if (ci.meta.itemForwarded != null) { + FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } } if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { 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 410372fe96..d63094cd1d 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 @@ -12,15 +12,17 @@ import androidx.compose.runtime.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.chatModel import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { +fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Surface( @@ -33,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State< verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f, false)) { - MergedMarkedDeletedText(ci, revealed) + MergedMarkedDeletedText(ci, chatInfo, revealed) } CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -41,11 +43,11 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State< } @Composable -private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State) { - var i = getChatItemIndexOrNull(chatItem) +private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State) { + val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + var i = getChatItemIndexOrNull(chatItem, reversedChatItems) val ciCategory = chatItem.mergeCategory val text = if (!revealed.value && ciCategory != null && i != null) { - val reversedChatItems = ChatModel.chatItems.asReversed() var moderated = 0 var blocked = 0 var blockedByAdmin = 0 @@ -67,7 +69,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State } val total = moderated + blocked + blockedByAdmin + deleted if (total <= 1) - markedDeletedText(chatItem) + markedDeletedText(chatItem, chatInfo) else if (total == moderated) stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) else if (total == blockedByAdmin) @@ -77,7 +79,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State else stringResource(MR.strings.marked_deleted_items_description).format(total) } else { - markedDeletedText(chatItem) + markedDeletedText(chatItem, chatInfo) } Text( @@ -91,8 +93,14 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State ) } -fun markedDeletedText(cItem: ChatItem): String = - if (cItem.meta.itemDeleted != null && cItem.isReport) generalGetString(MR.strings.report_item_archived) +fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String = + if (cItem.meta.itemDeleted != null && cItem.isReport) { + if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName) + } else { + generalGetString(MR.strings.report_item_archived) + } + } else when (cItem.meta.itemDeleted) { is CIDeleted.Moderated -> String.format(generalGetString(MR.strings.moderated_item_description), cItem.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 994d56d1fc..b793955911 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 @@ -10,24 +10,17 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.MaterialTheme.colors import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* -import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.markChatTagRead -import chat.simplex.common.model.ChatModel.updateChatTagRead import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* @@ -209,27 +202,27 @@ suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId) -suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(rhId, ChatType.Group, groupId) +suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? = null) = openChat(rhId, ChatType.Group, groupId, contentTag) -suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.chatType, chatInfo.apiId) +suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag) -private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) = - apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) +private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) = + apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) -suspend fun openLoadedChat(chat: Chat) { - withChats { - chatModel.chatItemStatuses.clear() +suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { + withChats(contentTag) { + chatItemStatuses.clear() chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id - chatModel.chatState.clear() + chatModel.chatStateForContent(contentTag).clear() } } -suspend fun apiFindMessages(ch: Chat, search: String) { - withChats { +suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?) { + withChats(contentTag) { chatItems.clearAndNotify() } - apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search) + apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search = search) } suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { @@ -255,7 +248,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { if (contact.activeConn != null) { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } @@ -295,7 +288,7 @@ fun GroupMenuItems( } else -> { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } @@ -316,7 +309,7 @@ fun GroupMenuItems( @Composable fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState, showMarkRead: Boolean) { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } @@ -324,12 +317,12 @@ fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState, showMarkRea } @Composable -fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { +fun MarkReadChatAction(chat: Chat, showMenu: MutableState) { ItemAction( stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), onClick = { - markChatRead(chat, chatModel) + markChatRead(chat) ntfManager.cancelNotificationsForChat(chat.id) showMenu.value = false } @@ -566,12 +559,15 @@ private fun InvalidDataView() { } } -fun markChatRead(c: Chat, chatModel: ChatModel) { +fun markChatRead(c: Chat) { var chat = c withApi { if (chat.chatStats.unreadCount > 0) { withChats { - markChatItemsRead(chat.remoteHostId, chat.chatInfo) + markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) + } + withReportsChatsIfOpen { + markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) } chatModel.controller.apiChatRead( chat.remoteHostId, @@ -612,7 +608,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) - updateChatTagRead(chat, wasUnread) + updateChatTagReadNoContentTag(chat, wasUnread) } } } @@ -874,7 +870,9 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch } val updatedChat = chatModel.getChat(chatInfo.id) if (updatedChat != null) { - chatModel.updateChatTagRead(updatedChat, wasUnread) + withChats { + updateChatTagReadNoContentTag(updatedChat, wasUnread) + } } val current = currentState?.value if (current != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index b4a381809d..c1728d9021 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -49,7 +49,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds -enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } +enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } sealed class ActiveFilter { data class PresetTag(val tag: PresetTagKind) : ActiveFilter() @@ -152,7 +152,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow) { val userTags = remember { chatModel.userTags } val presetTags = remember { chatModel.presetTags } + val collapsiblePresetTags = presetTags.filter { presetCanBeCollapsed(it.key) && it.value > 0 } + val alwaysShownPresetTags = presetTags.filter { !presetCanBeCollapsed(it.key) && it.value > 0 } val activeFilter = remember { chatModel.activeChatTagFilter } val unreadTags = remember { chatModel.unreadTags } val rhId = chatModel.remoteHostId() @@ -935,13 +937,16 @@ private fun TagsView(searchText: MutableState) { val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) TagsRow { - if (presetTags.size > 1) { - if (presetTags.size + userTags.value.size <= 3) { + if (collapsiblePresetTags.size > 1) { + if (collapsiblePresetTags.size + alwaysShownPresetTags.size + userTags.value.size <= 3) { PresetTagKind.entries.filter { t -> (presetTags[t] ?: 0) > 0 }.forEach { tag -> ExpandedTagFilterView(tag) } } else { CollapsedTagsFilterView(searchText) + alwaysShownPresetTags.forEach { tag -> + ExpandedTagFilterView(tag.key) + } } } @@ -1106,7 +1111,7 @@ private fun CollapsedTagsFilterView(searchText: MutableState) { val showMenu = remember { mutableStateOf(false) } val selectedPresetTag = when (val af = activeFilter.value) { - is ActiveFilter.PresetTag -> af.tag + is ActiveFilter.PresetTag -> if (presetCanBeCollapsed(af.tag)) af.tag else null else -> null } @@ -1152,7 +1157,7 @@ private fun CollapsedTagsFilterView(searchText: MutableState) { ) } PresetTagKind.entries.forEach { tag -> - if ((presetTags[tag] ?: 0) > 0) { + if ((presetTags[tag] ?: 0) > 0 && presetCanBeCollapsed(tag)) { ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction) } } @@ -1214,14 +1219,15 @@ fun filteredChats( private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = when (activeFilter) { - is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo) + is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats) is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 else -> true } -fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = +fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo, chatStats: Chat.ChatStats): Boolean = when (tag) { + PresetTagKind.GROUP_REPORTS -> chatStats.reportsCount > 0 PresetTagKind.FAVORITES -> chatInfo.chatSettings?.favorite == true PresetTagKind.CONTACTS -> when (chatInfo) { is ChatInfo.Direct -> !(chatInfo.contact.activeConn == null && chatInfo.contact.profile.contactLink != null && chatInfo.contact.active) && !chatInfo.contact.chatDeleted @@ -1246,6 +1252,7 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair = when (tag) { + PresetTagKind.GROUP_REPORTS -> (if (active) MR.images.ic_flag_filled else MR.images.ic_flag) to MR.strings.chat_list_group_reports PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups @@ -1253,6 +1260,11 @@ private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes } +private fun presetCanBeCollapsed(tag: PresetTagKind): Boolean = when (tag) { + PresetTagKind.GROUP_REPORTS -> false + else -> true +} + fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) { scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } } } 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 d0e9b003e2..0b7054114f 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 @@ -175,7 +175,7 @@ fun ChatPreviewView( val (text: CharSequence, inlineTextContent) = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null - else -> markedDeletedText(ci) to null + else -> markedDeletedText(ci, chat.chatInfo) to null } val formattedText = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> null @@ -322,6 +322,8 @@ fun ChatPreviewView( } else if (cInfo is ChatInfo.Group) { if (progressByTimeout) { progressView() + } else if (chat.chatStats.reportsCount > 0) { + GroupReportsIcon() } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -469,6 +471,18 @@ fun IncognitoIcon(incognito: Boolean) { } } +@Composable +fun GroupReportsIcon() { + Icon( + painterResource(MR.images.ic_flag), + contentDescription = null, + tint = MaterialTheme.colors.error, + modifier = Modifier + .size(21.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) +} + @Composable private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String { return if (groupInfo.membership.memberIncognito) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index f8ddc16bde..1b563e6d02 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -32,6 +32,7 @@ import chat.simplex.common.model.ChatController.apiDeleteChatTag import chat.simplex.common.model.ChatController.apiSetChatTags import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction @@ -72,11 +73,11 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: LazyColumnWithScrollBar( modifier = if (reorderMode) Modifier.dragContainer(dragDropState) else Modifier, + state = listState, contentPadding = PaddingValues( top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp ), - state = listState, verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top, ) { @Composable fun CreateList() { 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 933bc0c93a..d951f1f812 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 @@ -21,6 +21,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -534,6 +535,11 @@ fun deleteChatDatabaseFilesAndState() { chats.clear() popChatCollector.clear() } + withReportsChatsIfOpen { + chatItems.clearAndNotify() + chats.clear() + popChatCollector.clear() + } } chatModel.users.clear() ntfManager.cancelAllNotifications() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 4bf20d2128..1c5f86c8b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -35,7 +35,8 @@ fun DefaultAppBar( // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier val modifier = if (!showSearch) { Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { }) - } else Modifier.imePadding() + } else if (!onTop) Modifier.imePadding() + else Modifier val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) val prefAlpha = remember { appPrefs.inAppBarsAlpha.state } 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 819efcdd9a..3e24629ab1 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 @@ -77,8 +77,19 @@ class ModalData(val keyboardCoversBar: Boolean = true) { val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar) } +enum class ModalViewId { + GROUP_REPORTS +} + class ModalManager(private val placement: ModalPlacement? = null) { - private val modalViews = arrayListOf Unit) -> Unit)>>() + data class ModalViewHolder( + val id: ModalViewId?, + val animated: Boolean, + val data: ModalData, + val modal: @Composable ModalData.(close: () -> Unit) -> Unit + ) + + private val modalViews = arrayListOf() private val _modalCount = mutableStateOf(0) val modalCount: State = _modalCount private val toRemove = mutableSetOf() @@ -88,19 +99,23 @@ class ModalManager(private val placement: ModalPlacement? = null) { private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null) private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null) - fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { - showCustomModal { close -> + fun hasModalOpen(id: ModalViewId): Boolean = modalViews.any { it.id == id } + + fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { + showCustomModal(id = id) { close -> ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() }) } } - fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { - showCustomModal { close -> + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { + showCustomModal(id = id) { close -> ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) }) } } - fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) { + fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, id: ModalViewId? = null, modal: @Composable ModalData.(close: () -> Unit) -> Unit) { Log.d(TAG, "ModalManager.showCustomModal") val data = ModalData(keyboardCoversBar = keyboardCoversBar) // Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen. @@ -111,7 +126,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { // Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0) // to prevent unneeded animation on different situations val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START) - modalViews.add(Triple(anim, data, modal)) + modalViews.add(ModalViewHolder(id, anim, data, modal)) _modalCount.value = modalViews.size - toRemove.size if (placement == ModalPlacement.CENTER) { @@ -139,7 +154,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun closeModal() { if (modalViews.isNotEmpty()) { - if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex) + if (modalViews.lastOrNull()?.animated == false) modalViews.removeAt(modalViews.lastIndex) else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } } _modalCount.value = modalViews.size - toRemove.size @@ -161,10 +176,10 @@ class ModalManager(private val placement: ModalPlacement? = null) { @Composable fun showInView() { // Without animation - if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) { + if (modalCount.value > 0 && modalViews.lastOrNull()?.animated == false) { modalViews.lastOrNull()?.let { - CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) { - it.third(it.second, ::closeModal) + CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) { + it.modal(it.data, ::closeModal) } } return @@ -179,8 +194,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { } ) { modalViews.getOrNull(it - 1)?.let { - CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) { - it.third(it.second, ::closeModal) + CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) { + it.modal(it.data, ::closeModal) } } // This is needed because if we delete from modalViews immediately on request, animation will be bad diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index aa23eb355f..2380c64a4c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -45,7 +45,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c withChats { updateGroup(rhId = rhId, groupInfo) chatItems.clearAndNotify() - chatModel.chatItemStatuses.clear() + chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id } setGroupMembers(rhId, groupInfo, chatModel) 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 920e4b36e7..54a49bdf31 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -40,6 +40,7 @@ Only you and moderators see it Only sender and moderators see it archived report + archived report by %s blocked blocked by admin %d messages blocked @@ -440,8 +441,13 @@ Groups Businesses Notes + Reports All Add list + 1 report + %d reports + Member reports + Archived member reports Share message… diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg new file mode 100644 index 0000000000..a6f5a70618 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file 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 221f1a1291..9d747206ab 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 @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme @@ -61,6 +62,9 @@ fun showApp() { chatModel.chatId.value = null chatItems.clearAndNotify() } + withReportsChatsIfOpen { + chatItems.clearAndNotify() + } } } chatModel.activeCall.value?.let { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 785c3b40fa..3f5703365d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -36,6 +36,7 @@ actual fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, fillMaxSize: Boolean, content: LazyListScope.() -> Unit @@ -93,7 +94,7 @@ actual fun LazyColumnWithScrollBar( val modifier = if (fillMaxSize) Modifier.fillMaxSize().then(modifier) else modifier Box(Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).nestedScroll(connection)) { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar) + ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) } } @@ -108,6 +109,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, content: LazyListScope.() -> Unit ) { @@ -135,7 +137,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( val scrollBarDraggingState = remember { mutableStateOf(false) } Box { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar) + ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) } } @@ -147,11 +149,13 @@ private fun ScrollBar( scrollJob: MutableState, scrollBarDraggingState: MutableState, additionalBarHeight: State?, + additionalTopBar: State, chatBottomBar: State, ) { val oneHandUI = remember { appPrefs.oneHandUI.state } + val topBarPadding = if (additionalTopBar.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp val padding = if (additionalBarHeight != null) { - PaddingValues(top = if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value) + PaddingValues(top = topBarPadding + if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value) } else if (reverseLayout) { PaddingValues(bottom = AppBarHeight * fontSizeSqrtMultiplier) } else { From 77de92be03fc4d0bcabb21a97d306ae85ac8b1bf Mon Sep 17 00:00:00 2001 From: Diogo Date: Fri, 10 Jan 2025 21:14:46 +0000 Subject: [PATCH 59/95] android, desktop: fix size changing when empty (#5497) Co-authored-by: Evgeny Poberezkin --- .../chat/simplex/common/views/chatlist/ChatListView.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index c1728d9021..3205a084f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1115,11 +1115,9 @@ private fun CollapsedTagsFilterView(searchText: MutableState) { else -> null } - val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) - Box(rowSizeModifier - .padding(vertical = 4.dp) + Box(Modifier .clip(shape = CircleShape) - .size(30.sp.toDp()) + .size(TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) .clickable { showMenu.value = true }, contentAlignment = Alignment.Center ) { From 57cd99f619997810c666dd7705688b07e72c27bc Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Jan 2025 04:16:05 +0700 Subject: [PATCH 60/95] android, desktop: disable new emojis (#5507) --- .../kotlin/chat/simplex/common/model/ChatModel.kt | 8 ++++++++ .../chat/simplex/common/views/chat/item/ChatItemView.kt | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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 a9599cebc3..1ddf58aef8 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 @@ -3257,6 +3257,14 @@ sealed class MsgReaction { companion object { val values: List get() = MREmojiChar.values().map(::Emoji) + val old: List get() = listOf( + MREmojiChar.ThumbsUp, + MREmojiChar.ThumbsDown, + MREmojiChar.Smile, + MREmojiChar.Sad, + MREmojiChar.Heart, + MREmojiChar.Launch + ).map(::Emoji) } } 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 7915d1adf7..e0650e1d80 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 @@ -258,7 +258,7 @@ fun ChatItemView( @Composable fun MsgReactionsMenu() { - val rs = MsgReaction.values.mapNotNull { r -> + val rs = MsgReaction.old.mapNotNull { r -> if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { r } else { From 821f034d18930c5c32d1c010a72bff15775b9337 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Jan 2025 05:05:05 +0700 Subject: [PATCH 61/95] Revert "android, desktop: ability to scroll in all alerts if screen is small (#5470)" (#5509) This reverts commit 2793692a16ab45d7a81e0e1ce75079ffa36577aa. --- .../chat/simplex/common/views/helpers/AlertManager.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 30c5d9cc3c..6bfcf2809f 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 @@ -297,7 +297,7 @@ private fun AlertContent( belowTextContent: @Composable (() -> Unit) = {}, content: @Composable (() -> Unit) ) { - BoxWithConstraints(Modifier.verticalScroll(rememberScrollState())) { + BoxWithConstraints { Column( Modifier .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) @@ -311,6 +311,7 @@ private fun AlertContent( if (text != null) { Column(Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) .padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING) + .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( @@ -333,9 +334,10 @@ private fun AlertContent( @Composable private fun AlertContent(text: AnnotatedString?, hostDevice: Pair?, extraPadding: Boolean = false, content: @Composable (() -> Unit)) { - BoxWithConstraints(Modifier.verticalScroll(rememberScrollState())) { + BoxWithConstraints { Column( Modifier + .verticalScroll(rememberScrollState()) .padding(bottom = if (appPlatform.isDesktop) DEFAULT_PADDING else DEFAULT_PADDING_HALF) ) { if (appPlatform.isDesktop) { @@ -347,6 +349,7 @@ private fun AlertContent(text: AnnotatedString?, hostDevice: Pair if (text != null) { Column( Modifier.heightIn(max = this@BoxWithConstraints.maxHeight * 0.7f) + .verticalScroll(rememberScrollState()) ) { SelectionContainer { Text( From e3ddf042664275f4854c76e2107306e3cc4483d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Jan 2025 15:48:00 +0700 Subject: [PATCH 62/95] android, desktop: fix reports dashboard (#5508) * android, desktop: fix reports dashboard * change --- .../kotlin/chat/simplex/common/model/SimpleXAPI.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index f2bd8283bb..7b0563e21a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2591,7 +2591,8 @@ object ChatController { val cInfo = ChatInfo.Group(r.groupInfo) withChats { r.chatItemIDs.forEach { itemId -> - val cItem = chatItems.value.firstOrNull { it.id == itemId } ?: return@forEach + decreaseGroupReportsCounter(rhId, cInfo.id) + val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach if (chatModel.chatId.value != null) { // Stop voice playback only inside a chat, allow to play in a chat list AudioPlayer.stop(cItem) @@ -2606,25 +2607,22 @@ object ChatController { generalGetString(MR.strings.marked_deleted_description) ) } - val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember != r.member_) { + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { CIDeleted.Moderated(Clock.System.now(), r.member_) } else { CIDeleted.Deleted(Clock.System.now()) } upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) - if (cItem.isActiveReport) { - decreaseGroupReportsCounter(rhId, cInfo.id) - } } } withReportsChatsIfOpen { r.chatItemIDs.forEach { itemId -> - val cItem = chatItems.value.firstOrNull { it.id == itemId } ?: return@forEach + val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach if (chatModel.chatId.value != null) { // Stop voice playback only inside a chat, allow to play in a chat list AudioPlayer.stop(cItem) } - val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember != r.member_) { + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) { CIDeleted.Moderated(Clock.System.now(), r.member_) } else { CIDeleted.Deleted(Clock.System.now()) From bbb58c8e097977898f75a66fc925e881efe92aeb Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Jan 2025 17:39:39 +0700 Subject: [PATCH 63/95] ios: report tags and icon on ChatList (#5503) * ios: report tags and icon on ChatList * unfilled flag * changes * update lib, simplify * fix * simpler * one loop --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 46 ++++++++++-- apps/ios/Shared/Model/SimpleXAPI.swift | 37 ++++++++++ apps/ios/Shared/Views/Chat/ChatView.swift | 8 ++ .../Shared/Views/ChatList/ChatListView.swift | 73 ++++++++++++------- .../Views/ChatList/ChatPreviewView.swift | 10 +++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++-- apps/ios/SimpleXChat/APITypes.swift | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 9 ++- 8 files changed, 158 insertions(+), 45 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index dad84571ea..5388b4dc47 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -114,7 +114,7 @@ class ChatTagsModel: ObservableObject { var newUnreadTags: [Int64:Int] = [:] for chat in chats { for tag in PresetTag.allCases { - if presetTagMatchesChat(tag, chat.chatInfo) { + if presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) { newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1 } } @@ -143,19 +143,23 @@ class ChatTagsModel: ObservableObject { } } - func addPresetChatTags(_ chatInfo: ChatInfo) { + func addPresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) { for tag in PresetTag.allCases { - if presetTagMatchesChat(tag, chatInfo) { + if presetTagMatchesChat(tag, chatInfo, chatStats) { presetTags[tag] = (presetTags[tag] ?? 0) + 1 } } } - func removePresetChatTags(_ chatInfo: ChatInfo) { + func removePresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) { for tag in PresetTag.allCases { - if presetTagMatchesChat(tag, chatInfo) { + if presetTagMatchesChat(tag, chatInfo, chatStats) { if let count = presetTags[tag] { - presetTags[tag] = max(0, count - 1) + if count > 1 { + presetTags[tag] = count - 1 + } else { + presetTags.removeValue(forKey: tag) + } } } } @@ -186,6 +190,11 @@ class ChatTagsModel: ObservableObject { } } } + + func changeGroupReportsTag(_ by: Int = 0) { + if by == 0 { return } + presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by + } } class NetworkModel: ObservableObject { @@ -432,7 +441,7 @@ final class ChatModel: ObservableObject { updateChatInfo(cInfo) } else if addMissing { addChat(Chat(chatInfo: cInfo, chatItems: [])) - ChatTagsModel.shared.addPresetChatTags(cInfo) + ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats()) } } @@ -873,6 +882,27 @@ final class ChatModel: ObservableObject { users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) } + func increaseGroupReportsCounter(_ chatId: ChatId) { + changeGroupReportsCounter(chatId, 1) + } + + func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) { + changeGroupReportsCounter(chatId, -1) + } + + private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) { + if by == 0 { return } + + if let i = getChatIndex(chatId) { + let chat = chats[i] + let wasReportsCount = chat.chatStats.reportsCount + chat.chatStats.reportsCount = max(0, chat.chatStats.reportsCount + by) + let nowReportsCount = chat.chatStats.reportsCount + let by = wasReportsCount == 0 && nowReportsCount > 0 ? 1 : (wasReportsCount > 0 && nowReportsCount == 0) ? -1 : 0 + ChatTagsModel.shared.changeGroupReportsTag(by) + } + } + // this function analyses "connected" events and assumes that each member will be there only once func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) { var count = 0 @@ -956,7 +986,7 @@ final class ChatModel: ObservableObject { withAnimation { if let i = getChatIndex(id) { let removed = chats.remove(at: i) - ChatTagsModel.shared.removePresetChatTags(removed.chatInfo) + ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats) } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 4744eeefae..e7a691f9e1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1992,6 +1992,9 @@ func processReceivedMsg(_ res: ChatResponse) async { await MainActor.run { if active(user) { m.addChatItem(cInfo, cItem) + if cItem.isActiveReport { + m.increaseGroupReportsCounter(cInfo.id) + } } else if cItem.isRcvNew && cInfo.ntfsEnabled { m.increaseUnreadCounter(user: user) } @@ -2055,6 +2058,40 @@ func processReceivedMsg(_ res: ChatResponse) async { } } } + case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): + if !active(user) { + do { + let users = try listUsers() + await MainActor.run { + m.users = users + } + } catch { + logger.error("Error loading users: \(error)") + } + return + } + let im = ItemsModel.shared + let cInfo = ChatInfo.group(groupInfo: groupInfo) + await MainActor.run { + m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) + } + var notFound = chatItemIDs.count + for ci in im.reversedChatItems { + if chatItemIDs.contains(ci.id) { + let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId { + CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_) + } else { + CIDeleted.deleted(deletedTs: Date.now) + } + await MainActor.run { + var newItem = ci + newItem.meta.itemDeleted = deleted + _ = m.upsertChatItem(cInfo, newItem) + } + notFound -= 1 + if notFound == 0 { break } + } + } case let .receivedGroupInvitation(user, groupInfo, _, _): if active(user) { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5cf75dd6eb..72718edf37 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1825,6 +1825,10 @@ struct ChatView: View { } else { m.removeChatItem(chat.chatInfo, itemDeletion.deletedChatItem.chatItem) } + let deletedItem = itemDeletion.deletedChatItem.chatItem + if deletedItem.isActiveReport { + m.decreaseGroupReportsCounter(chat.chatInfo.id) + } } } } @@ -1902,6 +1906,10 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe } else { ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) } + let deletedItem = di.deletedChatItem.chatItem + if deletedItem.isActiveReport { + ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id) + } } } await onSuccess() diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index b54a58a1fe..e10b02e455 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -32,13 +32,18 @@ enum UserPickerSheet: Identifiable { } enum PresetTag: Int, Identifiable, CaseIterable, Equatable { - case favorites = 0 - case contacts = 1 - case groups = 2 - case business = 3 - case notes = 4 - + case groupReports = 0 + case favorites = 1 + case contacts = 2 + case groups = 3 + case business = 4 + case notes = 5 + var id: Int { rawValue } + + var сollapse: Bool { + self != .groupReports + } } enum ActiveFilter: Identifiable, Equatable { @@ -473,7 +478,7 @@ struct ChatListView: View { func filtered(_ chat: Chat) -> Bool { switch chatTagsModel.activeFilter { - case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo) + case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 case .none: true @@ -685,6 +690,11 @@ struct ChatTagsView: View { expandedPresetTagsFiltersView() } else { collapsedTagsFilterView() + ForEach(PresetTag.allCases, id: \.id) { (tag: PresetTag) in + if !tag.сollapse && (chatTagsModel.presetTags[tag] ?? 0) > 0 { + expandedTagFilterView(tag) + } + } } } let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter { @@ -757,30 +767,34 @@ struct ChatTagsView: View { } .foregroundColor(.secondary) } - - @ViewBuilder private func expandedPresetTagsFiltersView() -> some View { + + @ViewBuilder private func expandedTagFilterView(_ tag: PresetTag) -> some View { let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter { tag } else { nil } + let active = tag == selectedPresetTag + let (icon, text) = presetTagLabel(tag: tag, active: active) + let color: Color = active ? .accentColor : .secondary + + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundColor(color) + ZStack { + Text(text).fontWeight(.semibold).foregroundColor(.clear) + Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color) + } + } + .onTapGesture { + setActiveFilter(filter: .presetTag(tag)) + } + } + + @ViewBuilder private func expandedPresetTagsFiltersView() -> some View { ForEach(PresetTag.allCases, id: \.id) { tag in if (chatTagsModel.presetTags[tag] ?? 0) > 0 { - let active = tag == selectedPresetTag - let (icon, text) = presetTagLabel(tag: tag, active: active) - let color: Color = active ? .accentColor : .secondary - - HStack(spacing: 4) { - Image(systemName: icon) - .foregroundColor(color) - ZStack { - Text(text).fontWeight(.semibold).foregroundColor(.clear) - Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color) - } - } - .onTapGesture { - setActiveFilter(filter: .presetTag(tag)) - } + expandedTagFilterView(tag) } } } @@ -804,7 +818,7 @@ struct ChatTagsView: View { } } ForEach(PresetTag.allCases, id: \.id) { tag in - if (chatTagsModel.presetTags[tag] ?? 0) > 0 { + if (chatTagsModel.presetTags[tag] ?? 0) > 0 && tag.сollapse { Button { setActiveFilter(filter: .presetTag(tag)) } label: { @@ -817,7 +831,7 @@ struct ChatTagsView: View { } } } label: { - if let tag = selectedPresetTag { + if let tag = selectedPresetTag, tag.сollapse { let (systemName, _) = presetTagLabel(tag: tag, active: true) Image(systemName: systemName) .foregroundColor(.accentColor) @@ -831,6 +845,7 @@ struct ChatTagsView: View { private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) { switch tag { + case .groupReports: (active ? "flag.fill" : "flag", "Reports") case .favorites: (active ? "star.fill" : "star", "Favorites") case .contacts: (active ? "person.fill" : "person", "Contacts") case .groups: (active ? "person.2.fill" : "person.2", "Groups") @@ -838,7 +853,7 @@ struct ChatTagsView: View { case .notes: (active ? "folder.fill" : "folder", "Notes") } } - + private func setActiveFilter(filter: ActiveFilter) { if filter != chatTagsModel.activeFilter { chatTagsModel.activeFilter = filter @@ -859,8 +874,10 @@ func chatStoppedIcon() -> some View { } } -func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool { +func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool { switch tag { + case .groupReports: + chatStats.reportsCount > 0 case .favorites: chatInfo.chatSettings?.favorite == true case .contacts: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index ff5fb2986b..9182f25912 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -399,6 +399,8 @@ struct ChatPreviewView: View { case .group: if progressByTimeout { ProgressView() + } else if chat.chatStats.reportsCount > 0 { + groupReportsIcon(size: size * 0.8) } else { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } @@ -444,6 +446,14 @@ struct ChatPreviewView: View { } } +@ViewBuilder func groupReportsIcon(size: CGFloat) -> some View { + Image(systemName: "flag") + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundColor(.red) +} + func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { view() .frame(width: size, height: size) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 08493f081d..f73012033a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -517,9 +517,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -673,9 +673,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -756,8 +756,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index a07790728b..c8b776a57c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -650,6 +650,7 @@ public enum ChatResponse: Decodable, Error { case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed case newChatItems(user: UserRef, chatItems: [AChatItem]) + case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) case chatItemUpdated(user: UserRef, chatItem: AChatItem) @@ -829,6 +830,7 @@ public enum ChatResponse: Decodable, Error { case .groupEmpty: return "groupEmpty" case .userContactLinkSubscribed: return "userContactLinkSubscribed" case .newChatItems: return "newChatItems" + case .groupChatItemsDeleted: return "groupChatItemsDeleted" case .forwardPlan: return "forwardPlan" case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated" case .chatItemUpdated: return "chatItemUpdated" @@ -1008,6 +1010,8 @@ public enum ChatResponse: Decodable, Error { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): + return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))") case let .chatItemsStatusesUpdated(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e47d923dfd..97407817b2 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1539,13 +1539,16 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } public struct ChatStats: Decodable, Hashable { - public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { + public init(unreadCount: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { self.unreadCount = unreadCount + self.reportsCount = reportsCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } public var unreadCount: Int = 0 + // actual only via getChats() and getChat(.initial), otherwise, zero + public var reportsCount: Int = 0 public var minUnreadItemId: Int64 = 0 public var unreadChat: Bool = false } @@ -2611,6 +2614,10 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } } + public var isActiveReport: Bool { + isReport && !isDeletedContent && meta.itemDeleted == nil + } + public var canBeDeletedForSelf: Bool { (content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete } From d81ae757ebbae3f4988be9cf06368242e2baf29b Mon Sep 17 00:00:00 2001 From: Diogo Date: Sat, 11 Jan 2025 10:49:53 +0000 Subject: [PATCH 64/95] ios: moved and rename major tag components to match android/desktop (#5459) Co-authored-by: Evgeny Poberezkin --- .../Views/ChatList/ChatListNavLink.swift | 402 +---------------- .../Shared/Views/ChatList/ChatListView.swift | 8 +- .../Shared/Views/ChatList/TagListView.swift | 408 ++++++++++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + 4 files changed, 418 insertions(+), 404 deletions(-) create mode 100644 apps/ios/Shared/Views/ChatList/TagListView.swift diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 9ed310692f..f1ee4e4c42 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -8,7 +8,6 @@ import SwiftUI import SimpleXChat -import ElegantEmojiPicker typealias DynamicSizes = ( rowHeight: CGFloat, @@ -343,9 +342,9 @@ struct ChatListNavLink: View { AnyView( NavigationView { if chatTagsModel.userTags.isEmpty { - ChatListTagEditor(chat: chat) + TagListEditor(chat: chat) } else { - ChatListTag(chat: chat) + TagListView(chat: chat) } } ) @@ -560,403 +559,6 @@ struct ChatListNavLink: View { } } -struct TagEditorNavParams { - let chat: Chat? - let chatListTag: ChatTagData? - let tagId: Int64? -} - -struct ChatListTag: View { - var chat: Chat? = nil - @Environment(\.dismiss) var dismiss: DismissAction - @EnvironmentObject var theme: AppTheme - @EnvironmentObject var chatTagsModel: ChatTagsModel - @EnvironmentObject var m: ChatModel - @State private var editMode = EditMode.inactive - @State private var tagEditorNavParams: TagEditorNavParams? = nil - - var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] } - - var body: some View { - List { - Section { - ForEach(chatTagsModel.userTags, id: \.id) { tag in - let text = tag.chatTagText - let emoji = tag.chatTagEmoji - let tagId = tag.chatTagId - let selected = chatTagsIds.contains(tagId) - - HStack { - if let emoji { - Text(emoji) - } else { - Image(systemName: "tag") - } - Text(text) - .padding(.leading, 12) - Spacer() - if chat != nil { - radioButton(selected: selected) - } - } - .contentShape(Rectangle()) - .onTapGesture { - if let c = chat { - setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() } - } else { - tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button { - showAlert( - NSLocalizedString("Delete list?", comment: "alert title"), - message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"), - actions: {[ - UIAlertAction( - title: NSLocalizedString("Cancel", comment: "alert action"), - style: .default - ), - UIAlertAction( - title: NSLocalizedString("Delete", comment: "alert action"), - style: .destructive, - handler: { _ in - deleteTag(tagId) - } - ) - ]} - ) - } label: { - Label("Delete", systemImage: "trash.fill") - } - .tint(.red) - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) - } label: { - Label("Edit", systemImage: "pencil") - } - .tint(theme.colors.primary) - } - .background( - // isActive required to navigate to edit view from any possible tag edited in swipe action - NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) { - if let params = tagEditorNavParams { - ChatListTagEditor( - chat: params.chat, - tagId: params.tagId, - emoji: params.chatListTag?.emoji, - name: params.chatListTag?.text ?? "" - ) - } - } label: { - EmptyView() - } - .opacity(0) - ) - } - .onMove(perform: moveItem) - - NavigationLink { - ChatListTagEditor(chat: chat) - } label: { - Label("Create list", systemImage: "plus") - } - } header: { - if chat == nil { - editTagsButton() - .textCase(nil) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .modifier(ThemedBackground(grouped: true)) - .environment(\.editMode, $editMode) - } - - private func editTagsButton() -> some View { - if editMode.isEditing { - Button("Done") { - editMode = .inactive - dismiss() - } - } else { - Button("Edit") { - editMode = .active - } - } - } - - @ViewBuilder private func radioButton(selected: Bool) -> some View { - Image(systemName: selected ? "checkmark.circle.fill" : "circle") - .imageScale(.large) - .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) - } - - private func moveItem(from source: IndexSet, to destination: Int) { - Task { - do { - var tags = chatTagsModel.userTags - tags.move(fromOffsets: source, toOffset: destination) - try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId }) - - await MainActor.run { - chatTagsModel.userTags = tags - } - } catch let error { - showAlert( - NSLocalizedString("Error reordering lists", comment: "alert title"), - message: responseError(error) - ) - } - } - } - - private func deleteTag(_ tagId: Int64) { - Task { - try await apiDeleteChatTag(tagId: tagId) - - await MainActor.run { - chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId } - if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId { - chatTagsModel.activeFilter = nil - } - m.chats.forEach { c in - if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) { - contact.chatTags = contact.chatTags.filter({ $0 != tagId }) - m.updateContact(contact) - } else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) { - group.chatTags = group.chatTags.filter({ $0 != tagId }) - m.updateGroup(group) - } - } - } - } - } -} - -private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) { - Task { - do { - let tagIds: [Int64] = if let t = tagId { [t] } else {[]} - let (userTags, chatTags) = try await apiSetChatTags( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - tagIds: tagIds - ) - - await MainActor.run { - let m = ChatModel.shared - let tm = ChatTagsModel.shared - tm.userTags = userTags - if chat.unreadTag, let tags = chat.chatInfo.chatTags { - tm.decTagsReadCount(tags) - } - if var contact = chat.chatInfo.contact { - contact.chatTags = chatTags - m.updateContact(contact) - } else if var group = chat.chatInfo.groupInfo { - group.chatTags = chatTags - m.updateGroup(group) - } - ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false) - closeSheet() - } - } catch let error { - showAlert( - NSLocalizedString("Error saving chat list", comment: "alert title"), - message: responseError(error) - ) - } - } -} - -struct EmojiPickerView: UIViewControllerRepresentable { - @Binding var selectedEmoji: String? - @Binding var showingPicker: Bool - @Environment(\.presentationMode) var presentationMode - - class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate { - var parent: EmojiPickerView - - init(parent: EmojiPickerView) { - self.parent = parent - } - - func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) { - parent.selectedEmoji = emoji?.emoji - parent.showingPicker = false - picker.dismiss(animated: true) - } - - // Called when the picker is dismissed manually (without selection) - func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { - parent.showingPicker = false - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator(parent: self) - } - - func makeUIViewController(context: Context) -> UIViewController { - let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false) - let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config) - - picker.presentationController?.delegate = context.coordinator - - let viewController = UIViewController() - DispatchQueue.main.async { - if let topVC = getTopViewController() { - topVC.present(picker, animated: true) - } - } - - return viewController - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - // No need to update the controller after creation - } -} - -struct ChatListTagEditor: View { - @Environment(\.dismiss) var dismiss: DismissAction - @EnvironmentObject var chatTagsModel: ChatTagsModel - @EnvironmentObject var theme: AppTheme - var chat: Chat? = nil - var tagId: Int64? = nil - var emoji: String? - var name: String = "" - @State private var newEmoji: String? - @State private var newName: String = "" - @State private var isPickerPresented = false - @State private var saving: Bool? - - var body: some View { - VStack { - List { - let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in - tag.chatTagId != tagId && - ((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName) - } - - Section { - HStack { - Button { - isPickerPresented = true - } label: { - if let newEmoji { - Text(newEmoji) - } else { - Image(systemName: "face.smiling") - .foregroundColor(.secondary) - } - } - TextField("List name...", text: $newName) - } - - Button { - saving = true - if let tId = tagId { - updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName)) - } else { - createChatTag() - } - } label: { - Text( - chat != nil - ? "Add to list" - : "Save list" - ) - } - .disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName) - } footer: { - if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering - HStack { - Image(systemName: "exclamationmark.circle") - .foregroundColor(.red) - Text("List name and emoji should be different for all lists.") - .foregroundColor(theme.colors.secondary) - } - } - } - } - - if isPickerPresented { - EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented) - } - } - .modifier(ThemedBackground(grouped: true)) - .onAppear { - newEmoji = emoji - newName = name - } - } - - var trimmedName: String { - newName.trimmingCharacters(in: .whitespaces) - } - - private func createChatTag() { - Task { - do { - let text = trimmedName - let userTags = try await apiCreateChatTag( - tag: ChatTagData(emoji: newEmoji , text: text) - ) - await MainActor.run { - saving = false - chatTagsModel.userTags = userTags - } - if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) { - setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() } - } else { - await MainActor.run { dismiss() } - } - } catch let error { - await MainActor.run { - saving = nil - showAlert( - NSLocalizedString("Error creating list", comment: "alert title"), - message: responseError(error) - ) - } - } - } - } - - private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) { - Task { - do { - try await apiUpdateChatTag(tagId: tagId, tag: chatTagData) - await MainActor.run { - saving = false - for i in 0.. Alert { Alert( title: Text("Reject contact request"), diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index e10b02e455..68e0c57c75 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -569,7 +569,7 @@ struct ChatListSearchBar: View { var body: some View { VStack(spacing: 12) { - ScrollView([.horizontal], showsIndicators: false) { ChatTagsView(parentSheet: $parentSheet, searchText: $searchText) } + ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) } HStack(spacing: 12) { HStack(spacing: 4) { Image(systemName: "magnifyingglass") @@ -671,7 +671,7 @@ struct ChatListSearchBar: View { } } -struct ChatTagsView: View { +struct TagsView: View { @EnvironmentObject var chatTagsModel: ChatTagsModel @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -732,7 +732,7 @@ struct ChatTagsView: View { content: { AnyView( NavigationView { - ChatListTag(chat: nil) + TagListView(chat: nil) .modifier(ThemedBackground(grouped: true)) } ) @@ -749,7 +749,7 @@ struct ChatTagsView: View { content: { AnyView( NavigationView { - ChatListTagEditor() + TagListEditor() } ) }, diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift new file mode 100644 index 0000000000..8811234f52 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -0,0 +1,408 @@ +// +// TagListView.swift +// SimpleX (iOS) +// +// Created by Diogo Cunha on 31/12/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat +import ElegantEmojiPicker + +struct TagEditorNavParams { + let chat: Chat? + let chatListTag: ChatTagData? + let tagId: Int64? +} + +struct TagListView: View { + var chat: Chat? = nil + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatTagsModel: ChatTagsModel + @EnvironmentObject var m: ChatModel + @State private var editMode = EditMode.inactive + @State private var tagEditorNavParams: TagEditorNavParams? = nil + + var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] } + + var body: some View { + List { + Section { + ForEach(chatTagsModel.userTags, id: \.id) { tag in + let text = tag.chatTagText + let emoji = tag.chatTagEmoji + let tagId = tag.chatTagId + let selected = chatTagsIds.contains(tagId) + + HStack { + if let emoji { + Text(emoji) + } else { + Image(systemName: "tag") + } + Text(text) + .padding(.leading, 12) + Spacer() + if chat != nil { + radioButton(selected: selected) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if let c = chat { + setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() } + } else { + tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + showAlert( + NSLocalizedString("Delete list?", comment: "alert title"), + message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"), + actions: {[ + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default + ), + UIAlertAction( + title: NSLocalizedString("Delete", comment: "alert action"), + style: .destructive, + handler: { _ in + deleteTag(tagId) + } + ) + ]} + ) + } label: { + Label("Delete", systemImage: "trash.fill") + } + .tint(.red) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(theme.colors.primary) + } + .background( + // isActive required to navigate to edit view from any possible tag edited in swipe action + NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) { + if let params = tagEditorNavParams { + TagListEditor( + chat: params.chat, + tagId: params.tagId, + emoji: params.chatListTag?.emoji, + name: params.chatListTag?.text ?? "" + ) + } + } label: { + EmptyView() + } + .opacity(0) + ) + } + .onMove(perform: moveItem) + + NavigationLink { + TagListEditor(chat: chat) + } label: { + Label("Create list", systemImage: "plus") + } + } header: { + if chat == nil { + editTagsButton() + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .modifier(ThemedBackground(grouped: true)) + .environment(\.editMode, $editMode) + } + + private func editTagsButton() -> some View { + if editMode.isEditing { + Button("Done") { + editMode = .inactive + dismiss() + } + } else { + Button("Edit") { + editMode = .active + } + } + } + + @ViewBuilder private func radioButton(selected: Bool) -> some View { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .imageScale(.large) + .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) + } + + private func moveItem(from source: IndexSet, to destination: Int) { + Task { + do { + var tags = chatTagsModel.userTags + tags.move(fromOffsets: source, toOffset: destination) + try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId }) + + await MainActor.run { + chatTagsModel.userTags = tags + } + } catch let error { + showAlert( + NSLocalizedString("Error reordering lists", comment: "alert title"), + message: responseError(error) + ) + } + } + } + + private func deleteTag(_ tagId: Int64) { + Task { + try await apiDeleteChatTag(tagId: tagId) + + await MainActor.run { + chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId } + if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId { + chatTagsModel.activeFilter = nil + } + m.chats.forEach { c in + if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) { + contact.chatTags = contact.chatTags.filter({ $0 != tagId }) + m.updateContact(contact) + } else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) { + group.chatTags = group.chatTags.filter({ $0 != tagId }) + m.updateGroup(group) + } + } + } + } + } +} + +private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) { + Task { + do { + let tagIds: [Int64] = if let t = tagId { [t] } else {[]} + let (userTags, chatTags) = try await apiSetChatTags( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + tagIds: tagIds + ) + + await MainActor.run { + let m = ChatModel.shared + let tm = ChatTagsModel.shared + tm.userTags = userTags + if chat.unreadTag, let tags = chat.chatInfo.chatTags { + tm.decTagsReadCount(tags) + } + if var contact = chat.chatInfo.contact { + contact.chatTags = chatTags + m.updateContact(contact) + } else if var group = chat.chatInfo.groupInfo { + group.chatTags = chatTags + m.updateGroup(group) + } + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false) + closeSheet() + } + } catch let error { + showAlert( + NSLocalizedString("Error saving chat list", comment: "alert title"), + message: responseError(error) + ) + } + } +} + +struct EmojiPickerView: UIViewControllerRepresentable { + @Binding var selectedEmoji: String? + @Binding var showingPicker: Bool + @Environment(\.presentationMode) var presentationMode + + class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate { + var parent: EmojiPickerView + + init(parent: EmojiPickerView) { + self.parent = parent + } + + func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) { + parent.selectedEmoji = emoji?.emoji + parent.showingPicker = false + picker.dismiss(animated: true) + } + + // Called when the picker is dismissed manually (without selection) + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + parent.showingPicker = false + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UIViewController { + let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false) + let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config) + + picker.presentationController?.delegate = context.coordinator + + let viewController = UIViewController() + DispatchQueue.main.async { + if let topVC = getTopViewController() { + topVC.present(picker, animated: true) + } + } + + return viewController + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + // No need to update the controller after creation + } +} + +struct TagListEditor: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var chatTagsModel: ChatTagsModel + @EnvironmentObject var theme: AppTheme + var chat: Chat? = nil + var tagId: Int64? = nil + var emoji: String? + var name: String = "" + @State private var newEmoji: String? + @State private var newName: String = "" + @State private var isPickerPresented = false + @State private var saving: Bool? + + var body: some View { + VStack { + List { + let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in + tag.chatTagId != tagId && + ((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName) + } + + Section { + HStack { + Button { + isPickerPresented = true + } label: { + if let newEmoji { + Text(newEmoji) + } else { + Image(systemName: "face.smiling") + .foregroundColor(.secondary) + } + } + TextField("List name...", text: $newName) + } + + Button { + saving = true + if let tId = tagId { + updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName)) + } else { + createChatTag() + } + } label: { + Text( + chat != nil + ? "Add to list" + : "Save list" + ) + } + .disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName) + } footer: { + if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering + HStack { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + Text("List name and emoji should be different for all lists.") + .foregroundColor(theme.colors.secondary) + } + } + } + } + + if isPickerPresented { + EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented) + } + } + .modifier(ThemedBackground(grouped: true)) + .onAppear { + newEmoji = emoji + newName = name + } + } + + var trimmedName: String { + newName.trimmingCharacters(in: .whitespaces) + } + + private func createChatTag() { + Task { + do { + let text = trimmedName + let userTags = try await apiCreateChatTag( + tag: ChatTagData(emoji: newEmoji , text: text) + ) + await MainActor.run { + saving = false + chatTagsModel.userTags = userTags + } + if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) { + setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() } + } else { + await MainActor.run { dismiss() } + } + } catch let error { + await MainActor.run { + saving = nil + showAlert( + NSLocalizedString("Error creating list", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) { + Task { + do { + try await apiUpdateChatTag(tagId: tagId, tag: chatTagData) + await MainActor.run { + saving = false + for i in 0.. Date: Sat, 11 Jan 2025 23:31:57 +0100 Subject: [PATCH 65/95] fix typo (#5506) --- docs/CLI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CLI.md b/docs/CLI.md index 6f56cf6cd3..628fe2a4af 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -120,7 +120,7 @@ git checkout stable # git checkout v5.3.0-beta.8 ``` -`master` is a development branch, it may containt unstable code. +`master` is a development branch, it may contain unstable code. 3. Prepare the system: From eacae74fed85f7f09d54057291a4ca00ec4636ec Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 12 Jan 2025 21:25:25 +0000 Subject: [PATCH 66/95] core, ui: errors for blocked files and contact addresses (#5510) * core, ui: errors for blocked files and contact addresses * android * iOS: How it works, stub for blog post * android: blocked errors WIP * android: alert with button * update * fix encoding * nix * simplexmq --- apps/ios/Shared/Model/SimpleXAPI.swift | 12 +++++ .../Views/Chat/ChatItem/CIFileView.swift | 40 ++++++++------ .../Views/Chat/ChatItem/CIImageView.swift | 20 ++----- .../Views/Chat/ChatItem/CIVideoView.swift | 20 ++----- .../Views/Chat/ChatItem/CIVoiceView.swift | 20 ++----- apps/ios/SimpleXChat/APITypes.swift | 18 +++++++ apps/ios/SimpleXChat/ChatTypes.swift | 14 ++++- .../chat/simplex/common/model/ChatModel.kt | 17 ++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 34 +++++++++++- .../common/views/chat/item/CIFileView.kt | 54 +++++++++++++------ .../common/views/chat/item/CIImageView.kt | 20 ++----- .../common/views/chat/item/CIVideoView.kt | 20 ++----- .../common/views/chat/item/CIVoiceView.kt | 20 ++----- .../commonMain/resources/MR/base/strings.xml | 5 ++ ...k-privacy-preserving-content-moderation.md | 22 ++++++++ cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Subscriber.hs | 1 + src/Simplex/Chat/Messages.hs | 7 ++- src/Simplex/Chat/View.hs | 8 ++- 20 files changed, 216 insertions(+), 140 deletions(-) create mode 100644 blog/20250112-simplex-network-privacy-preserving-content-moderation.md diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e7a691f9e1..48b78d8505 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -852,6 +852,18 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) return (nil, alert) + case let .chatCmdError(_, .errorAgent(.SMP(_, .BLOCKED(info)))): + let alert = Alert( + title: Text("Connection blocked"), + message: Text("Connection is blocked by server operator:\n\(info.reason.text)"), + primaryButton: .default(Text("Ok")), + secondaryButton: .default(Text("How it works")) { + DispatchQueue.main.async { + UIApplication.shared.open(contentModerationPostLink) + } + } + ) + return (nil, alert) case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))): let alert = mkAlert( title: "Undelivered messages", diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index f5ab7f3a4b..a785f3e6d8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -118,16 +118,10 @@ struct CIFileView: View { } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) case let .rcvWarning(rcvFileError): logger.debug("CIFileView fileAction - in .rcvWarning") - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) case .sndStored: logger.debug("CIFileView fileAction - in .sndStored") if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { @@ -140,16 +134,10 @@ struct CIFileView: View { } case let .sndError(sndFileError): logger.debug("CIFileView fileAction - in .sndError") - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) case let .sndWarning(sndFileError): logger.debug("CIFileView fileAction - in .sndWarning") - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) default: break } } @@ -268,6 +256,26 @@ func saveCryptoFile(_ fileSource: CryptoFile) { } } +func showFileErrorAlert(_ err: FileError, temporary: Bool = false) { + let title: String = if temporary { + NSLocalizedString("Temporary file error", comment: "file error alert title") + } else { + NSLocalizedString("File error", comment: "file error alert title") + } + if let btn = err.moreInfoButton { + showAlert(title, message: err.errorInfo) { + [ + okAlertAction, + UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(contentModerationPostLink) + }) + ] + } + } else { + showAlert(title, message: err.errorInfo) + } +} + struct CIFileView_Previews: PreviewProvider { static var previews: some View { let sentFile: ChatItem = ChatItem( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b06c6df48c..d491563913 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -69,25 +69,13 @@ struct CIImageView: View { case .rcvComplete: () // ? case .rcvCancelled: () // TODO case let .rcvError(rcvFileError): - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) case let .rcvWarning(rcvFileError): - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) case let .sndError(sndFileError): - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) case let .sndWarning(sndFileError): - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) default: () } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 851b90bc3d..f774299ad3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -355,18 +355,12 @@ struct CIVideoView: View { case let .sndError(sndFileError): fileIcon("xmark", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) } case let .sndWarning(sndFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) } case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) @@ -382,18 +376,12 @@ struct CIVideoView: View { case let .rcvError(rcvFileError): fileIcon("xmark", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) } case let .rcvWarning(rcvFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) } case .invalid: fileIcon("questionmark", 10, 13) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index acecaaae4f..ff4378c715 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -169,18 +169,12 @@ struct VoiceMessagePlayer: View { case let .sndError(sndFileError): fileStatusIcon("multiply", 14) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) } case let .sndWarning(sndFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) } case .rcvInvitation: downloadButton(recordingFile, "play.fill") case .rcvAccepted: loadingIcon() @@ -191,18 +185,12 @@ struct VoiceMessagePlayer: View { case let .rcvError(rcvFileError): fileStatusIcon("multiply", 14) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) } case let .rcvWarning(rcvFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) } case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index c8b776a57c..b1056e791f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -2481,6 +2481,7 @@ public enum ProtocolErrorType: Decodable, Hashable { case CMD(cmdErr: ProtocolCommandError) indirect case PROXY(proxyErr: ProxyError) case AUTH + case BLOCKED(blockInfo: BlockingInfo) case CRYPTO case QUOTA case STORE(storeErr: String) @@ -2497,11 +2498,28 @@ public enum ProxyError: Decodable, Hashable { case NO_SESSION } +public struct BlockingInfo: Decodable, Equatable, Hashable { + public var reason: BlockingReason +} + +public enum BlockingReason: String, Decodable { + case spam + case content + + public var text: String { + switch self { + case .spam: NSLocalizedString("Spam", comment: "blocking reason") + case .content: NSLocalizedString("Content violates conditions of use", comment: "blocking reason") + } + } +} + public enum XFTPErrorType: Decodable, Hashable { case BLOCK case SESSION case CMD(cmdErr: ProtocolCommandError) case AUTH + case BLOCKED(blockInfo: BlockingInfo) case SIZE case QUOTA case DIGEST diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 97407817b2..0426b91704 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -15,6 +15,8 @@ public let CREATE_MEMBER_CONTACT_VERSION = 2 // version to receive reports (MCReport) public let REPORTS_VERSION = 12 +public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250112-simplex-network-privacy-preserving-content-moderation.html")! + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -3024,7 +3026,7 @@ public enum SndError: Decodable, Hashable { case proxyRelay(proxyServer: String, srvError: SrvError) case other(sndError: String) - public var errorInfo: String { + public var errorInfo: String { switch self { case .auth: NSLocalizedString("Wrong key or unknown connection - most likely this connection is deleted.", comment: "snd error text") case .quota: NSLocalizedString("Capacity exceeded - recipient did not receive previously sent messages.", comment: "snd error text") @@ -3684,6 +3686,7 @@ public enum CIFileStatus: Decodable, Equatable, Hashable { public enum FileError: Decodable, Equatable, Hashable { case auth + case blocked(server: String, blockInfo: BlockingInfo) case noFile case relay(srvError: SrvError) case other(fileError: String) @@ -3691,6 +3694,7 @@ public enum FileError: Decodable, Equatable, Hashable { var id: String { switch self { case .auth: return "auth" + case let .blocked(srv, info): return "blocked \(srv) \(info)" case .noFile: return "noFile" case let .relay(srvError): return "relay \(srvError)" case let .other(fileError): return "other \(fileError)" @@ -3700,11 +3704,19 @@ public enum FileError: Decodable, Equatable, Hashable { public var errorInfo: String { switch self { case .auth: NSLocalizedString("Wrong key or unknown file chunk address - most likely file is deleted.", comment: "file error text") + case let .blocked(_, info): NSLocalizedString("File is blocked by server operator:\n\(info.reason.text).", comment: "file error text") case .noFile: NSLocalizedString("File not found - most likely file was deleted or cancelled.", comment: "file error text") case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("File server error: %@", comment: "file error text"), srvError.errorInfo) case let .other(fileError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "file error text"), fileError) } } + + public var moreInfoButton: (label: LocalizedStringKey, link: URL)? { + switch self { + case .blocked: ("How it works", contentModerationPostLink) + default: nil + } + } } public enum MsgContent: Equatable, Hashable { 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 1ddf58aef8..2bb1605981 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 @@ -12,6 +12,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState @@ -22,7 +23,6 @@ import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlin.collections.removeAll as remAll import kotlinx.datetime.* import kotlinx.datetime.TimeZone @@ -3591,15 +3591,22 @@ sealed class CIFileStatus { @Serializable sealed class FileError { @Serializable @SerialName("auth") class Auth: FileError() + @Serializable @SerialName("blocked") class Blocked(val server: String, val blockInfo: BlockingInfo): FileError() @Serializable @SerialName("noFile") class NoFile: FileError() @Serializable @SerialName("relay") class Relay(val srvError: SrvError): FileError() @Serializable @SerialName("other") class Other(val fileError: String): FileError() val errorInfo: String get() = when (this) { - is FileError.Auth -> generalGetString(MR.strings.file_error_auth) - is FileError.NoFile -> generalGetString(MR.strings.file_error_no_file) - is FileError.Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo) - is FileError.Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError) + is Auth -> generalGetString(MR.strings.file_error_auth) + is Blocked -> generalGetString(MR.strings.file_error_blocked).format(blockInfo.reason.text) + is NoFile -> generalGetString(MR.strings.file_error_no_file) + is Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo) + is Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError) + } + + val moreInfoButton: Pair? get() = when(this) { + is Blocked -> generalGetString(MR.strings.how_it_works) to contentModerationPostLink + else -> null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7b0563e21a..6a7c3448ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -19,10 +19,12 @@ import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen +import chat.simplex.common.model.SMPErrorType.BLOCKED import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.migration.MigrationFileLinkData @@ -1411,6 +1413,15 @@ object ChatController { ) return null } + r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent + && r.chatError.agentError is AgentErrorType.SMP + && r.chatError.agentError.smpErr is SMPErrorType.BLOCKED -> { + showContentBlockedAlert( + generalGetString(MR.strings.connection_error_blocked), + generalGetString(MR.strings.connection_error_blocked_desc).format(r.chatError.agentError.smpErr.blockInfo.reason.text), + ) + return null + } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP && r.chatError.agentError.smpErr is SMPErrorType.QUOTA -> { @@ -6756,6 +6767,7 @@ sealed class BrokerErrorType { @Serializable @SerialName("TIMEOUT") object TIMEOUT: BrokerErrorType() } +// ProtocolErrorType @Serializable sealed class SMPErrorType { val string: String get() = when (this) { @@ -6764,9 +6776,10 @@ sealed class SMPErrorType { is CMD -> "CMD ${cmdErr.string}" is PROXY -> "PROXY ${proxyErr.string}" is AUTH -> "AUTH" + is BLOCKED -> "BLOCKED ${json.encodeToString(blockInfo)}" is CRYPTO -> "CRYPTO" is QUOTA -> "QUOTA" - is STORE -> "STORE ${storeErr}" + is STORE -> "STORE $storeErr" is NO_MSG -> "NO_MSG" is LARGE_MSG -> "LARGE_MSG" is EXPIRED -> "EXPIRED" @@ -6777,6 +6790,7 @@ sealed class SMPErrorType { @Serializable @SerialName("CMD") class CMD(val cmdErr: ProtocolCommandError): SMPErrorType() @Serializable @SerialName("PROXY") class PROXY(val proxyErr: ProxyError): SMPErrorType() @Serializable @SerialName("AUTH") class AUTH: SMPErrorType() + @Serializable @SerialName("BLOCKED") class BLOCKED(val blockInfo: BlockingInfo): SMPErrorType() @Serializable @SerialName("CRYPTO") class CRYPTO: SMPErrorType() @Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType() @Serializable @SerialName("STORE") class STORE(val storeErr: String): SMPErrorType() @@ -6800,6 +6814,22 @@ sealed class ProxyError { @Serializable @SerialName("NO_SESSION") class NO_SESSION: ProxyError() } +@Serializable +data class BlockingInfo( + val reason: BlockingReason +) + +@Serializable +enum class BlockingReason { + @SerialName("spam") Spam, + @SerialName("content") Content; + + val text: String get() = when (this) { + Spam -> generalGetString(MR.strings.blocking_reason_spam) + Content -> generalGetString(MR.strings.blocking_reason_content) + } +} + @Serializable sealed class ProtocolCommandError { val string: String get() = when (this) { @@ -6875,6 +6905,7 @@ sealed class XFTPErrorType { is SESSION -> "SESSION" is CMD -> "CMD ${cmdErr.string}" is AUTH -> "AUTH" + is BLOCKED -> "BLOCKED ${json.encodeToString(blockInfo)}" is SIZE -> "SIZE" is QUOTA -> "QUOTA" is DIGEST -> "DIGEST" @@ -6890,6 +6921,7 @@ sealed class XFTPErrorType { @Serializable @SerialName("SESSION") object SESSION: XFTPErrorType() @Serializable @SerialName("CMD") class CMD(val cmdErr: ProtocolCommandError): XFTPErrorType() @Serializable @SerialName("AUTH") object AUTH: XFTPErrorType() + @Serializable @SerialName("BLOCKED") class BLOCKED(val blockInfo: BlockingInfo): XFTPErrorType() @Serializable @SerialName("SIZE") object SIZE: XFTPErrorType() @Serializable @SerialName("QUOTA") object QUOTA: XFTPErrorType() @Serializable @SerialName("DIGEST") object DIGEST: XFTPErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 2c16de40e9..8940161898 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chat.item +import SectionItemView import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize @@ -13,6 +14,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -92,25 +95,13 @@ fun CIFileView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) file.fileStatus is CIFileStatus.RcvWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) file.forwardingAllowed() -> { withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) @@ -235,6 +226,37 @@ fun CIFileView( fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { + val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) + val btn = err.moreInfoButton + if (btn != null) { + showContentBlockedAlert(title, err.errorInfo) + } else { + AlertManager.shared.showAlertMsg(title, err.errorInfo) + } +} + +val contentModerationPostLink = "https://simplex.chat/blog/20250112-simplex-network-privacy-preserving-content-moderation.html" + +fun showContentBlockedAlert(title: String, message: String) { + AlertManager.shared.showAlertDialogButtonsColumn(title, text = message, buttons = { + val uriHandler = LocalUriHandler.current + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(contentModerationPostLink) + }) { + Text(generalGetString(MR.strings.how_it_works), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) +} + @Composable expect fun SaveOrOpenFileMenu( showMenu: MutableState, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index b7fe9ea4cf..401d098bea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -238,25 +238,13 @@ fun CIImageView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) file.fileStatus is CIFileStatus.RcvWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) file.fileStatus is CIFileStatus.RcvTransfer -> {} // ? file.fileStatus is CIFileStatus.RcvComplete -> {} // ? file.fileStatus is CIFileStatus.RcvCancelled -> {} // TODO diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 9f7b5dc9c6..8289149ad9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -499,10 +499,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_close), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) } ) is CIFileStatus.SndWarning -> @@ -510,10 +507,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) } ) is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) @@ -532,10 +526,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_close), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) } ) is CIFileStatus.RcvWarning -> @@ -543,10 +534,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) } ) is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 4aedcc013a..136300e4ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -398,10 +398,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) } ) file != null && file.fileStatus is CIFileStatus.SndWarning -> @@ -411,10 +408,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) } ) file?.fileStatus is CIFileStatus.RcvInvitation -> @@ -430,10 +424,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) } ) file != null && file.fileStatus is CIFileStatus.RcvWarning -> @@ -443,10 +434,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) } ) file != null && file.loaded && progress != null && duration != null -> 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 54a49bdf31..38ae957b64 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -132,6 +132,8 @@ For chat profile %s: Errors in servers configuration. Error accepting conditions + Spam + Content violates conditions of use Connection timeout @@ -168,6 +170,8 @@ Please check that you used the correct link or ask your contact to send you another one. Connection error (AUTH) Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection. + Connection blocked + Connection is blocked by server operator:\n%1$s. Undelivered messages The connection reached the limit of undelivered messages, your contact may be offline. Error accepting contact request @@ -323,6 +327,7 @@ Wrong key or unknown file chunk address - most likely file is deleted. + File is blocked by server operator:\n%1$s. File not found - most likely file was deleted or cancelled. File server error: %1$s diff --git a/blog/20250112-simplex-network-privacy-preserving-content-moderation.md b/blog/20250112-simplex-network-privacy-preserving-content-moderation.md new file mode 100644 index 0000000000..6546db8de0 --- /dev/null +++ b/blog/20250112-simplex-network-privacy-preserving-content-moderation.md @@ -0,0 +1,22 @@ +--- +layout: layouts/article.html +title: "SimpleX network: privacy preserving content moderation" +date: 2024-12-18 +preview: How network operators prevent distribution of CSAM without compromising users privacy and security. +# image: images/20241218-pub.jpg +# imageWide: true +draft: true +permalink: "/blog/20250112-simplex-network-privacy-preserving-content-moderation.html" +--- + +# SimpleX network: privacy preserving content moderation + +**Will be published:** Jan 12, 2025 + +This blog post will cover our approach to removing CSAM that has: +- NO user identification, thus preserving privacy of the users. +- NO client- or server-side content scanning, thus preserving privacy and security of e2e encryption. + +The current and future content restriction will only be applied based on the users' complaints, and only to the content that can be accessed by server operators via public channels. + +Please read this document: https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md diff --git a/cabal.project b/cabal.project index bcbf01d365..5a60c53b66 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9d9ec8cd0b171b2058c59c4e7292ccafa96b6e2b + tag: 3d4e0b06c04a13555c55c2e0efde56f9f78e7ea1 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7c00706d33..54b3443b5b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9d9ec8cd0b171b2058c59c4e7292ccafa96b6e2b" = "0mvg9yrwb835vf2kz8k0ac4i7vzjpvbpcwg895n3kcfdkdcnxh14"; + "https://github.com/simplex-chat/simplexmq.git"."3d4e0b06c04a13555c55c2e0efde56f9f78e7ea1" = "0l194fm6kxy54gkyz0lhvba3cxgjdg812qwpjki5kwfmhhliys6q"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e7ca4ed2e9..4c95375f73 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -284,6 +284,7 @@ processAgentMsgSndFile _corrId aFileId msg = do agentFileError :: AgentErrorType -> FileError agentFileError = \case XFTP _ XFTP.AUTH -> FileErrAuth + XFTP srv (XFTP.BLOCKED info) -> FileErrBlocked srv info FILE NO_FILE -> FileErrNoFile BROKER _ e -> brokerError FileErrRelay e e -> FileErrOther $ tshow e diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 6fc6b52884..d665ab806b 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -52,7 +52,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (MsgBody) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) #if defined(dbPostgres) import Database.PostgreSQL.Simple.FromField (FromField (..)) @@ -741,6 +741,7 @@ aciFileStatusJSON = \case data FileError = FileErrAuth + | FileErrBlocked {server :: String, blockInfo :: BlockingInfo} | FileErrNoFile | FileErrRelay {srvError :: SrvError} | FileErrOther {fileError :: Text} @@ -749,14 +750,16 @@ data FileError instance StrEncoding FileError where strEncode = \case FileErrAuth -> "auth" + FileErrBlocked srv info -> "blocked " <> strEncode (srv, info) FileErrNoFile -> "no_file" FileErrRelay srvErr -> "relay " <> strEncode srvErr FileErrOther e -> "other " <> encodeUtf8 e strP = A.takeWhile1 (/= ' ') >>= \case "auth" -> pure FileErrAuth + "blocked" -> FileErrBlocked <$> _strP <*> _strP "no_file" -> pure FileErrNoFile - "relay" -> FileErrRelay <$> (A.space *> strP) + "relay" -> FileErrRelay <$> _strP "other" -> FileErrOther . safeDecodeUtf8 <$> (A.space *> A.takeByteString) s -> FileErrOther . safeDecodeUtf8 . (s <>) <$> A.takeByteString diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2c736d9269..b73f720930 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -66,7 +66,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) @@ -2223,6 +2223,12 @@ viewChatError isCmd logLevel testView = \case [ withConnEntity <> "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" ] + SMP _ (SMP.BLOCKED BlockingInfo {reason}) -> + [withConnEntity <> "error: connection blocked by server operator: " <> reasonStr] + where + reasonStr = case reason of + BRSpam -> "spam" + BRContent -> "content violates conditions of use" BROKER _ NETWORK | not isCmd -> [] BROKER _ TIMEOUT | not isCmd -> [] AGENT A_DUPLICATE -> [withConnEntity <> "error: AGENT A_DUPLICATE" | logLevel == CLLDebug || isCmd] From 9a736b6417681e825514c471f04ef35e159a089c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 12 Jan 2025 21:01:56 +0000 Subject: [PATCH 67/95] core: 6.2.4.0 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.yaml b/package.yaml index 668e2f26a0..f8c75a0eb7 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 6.2.2.0 +version: 6.2.4.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4dc755b856..fc8cab293c 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: 6.2.2.0 +version: 6.2.4.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index cfc4fe2fa0..070c38e2c6 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -73,11 +73,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 2, 0, 7] +minRemoteCtrlVersion = AppVersion [6, 2, 4, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 2, 0, 7] +minRemoteHostVersion = AppVersion [6, 2, 4, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From 748287b724d4e3d3cb130e85d85f7dd5cf29d53e Mon Sep 17 00:00:00 2001 From: Diogo Date: Sun, 12 Jan 2025 22:29:54 +0000 Subject: [PATCH 68/95] ui: disable report item feature (#5498) * ui: disable report item feature --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatView.swift | 6 +++--- .../chat/simplex/common/views/chat/item/ChatItemView.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 3444fd0723..1138bf45fa 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1342,9 +1342,9 @@ struct ChatView: View { if ci.chatDir != .groupSnd { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) - } else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { - reportButton(ci) - } + } // else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole == .member, !live, composeState.voiceMessageRecordingState == .noRecording { + // reportButton(ci) + // } } } else if ci.meta.itemDeleted != nil { if revealed { 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 58e4a31840..88dd30c4e2 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 @@ -399,9 +399,9 @@ fun ChatItemView( val groupInfo = cItem.memberToModerate(cInfo)?.first if (groupInfo != null) { ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) - } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { - ReportItemAction(cItem, composeState, showMenu) - } + } // else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + // ReportItemAction(cItem, composeState, showMenu) + // } } if (cItem.canBeDeletedForSelf) { Divider() From d287df2640d6f7db94fc9a58da88bb36ebea0edb Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 12 Jan 2025 22:47:24 +0000 Subject: [PATCH 69/95] core: fix ghc 8.10.7 import --- src/Simplex/Chat/Protocol.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 577cb6293d..bc6b083964 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -37,7 +37,7 @@ import Data.Maybe (fromMaybe, mapMaybe) import Data.String import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (decodeASCII', decodeLatin1, encodeUtf8) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime) import Data.Type.Equality import Data.Typeable (Typeable) @@ -288,7 +288,7 @@ instance StrEncoding ReportReason where "community" -> pure RRCommunity "profile" -> pure RRProfile "other" -> pure RROther - t -> maybe (fail "bad ReportReason") (pure . RRUnknown) $ decodeASCII' t + t -> pure $ RRUnknown $ safeDecodeUtf8 t instance FromJSON ReportReason where parseJSON = strParseJSON "ReportReason" From 161143add0b98e34b85db63fe78b46ccb03539a1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 13 Jan 2025 00:47:09 +0000 Subject: [PATCH 70/95] 6.2.4: ios 259, android 268, desktop 87 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 33b0642d2b..425ac259cc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -516,9 +516,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -671,9 +671,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -754,8 +754,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.2.0-D2oDit4btfV544uCfkkET8.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.4.0-7uhh57BtNQ1k4RL4Ozyt5.a */, ); path = Libraries; sourceTree = ""; @@ -1931,7 +1931,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1956,7 +1956,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1980,7 +1980,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2005,7 +2005,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -2021,11 +2021,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2041,11 +2041,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2066,7 +2066,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2081,7 +2081,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2103,7 +2103,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2118,7 +2118,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2140,7 +2140,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2166,7 +2166,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2191,7 +2191,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2217,7 +2217,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2242,7 +2242,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2257,7 +2257,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2276,7 +2276,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 257; + CURRENT_PROJECT_VERSION = 259; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2291,7 +2291,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.2.3; + MARKETING_VERSION = 6.2.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 9af367a5f6..8c172d753f 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.2.3 -android.version_code=265 +android.version_name=6.2.4 +android.version_code=268 -desktop.version_name=6.2.3 -desktop.version_code=85 +desktop.version_name=6.2.4 +desktop.version_code=87 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From ef72d8e446e092e5b1fdcd131b054ba841a14355 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 13 Jan 2025 19:46:42 +0700 Subject: [PATCH 71/95] ui: open links from chat list with confirmation (#5519) * ui: open links from chat list with confirmation * appSettings * ios * core: migrate setting * ios icon * android icon --------- Co-authored-by: Evgeny Poberezkin --- .../Views/ChatList/ChatPreviewView.swift | 13 +++++- .../Views/UserSettings/AppSettings.swift | 2 + .../Views/UserSettings/PrivacySettings.swift | 12 ++++++ .../Views/UserSettings/SettingsView.swift | 3 ++ apps/ios/SimpleXChat/APITypes.swift | 19 +++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 14 +++++++ .../views/chatlist/ChatListNavLinkView.kt | 21 ++++++---- .../common/views/chatlist/ChatPreviewView.kt | 40 +++++++++++++++++-- .../views/usersettings/PrivacySettings.kt | 23 +++++++++++ .../commonMain/resources/MR/base/strings.xml | 6 +++ src/Simplex/Chat/AppSettings.hs | 10 +++++ 11 files changed, 151 insertions(+), 12 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 9182f25912..654bb56441 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -313,6 +313,7 @@ struct ChatPreviewView: View { } @ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View { + let linkClicksEnabled = privacyChatListOpenLinksDefault.get() != PrivacyChatListOpenLinksMode.no let mc = ci.content.msgContent switch mc { case let .link(_, preview): @@ -334,7 +335,17 @@ struct ChatPreviewView: View { .cornerRadius(8) } .onTapGesture { - UIApplication.shared.open(preview.uri) + switch privacyChatListOpenLinksDefault.get() { + case .yes: UIApplication.shared.open(preview.uri) + case .no: ItemsModel.shared.loadOpenChat(chat.id) + case .ask: AlertManager.shared.showAlert( + Alert(title: Text("Open web link?"), + message: Text(preview.uri.absoluteString), + primaryButton: .default(Text("Open chat"), action: { ItemsModel.shared.loadOpenChat(chat.id) }), + secondaryButton: .default(Text("Open link"), action: { UIApplication.shared.open(preview.uri) }) + ) + ) + } } } case let .image(_, image): diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 44e0b20958..00532c0a8e 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -38,6 +38,7 @@ extension AppSettings { privacyLinkPreviewsGroupDefault.set(val) def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } + if let val = privacyChatListOpenLinks { privacyChatListOpenLinksDefault.set(val) } if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } @@ -77,6 +78,7 @@ extension AppSettings { c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + c.privacyChatListOpenLinks = privacyChatListOpenLinksDefault.get() c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 62aad348a7..0b9d1ef76c 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -14,6 +14,7 @@ struct PrivacySettings: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true + @State private var chatListOpenLinks = privacyChatListOpenLinksDefault.get() @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @@ -74,6 +75,17 @@ struct PrivacySettings: View { privacyLinkPreviewsGroupDefault.set(linkPreviews) } } + settingsRow("arrow.up.right.circle", color: theme.colors.secondary) { + Picker("Open links from chat list", selection: $chatListOpenLinks) { + ForEach(PrivacyChatListOpenLinksMode.allCases) { mode in + Text(mode.text) + } + } + } + .frame(height: 36) + .onChange(of: chatListOpenLinks) { mode in + privacyChatListOpenLinksDefault.set(mode) + } settingsRow("message", color: theme.colors.secondary) { Toggle("Show last messages", isOn: $showChatPreviews) } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 8a4ccce91b..138c3689f5 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -29,6 +29,7 @@ let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group +let DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS = "privacyChatListOpenLinks" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" @@ -182,6 +183,8 @@ let connectViaLinkTabDefault = EnumDefault(defaults: UserDefa let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) +let privacyChatListOpenLinksDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS, withDefault: PrivacyChatListOpenLinksMode.ask) + let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system) let privacyDeliveryReceiptsSet = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b1056e791f..753a28f7e9 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -2217,6 +2217,22 @@ public enum NotificationPreviewMode: String, SelectableItem, Codable { public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] } +public enum PrivacyChatListOpenLinksMode: String, CaseIterable, Codable, RawRepresentable, Identifiable { + case yes + case no + case ask + + public var id: Self { self } + + public var text: LocalizedStringKey { + switch self { + case .yes: return "Yes" + case .no: return "No" + case .ask: return "Ask" + } + } +} + public struct RemoteCtrlInfo: Decodable { public var remoteCtrlId: Int64 public var ctrlDeviceName: String @@ -2658,6 +2674,7 @@ public struct AppSettings: Codable, Equatable { public var privacyAskToApproveRelays: Bool? = nil public var privacyAcceptImages: Bool? = nil public var privacyLinkPreviews: Bool? = nil + public var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = nil public var privacyShowChatPreviews: Bool? = nil public var privacySaveLastDraft: Bool? = nil public var privacyProtectScreen: Bool? = nil @@ -2693,6 +2710,7 @@ public struct AppSettings: Codable, Equatable { if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacyChatListOpenLinks != def.privacyChatListOpenLinks { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } @@ -2729,6 +2747,7 @@ public struct AppSettings: Codable, Equatable { privacyAskToApproveRelays: true, privacyAcceptImages: true, privacyLinkPreviews: true, + privacyChatListOpenLinks: .ask, privacyShowChatPreviews: true, privacySaveLastDraft: true, privacyProtectScreen: false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 6a7c3448ed..b42d99f2fc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -105,6 +105,7 @@ class AppPreferences { val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true) val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) + val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } } private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name) val simplexLinkMode: SharedPreference = SharedPreference( get = fun(): SimplexLinkMode { @@ -373,6 +374,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" + private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews" private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft" @@ -7071,6 +7073,13 @@ enum class NotificationsMode() { } } +@Serializable +enum class PrivacyChatListOpenLinksMode { + @SerialName("yes") YES, + @SerialName("no") NO, + @SerialName("ask") ASK +} + @Serializable data class AppSettings( var networkConfig: NetCfg? = null, @@ -7079,6 +7088,7 @@ data class AppSettings( var privacyAskToApproveRelays: Boolean? = null, var privacyAcceptImages: Boolean? = null, var privacyLinkPreviews: Boolean? = null, + var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = null, var privacyShowChatPreviews: Boolean? = null, var privacySaveLastDraft: Boolean? = null, var privacyProtectScreen: Boolean? = null, @@ -7114,6 +7124,7 @@ data class AppSettings( if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages } if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews } + if (privacyChatListOpenLinks != def.privacyChatListOpenLinks) { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews } if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft } if (privacyProtectScreen != def.privacyProtectScreen) { empty.privacyProtectScreen = privacyProtectScreen } @@ -7160,6 +7171,7 @@ data class AppSettings( privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) } privacyAcceptImages?.let { def.privacyAcceptImages.set(it) } privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) } + privacyChatListOpenLinks?.let { def.privacyChatListOpenLinks.set(it) } privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) } privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) } privacyProtectScreen?.let { def.privacyProtectScreen.set(it) } @@ -7196,6 +7208,7 @@ data class AppSettings( privacyAskToApproveRelays = true, privacyAcceptImages = true, privacyLinkPreviews = true, + privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK, privacyShowChatPreviews = true, privacySaveLastDraft = true, privacyProtectScreen = false, @@ -7233,6 +7246,7 @@ data class AppSettings( privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(), privacyAcceptImages = def.privacyAcceptImages.get(), privacyLinkPreviews = def.privacyLinkPreviews.get(), + privacyChatListOpenLinks = def.privacyChatListOpenLinks.get(), privacyShowChatPreviews = def.privacyShowChatPreviews.get(), privacySaveLastDraft = def.privacySaveLastDraft.get(), privacyProtectScreen = def.privacyProtectScreen.get(), 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 b793955911..3ba15bc79c 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 @@ -64,13 +64,14 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { when (chat.chatInfo) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) + val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, - click = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } }, + click = defaultClickAction, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) @@ -82,14 +83,15 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { nextChatSelected, ) } - is ChatInfo.Group -> + is ChatInfo.Group -> { + val defaultClickAction = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction) } }, - click = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } }, + click = defaultClickAction, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) @@ -100,11 +102,12 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { selectedChat, nextChatSelected, ) + } is ChatInfo.Local -> { ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, {}) } }, click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }, @@ -925,7 +928,8 @@ fun PreviewChatListNavLinkDirect() { disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, - progressByTimeout = false + progressByTimeout = false, + {} ) }, click = {}, @@ -970,7 +974,8 @@ fun PreviewChatListNavLinkGroup() { disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, - progressByTimeout = false + progressByTimeout = false, + {} ) }, click = {}, 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 0b7054114f..ba7334522a 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 @@ -1,5 +1,6 @@ package chat.simplex.common.views.chatlist +import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.InlineTextContent @@ -27,6 +28,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* @@ -45,7 +47,8 @@ fun ChatPreviewView( disabled: Boolean, linkMode: SimplexLinkMode, inProgress: Boolean, - progressByTimeout: Boolean + progressByTimeout: Boolean, + defaultClickAction: () -> Unit ) { val cInfo = chat.chatInfo @@ -248,7 +251,38 @@ fun ChatPreviewView( val uriHandler = LocalUriHandler.current when (mc) { is MsgContent.MCLink -> SmallContentPreview { - IconButton({ uriHandler.openUriCatching(mc.preview.uri) }, Modifier.desktopPointerHoverIconHand()) { + val linkClicksEnabled = remember { appPrefs.privacyChatListOpenLinks.state }.value != PrivacyChatListOpenLinksMode.NO + IconButton({ + when (appPrefs.privacyChatListOpenLinks.get()) { + PrivacyChatListOpenLinksMode.YES -> uriHandler.openUriCatching(mc.preview.uri) + PrivacyChatListOpenLinksMode.NO -> defaultClickAction() + PrivacyChatListOpenLinksMode.ASK -> AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.privacy_chat_list_open_web_link_question), + text = mc.preview.uri, + buttons = { + Column { + if (chatModel.chatId.value != chat.id) { + SectionItemView({ + AlertManager.shared.hideAlert() + defaultClickAction() + }) { + Text(stringResource(MR.strings.open_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(mc.preview.uri) + } + ) { + Text(stringResource(MR.strings.privacy_chat_list_open_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) + } + }, + if (linkClicksEnabled) Modifier.desktopPointerHoverIconHand() else Modifier, + ) { Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop) } Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) { @@ -527,6 +561,6 @@ private data class ActiveVoicePreview( @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false) + ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 5978f812a8..c411eb0d78 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -63,6 +63,9 @@ fun PrivacySettingsView( SectionView(stringResource(MR.strings.settings_section_title_chats)) { SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) + ChatListLinksOptions(appPrefs.privacyChatListOpenLinks.state, onSelected = { + appPrefs.privacyChatListOpenLinks.set(it) + }) SettingsPreferenceItem( painterResource(MR.images.ic_chat_bubble), stringResource(MR.strings.privacy_show_last_messages), @@ -199,6 +202,26 @@ fun PrivacySettingsView( } } +@Composable +private fun ChatListLinksOptions(state: State, onSelected: (PrivacyChatListOpenLinksMode) -> Unit) { + val values = remember { + PrivacyChatListOpenLinksMode.entries.map { + when (it) { + PrivacyChatListOpenLinksMode.YES -> it to generalGetString(MR.strings.privacy_chat_list_open_links_yes) + PrivacyChatListOpenLinksMode.NO -> it to generalGetString(MR.strings.privacy_chat_list_open_links_no) + PrivacyChatListOpenLinksMode.ASK -> it to generalGetString(MR.strings.privacy_chat_list_open_links_ask) + } + } + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.privacy_chat_list_open_links), + values, + state, + icon = painterResource(MR.images.ic_open_in_new), + onSelected = onSelected + ) +} + @Composable private fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { val modeValues = listOf(SimplexLinkMode.DESCRIPTION, SimplexLinkMode.FULL) 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 38ae957b64..398648b666 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1307,6 +1307,12 @@ Soft Medium Strong + Open links from chat list + Yes + No + Ask + Open web link? + Open link YOU diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs index 1efa69fad4..23b5f2ddad 100644 --- a/src/Simplex/Chat/AppSettings.hs +++ b/src/Simplex/Chat/AppSettings.hs @@ -25,6 +25,8 @@ data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Sho data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show) +data OpenLinksSetting = OLSYes | OLSNo | OLSAsk deriving (Show) + data AppSettings = AppSettings { appPlatform :: Maybe AppPlatform, networkConfig :: Maybe NetworkConfig, @@ -33,6 +35,7 @@ data AppSettings = AppSettings privacyAskToApproveRelays :: Maybe Bool, privacyAcceptImages :: Maybe Bool, privacyLinkPreviews :: Maybe Bool, + privacyChatListOpenLinks :: Maybe OpenLinksSetting, privacyShowChatPreviews :: Maybe Bool, privacySaveLastDraft :: Maybe Bool, privacyProtectScreen :: Maybe Bool, @@ -83,6 +86,7 @@ defaultAppSettings = privacyAskToApproveRelays = Just True, privacyAcceptImages = Just True, privacyLinkPreviews = Just True, + privacyChatListOpenLinks = Just OLSAsk, privacyShowChatPreviews = Just True, privacySaveLastDraft = Just True, privacyProtectScreen = Just False, @@ -120,6 +124,7 @@ defaultParseAppSettings = privacyAskToApproveRelays = Nothing, privacyAcceptImages = Nothing, privacyLinkPreviews = Nothing, + privacyChatListOpenLinks = Nothing, privacyShowChatPreviews = Nothing, privacySaveLastDraft = Nothing, privacyProtectScreen = Nothing, @@ -157,6 +162,7 @@ combineAppSettings platformDefaults storedSettings = privacyAskToApproveRelays = p privacyAskToApproveRelays, privacyAcceptImages = p privacyAcceptImages, privacyLinkPreviews = p privacyLinkPreviews, + privacyChatListOpenLinks = p privacyChatListOpenLinks, privacyShowChatPreviews = p privacyShowChatPreviews, privacySaveLastDraft = p privacySaveLastDraft, privacyProtectScreen = p privacyProtectScreen, @@ -197,6 +203,8 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls) $(JQ.deriveJSON (enumJSON $ dropPrefix "NPA") ''NetworkProxyAuth) +$(JQ.deriveJSON (enumJSON $ dropPrefix "OLS") ''OpenLinksSetting) + $(JQ.deriveJSON defaultJSON ''NetworkProxy) $(JQ.deriveToJSON defaultJSON ''AppSettings) @@ -210,6 +218,7 @@ instance FromJSON AppSettings where privacyAskToApproveRelays <- p "privacyAskToApproveRelays" privacyAcceptImages <- p "privacyAcceptImages" privacyLinkPreviews <- p "privacyLinkPreviews" + privacyChatListOpenLinks <- p "privacyChatListOpenLinks" privacyShowChatPreviews <- p "privacyShowChatPreviews" privacySaveLastDraft <- p "privacySaveLastDraft" privacyProtectScreen <- p "privacyProtectScreen" @@ -244,6 +253,7 @@ instance FromJSON AppSettings where privacyAskToApproveRelays, privacyAcceptImages, privacyLinkPreviews, + privacyChatListOpenLinks, privacyShowChatPreviews, privacySaveLastDraft, privacyProtectScreen, From db8f33debe33a4eaf8208c690f0a85cbb3302907 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 13 Jan 2025 14:06:12 +0000 Subject: [PATCH 72/95] core: add index to load chats faster (#5521) * core: add index to load chats faster * schema * revert query (sqlite) * Revert "revert query (sqlite)" This reverts commit 194a48d61fb4bbcab3137f95aad377edff82158b. --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- .../Chat/Store/Postgres/Migrations/M20241220_initial.hs | 7 +++++++ .../Chat/Store/SQLite/Migrations/M20250105_indexes.hs | 2 ++ src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql | 7 +++++++ 3 files changed, 16 insertions(+) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index c9b36b8d2a..ad9bbd65a4 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -1002,4 +1002,11 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items( msg_content_tag, item_ts ); +CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( + user_id, + group_id, + msg_content_tag, + item_deleted, + item_sent +); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs index 2f73dd91b5..fe465710b7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250105_indexes.hs @@ -9,10 +9,12 @@ m20250105_indexes :: Query m20250105_indexes = [sql| CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items(user_id, group_id, msg_content_tag, item_ts); +CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items(user_id, group_id, msg_content_tag, item_deleted, item_sent); |] down_m20250105_indexes :: Query down_m20250105_indexes = [sql| DROP INDEX idx_chat_items_groups_msg_content_tag_item_ts; +DROP INDEX idx_chat_items_groups_msg_content_tag_deleted; |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 45693f4219..0601c9bbc0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -968,3 +968,10 @@ CREATE INDEX idx_chat_items_groups_msg_content_tag_item_ts ON chat_items( msg_content_tag, item_ts ); +CREATE INDEX idx_chat_items_groups_msg_content_tag_deleted ON chat_items( + user_id, + group_id, + msg_content_tag, + item_deleted, + item_sent +); From ddd3956a6845996aceb9a534864c7a810f9bd48a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 13 Jan 2025 14:52:03 +0000 Subject: [PATCH 73/95] blog: change date --- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- .../chat/simplex/common/views/chat/item/CIFileView.kt | 2 +- ...implex-network-privacy-preserving-content-moderation.md} | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename blog/{20250112-simplex-network-privacy-preserving-content-moderation.md => 20250114-simplex-network-privacy-preserving-content-moderation.md} (88%) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 0426b91704..b3516e068f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -15,7 +15,7 @@ public let CREATE_MEMBER_CONTACT_VERSION = 2 // version to receive reports (MCReport) public let REPORTS_VERSION = 12 -public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250112-simplex-network-privacy-preserving-content-moderation.html")! +public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-privacy-preserving-content-moderation.html")! public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 8940161898..dc51e5eec5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -236,7 +236,7 @@ fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { } } -val contentModerationPostLink = "https://simplex.chat/blog/20250112-simplex-network-privacy-preserving-content-moderation.html" +val contentModerationPostLink = "https://simplex.chat/blog/20250114-simplex-network-privacy-preserving-content-moderation.html" fun showContentBlockedAlert(title: String, message: String) { AlertManager.shared.showAlertDialogButtonsColumn(title, text = message, buttons = { diff --git a/blog/20250112-simplex-network-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-privacy-preserving-content-moderation.md similarity index 88% rename from blog/20250112-simplex-network-privacy-preserving-content-moderation.md rename to blog/20250114-simplex-network-privacy-preserving-content-moderation.md index 6546db8de0..a158fd517b 100644 --- a/blog/20250112-simplex-network-privacy-preserving-content-moderation.md +++ b/blog/20250114-simplex-network-privacy-preserving-content-moderation.md @@ -1,17 +1,17 @@ --- layout: layouts/article.html title: "SimpleX network: privacy preserving content moderation" -date: 2024-12-18 +date: 2025-01-14 preview: How network operators prevent distribution of CSAM without compromising users privacy and security. # image: images/20241218-pub.jpg # imageWide: true draft: true -permalink: "/blog/20250112-simplex-network-privacy-preserving-content-moderation.html" +permalink: "/blog/20250114-simplex-network-privacy-preserving-content-moderation.html" --- # SimpleX network: privacy preserving content moderation -**Will be published:** Jan 12, 2025 +**Will be published:** Jan 14, 2025 This blog post will cover our approach to removing CSAM that has: - NO user identification, thus preserving privacy of the users. From bd396cb4d6e5987c2c9bac9322f8a53a5e472042 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 13 Jan 2025 23:40:07 +0700 Subject: [PATCH 74/95] ui: deleting wallpapers after deleting user and chats (#5524) * ui: deleting wallpapers after deleting user and chats * ios * change * change * change * fix deleting wallpapers --- apps/ios/Shared/Model/ChatModel.swift | 22 +++++++++++++++- .../Views/UserSettings/UserProfilesView.swift | 2 ++ apps/ios/SimpleXChat/ImageUtils.swift | 15 ++++++++--- .../chat/simplex/common/model/ChatModel.kt | 6 ++++- .../simplex/common/views/helpers/Utils.kt | 25 +++++++++++++++++++ .../views/usersettings/UserProfilesView.kt | 2 ++ 6 files changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 6b6b0ac03f..67ecd429d4 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -857,7 +857,10 @@ final class ChatModel: ObservableObject { func removeChat(_ id: String) { withAnimation { - chats.removeAll(where: { $0.id == id }) + if let i = getChatIndex(id) { + let chat = chats.remove(at: i) + removeWallpaperFilesFromChat(chat) + } } } @@ -895,6 +898,23 @@ final class ChatModel: ObservableObject { _ = upsertGroupMember(groupInfo, updatedMember) } } + + func removeWallpaperFilesFromChat(_ chat: Chat) { + if case let .direct(contact) = chat.chatInfo { + removeWallpaperFilesFromTheme(contact.uiThemes) + } else if case let .group(groupInfo) = chat.chatInfo { + removeWallpaperFilesFromTheme(groupInfo.uiThemes) + } + } + + func removeWallpaperFilesFromAllChats(_ user: User) { + // Currently, only removing everything from currently active user is supported. Inactive users are TODO + if user.userId == currentUser?.userId { + chats.forEach { + removeWallpaperFilesFromChat($0) + } + } + } } struct ShowingInvitation { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 7cd86ef1ef..781ea4bc34 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -298,6 +298,7 @@ struct UserProfilesView: View { private func removeUser(_ user: User, _ delSMPQueues: Bool, viewPwd: String?) async { do { if user.activeUser { + ChatModel.shared.removeWallpaperFilesFromAllChats(user) if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) { try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil) try await deleteUser() @@ -323,6 +324,7 @@ struct UserProfilesView: View { func deleteUser() async throws { try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: viewPwd) + removeWallpaperFilesFromTheme(user.uiThemes) await MainActor.run { withAnimation { m.removeUser(user) } } } } diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index 89cc45c4f5..be43158bc1 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -267,17 +267,26 @@ public func saveWallpaperFile(image: UIImage) -> String? { public func removeWallpaperFile(fileName: String? = nil) { do { - try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach { - if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) } + try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: getWallpaperDirectory().path), includingPropertiesForKeys: nil, options: []).forEach { url in + if url.lastPathComponent == fileName { + try FileManager.default.removeItem(at: url) + } } } catch { - logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)") + logger.error("FileUtils.removeWallpaperFile error: \(error)") } if let fileName { WallpaperType.cachedImages.removeValue(forKey: fileName) } } +public func removeWallpaperFilesFromTheme(_ theme: ThemeModeOverrides?) { + if let theme { + removeWallpaperFile(fileName: theme.light?.wallpaper?.imageFile) + removeWallpaperFile(fileName: theme.dark?.wallpaper?.imageFile) + } +} + public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String { uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath) } 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 96f12b9ce9..d051fc3a53 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 @@ -546,7 +546,11 @@ object ChatModel { } fun removeChat(rhId: Long?, id: String) { - chats.removeAll { it.id == id && it.remoteHostId == rhId } + val i = getChatIndex(rhId, id) + if (i != -1) { + val chat = chats.removeAt(i) + removeWallpaperFilesFromChat(chat) + } } suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 39611361e3..99dda186dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.ThemeModeOverrides import chat.simplex.common.ui.theme.ThemeOverrides import chat.simplex.common.views.chatlist.connectIfOpenedViaUri import chat.simplex.res.MR @@ -316,6 +317,30 @@ fun removeWallpaperFile(fileName: String? = null) { WallpaperType.cachedImages.remove(fileName) } +fun removeWallpaperFilesFromTheme(theme: ThemeModeOverrides?) { + if (theme != null) { + removeWallpaperFile(theme.light?.wallpaper?.imageFile) + removeWallpaperFile(theme.dark?.wallpaper?.imageFile) + } +} + +fun removeWallpaperFilesFromChat(chat: Chat) { + if (chat.chatInfo is ChatInfo.Direct) { + removeWallpaperFilesFromTheme(chat.chatInfo.contact.uiThemes) + } else if (chat.chatInfo is ChatInfo.Group) { + removeWallpaperFilesFromTheme(chat.chatInfo.groupInfo.uiThemes) + } +} + +fun removeWallpaperFilesFromAllChats(user: User) { + // Currently, only removing everything from currently active user is supported. Inactive users are TODO + if (user.userId == chatModel.currentUser.value?.userId) { + chatModel.chats.value.forEach { + removeWallpaperFilesFromChat(it) + } + } +} + fun createTmpFileAndDelete(onCreated: (File) -> T): T { val tmpFile = File(tmpDir, UUID.randomUUID().toString()) tmpFile.deleteOnExit() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index ad732cd699..d7ddb6b950 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -347,6 +347,7 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List, de try { when { user.activeUser -> { + removeWallpaperFilesFromAllChats(user) val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden } if (newActive != null) { m.controller.changeActiveUser_(user.remoteHostId, newActive.userId, null) @@ -366,6 +367,7 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List, de m.controller.apiDeleteUser(user, delSMPQueues, viewPwd) } } + removeWallpaperFilesFromTheme(user.uiThemes) m.removeUser(user) ntfManager.cancelNotificationsForUser(user.userId) } catch (e: Exception) { From 0d44e9f0f550ed4eada52f44726c57f9713b11c0 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 13 Jan 2025 16:51:15 +0000 Subject: [PATCH 75/95] core, ui: clean media filename on forwards (#5522) * core, ui: clean media name on forwards * fix forward tests for new jpg files format --- src/Simplex/Chat.hs | 24 ++++++++++++++++++++++-- tests/ChatTests/Forward.hs | 27 ++++++++++++++------------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ca8ec60c41..e09ed71e57 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -129,7 +129,7 @@ import Simplex.Messaging.Version import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..)) import Simplex.RemoteControl.Types (RCCtrlAddress (..)) import System.Exit (ExitCode, exitSuccess) -import System.FilePath (takeFileName, ()) +import System.FilePath (takeExtension, takeFileName, ()) import qualified System.FilePath as FP import System.IO (Handle, IOMode (..), SeekMode (..), hFlush) import System.Random (randomRIO) @@ -292,6 +292,15 @@ fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEA smallGroupsRcptsMemLimit :: Int smallGroupsRcptsMemLimit = 20 +imageFilePrefix :: String +imageFilePrefix = "IMG_" + +voiceFilePrefix :: String +voiceFilePrefix = "voice_" + +videoFilePrefix :: String +videoFilePrefix = "video_" + logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} @@ -1237,7 +1246,12 @@ processChatCommand' vr = \case ifM (doesFileExist fsFromPath) ( do - fsNewPath <- liftIO $ filesFolder `uniqueCombine` fileName + newFileName <- case mc of + MCImage {} -> liftIO $ generateNewFileName fileName imageFilePrefix + MCVoice {} -> liftIO $ generateNewFileName fileName voiceFilePrefix + MCVideo {} -> liftIO $ generateNewFileName fileName videoFilePrefix + _ -> pure fileName + fsNewPath <- liftIO $ filesFolder `uniqueCombine` newFileName liftIO $ B.writeFile fsNewPath "" -- create empty file encrypt <- chatReadVar encryptLocalFiles cfArgs <- if encrypt then Just <$> (atomically . CF.randomArgs =<< asks random) else pure Nothing @@ -1274,6 +1288,12 @@ processChatCommand' vr = \case when (B.length ch /= chSize') $ throwError $ CF.FTCEFileIOError "encrypting file: unexpected EOF" liftIO . CF.hPut w $ LB.fromStrict ch when (size' > 0) $ copyChunks r w size' + generateNewFileName :: String -> String -> IO String + generateNewFileName fileName prefix = do + currentDate <- liftIO getCurrentTime + let formattedDate = formatTime defaultTimeLocale "%Y%m%d_%H%M%S" currentDate + let ext = takeExtension fileName + pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId APIChatRead chatRef@(ChatRef cType chatId) -> withUser $ \_ -> case cType of diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 3b861a8417..dfc3edfcd1 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -9,9 +9,9 @@ import Control.Concurrent (threadDelay) import qualified Data.ByteString.Char8 as B import Data.List (intercalate) import qualified Data.Text as T -import System.Directory (copyFile, doesFileExist, removeFile) import Simplex.Chat (fixedImagePreview) import Simplex.Chat.Types (ImageData (..)) +import System.Directory (copyFile, doesFileExist, removeFile) import Test.Hspec hiding (it) chatForwardTests :: SpecWith FilePath @@ -740,7 +740,7 @@ testMultiForwardFiles = -- IDs to forward let msgId1 = (read msgIdZero :: Int) + 1 - msgIds = intercalate "," $ map (show . (msgId1 +)) [0..5] + msgIds = intercalate "," $ map (show . (msgId1 +)) [0 .. 5] bob ##> ("/_forward plan @2 " <> msgIds) bob <## "Files can be received: 1, 2, 3, 4" bob <## "5 message(s) out of 6 can be forwarded" @@ -785,8 +785,9 @@ testMultiForwardFiles = bob <## " message without file" bob <# "@cath <- @alice" - bob <## " test_1.jpg" - bob <# "/f @cath test_1.jpg" + + jpgFileName <- T.unpack . T.strip . T.pack <$> getTermLine bob + bob <# ("/f @cath " <> jpgFileName) bob <## "use /fc 5 to cancel sending" bob <# "@cath <- @alice" @@ -808,8 +809,8 @@ testMultiForwardFiles = cath <## " message without file" cath <# "bob> -> forwarded" - cath <## " test_1.jpg" - cath <# "bob> sends file test_1.jpg (136.5 KiB / 139737 bytes)" + cath <## (" " <> jpgFileName) + cath <# ("bob> sends file " <> jpgFileName <> " (136.5 KiB / 139737 bytes)") cath <## "use /fr 1 [/ | ] to receive it" cath <# "bob> -> forwarded" @@ -824,15 +825,15 @@ testMultiForwardFiles = cath <## "" -- file transfer - bob <## "completed uploading file 5 (test_1.jpg) for cath" + bob <## ("completed uploading file 5 (" <> jpgFileName <> ") for cath") bob <## "completed uploading file 6 (test_1.pdf) for cath" cath ##> "/fr 1" cath - <### [ "saving file 1 from bob to test_1.jpg", - "started receiving file 1 (test_1.jpg) from bob" + <### [ ConsoleString $ "saving file 1 from bob to " <> jpgFileName, + ConsoleString $ "started receiving file 1 (" <> jpgFileName <> ") from bob" ] - cath <## "completed receiving file 1 (test_1.jpg) from bob" + cath <## ("completed receiving file 1 (" <> jpgFileName <> ") from bob") cath ##> "/fr 2" cath @@ -841,9 +842,9 @@ testMultiForwardFiles = ] cath <## "completed receiving file 2 (test_1.pdf) from bob" - src1B <- B.readFile "./tests/tmp/bob_app_files/test_1.jpg" + src1B <- B.readFile ("./tests/tmp/bob_app_files/" <> jpgFileName) src1B `shouldBe` dest1 - dest1C <- B.readFile "./tests/tmp/cath_app_files/test_1.jpg" + dest1C <- B.readFile ("./tests/tmp/cath_app_files/" <> jpgFileName) dest1C `shouldBe` src1B src2B <- B.readFile "./tests/tmp/bob_app_files/test_1.pdf" @@ -886,5 +887,5 @@ testMultiForwardFiles = checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do bob ##> "/clear alice" bob <## "alice: all messages are removed locally ONLY" - fwdFileExists <- doesFileExist "./tests/tmp/bob_app_files/test_1.jpg" + fwdFileExists <- doesFileExist ("./tests/tmp/bob_app_files/" <> jpgFileName) fwdFileExists `shouldBe` True From 9cefcb3fe8a934d9d549029c7182c20b5940bead Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 14 Jan 2025 01:56:41 +0700 Subject: [PATCH 76/95] ios: storage breakdown (#5529) * ios: storage breakdown * spaces --- .../Views/UserSettings/DeveloperView.swift | 7 +++ .../Views/UserSettings/StorageView.swift | 56 +++++++++++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 ++ apps/ios/SimpleXChat/FileUtils.swift | 2 +- 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 apps/ios/Shared/Views/UserSettings/StorageView.swift diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 513a6c2708..54454b7cef 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -54,6 +54,13 @@ struct DeveloperView: View { settingsRow("internaldrive", color: theme.colors.secondary) { Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades) } + NavigationLink { + StorageView() + .navigationTitle("Storage") + .navigationBarTitleDisplayMode(.large) + } label: { + settingsRow("internaldrive", color: theme.colors.secondary) { Text("Storage") } + } } header: { Text("Developer options") } diff --git a/apps/ios/Shared/Views/UserSettings/StorageView.swift b/apps/ios/Shared/Views/UserSettings/StorageView.swift new file mode 100644 index 0000000000..2cf63692a7 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/StorageView.swift @@ -0,0 +1,56 @@ +// +// StorageView.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 13.01.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct StorageView: View { + @State var appGroupFiles: [String: Int64] = [:] + @State var documentsFiles: [String: Int64] = [:] + + var body: some View { + ScrollView { + VStack(alignment: .leading) { + directoryView("App group:", appGroupFiles) + if !documentsFiles.isEmpty { + directoryView("Documents:", documentsFiles) + } + } + } + .padding() + .onAppear { + appGroupFiles = traverseFiles(in: getGroupContainerDirectory()) + documentsFiles = traverseFiles(in: getDocumentsDirectory()) + } + } + + @ViewBuilder + private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View { + Text(name).font(.headline) + ForEach(Array(contents), id: \.key) { (key, value) in + Text(key).bold() + Text(" ") + Text("\(ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))") + } + } + + private func traverseFiles(in dir: URL) -> [String: Int64] { + var res: [String: Int64] = [:] + let fm = FileManager.default + do { + if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .fileAllocatedSizeKey]) { + for case let url as URL in enumerator { + let attrs = try url.resourceValues(forKeys: [/*.isDirectoryKey, .fileSizeKey,*/ .fileAllocatedSizeKey]) + let root = String(url.absoluteString.replacingOccurrences(of: dir.absoluteString, with: "").split(separator: "/")[0]) + res[root] = (res[root] ?? 0) + Int64(attrs.fileAllocatedSize ?? 0) + } + } + } catch { + logger.error("Error traversing files: \(error)") + } + return res + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 425ac259cc..70ef22cf49 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -200,6 +200,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; }; 8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; }; 8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; }; + 8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CBC14852D357CDB00BBD901 /* StorageView.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; @@ -548,6 +549,7 @@ 8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = ""; }; 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = ""; }; + 8CBC14852D357CDB00BBD901 /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; @@ -943,6 +945,7 @@ 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */, 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */, 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */, + 8CBC14852D357CDB00BBD901 /* StorageView.swift */, ); path = UserSettings; sourceTree = ""; @@ -1453,6 +1456,7 @@ 8C7F8F0E2C19C0C100D16888 /* ViewModifiers.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */, + 8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */, 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */, 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */, 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 8b0d082aed..2341eb4a4f 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -41,7 +41,7 @@ public func getDocumentsDirectory() -> URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } -func getGroupContainerDirectory() -> URL { +public func getGroupContainerDirectory() -> URL { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)! } From 27481116f014fe8ab5bc2eb2848ece1a2b45c018 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 13 Jan 2025 18:59:33 +0000 Subject: [PATCH 77/95] core: 6.3.0.1 (simplexmq 6.3.0.1) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cabal.project b/cabal.project index 5a60c53b66..5ae5c41937 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 3d4e0b06c04a13555c55c2e0efde56f9f78e7ea1 + tag: dadf6ec5b67ee49b0f18ac4aecdce0d0be26786d source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 54b3443b5b..e3e39e3f47 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3d4e0b06c04a13555c55c2e0efde56f9f78e7ea1" = "0l194fm6kxy54gkyz0lhvba3cxgjdg812qwpjki5kwfmhhliys6q"; + "https://github.com/simplex-chat/simplexmq.git"."dadf6ec5b67ee49b0f18ac4aecdce0d0be26786d" = "0rgnqqkvhgd0a3vncbfx75sqlnd6spgyi2nj303pifdh9hza7k57"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 81858fe9b2..e74d785336 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: 6.3.0.0 +version: 6.3.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 8e23af24e9..a2eaad11fe 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 3, 0, 0] +minRemoteCtrlVersion = AppVersion [6, 3, 0, 1] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 3, 0, 0] +minRemoteHostVersion = AppVersion [6, 3, 0, 1] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version From 748af1fdc28d1200a9454f9c5a79b3c08c5adb80 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 13 Jan 2025 22:31:04 +0000 Subject: [PATCH 78/95] desktop, android: fix group moderation on multi select (#5530) --- .../kotlin/chat/simplex/common/views/chat/ChatView.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 ff954e0f18..7713a2399f 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 @@ -2306,9 +2306,7 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List Date: Tue, 14 Jan 2025 17:41:21 +0000 Subject: [PATCH 79/95] blog: privacy preserving content moderation (#5528) * blog: privacy preserving content moderation * update * update * update * image, links --- apps/ios/SimpleXChat/ChatTypes.swift | 2 +- .../common/views/chat/item/CIFileView.kt | 2 +- ...s-privacy-preserving-content-moderation.md | 126 ++++++++++++++++++ ...k-privacy-preserving-content-moderation.md | 22 --- blog/images/20250114-locked-books.jpg | Bin 0 -> 87845 bytes 5 files changed, 128 insertions(+), 24 deletions(-) create mode 100644 blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md delete mode 100644 blog/20250114-simplex-network-privacy-preserving-content-moderation.md create mode 100644 blog/images/20250114-locked-books.jpg diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b3516e068f..666083ffbd 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -15,7 +15,7 @@ public let CREATE_MEMBER_CONTACT_VERSION = 2 // version to receive reports (MCReport) public let REPORTS_VERSION = 12 -public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-privacy-preserving-content-moderation.html")! +public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption")! public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index dc51e5eec5..542623028a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -236,7 +236,7 @@ fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { } } -val contentModerationPostLink = "https://simplex.chat/blog/20250114-simplex-network-privacy-preserving-content-moderation.html" +val contentModerationPostLink = "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption" fun showContentBlockedAlert(title: String, message: String) { AlertManager.shared.showAlertDialogButtonsColumn(title, text = message, buttons = { diff --git a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md new file mode 100644 index 0000000000..01306e17e4 --- /dev/null +++ b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md @@ -0,0 +1,126 @@ +--- +layout: layouts/article.html +title: "SimpleX network: large groups and privacy preserving content moderation" +date: 2025-01-14 +preview: "How can server operators moderate end-to-end encrypted conversations?\nFile servers can't look inside files – they are securely locked. But if file recipient gives us the keys to some file, we can unlock it and look inside. If it violates conditions of use, we can remove or block this file." +image: images/20250114-locked-books.jpg +permalink: "/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html" +--- + +# SimpleX network: large groups and privacy preserving content moderation + +**Published:** Jan 14, 2025 + + + +Many people believe that it is impossible to moderate and prevent abuse in end-to-end encrypted conversations. This belief is incorrect – there is a way to prevent abuse and distribution of illegal content without any compromises to users privacy and security of end-to-end encryption. + +Anti-privacy lobbyists use this incorrect belief to advocate for scanning of private communications, which not only would fail to prevent abuse, but would make it worse - because our private data will become available to criminals. + +So it's very important to understand how privacy preserving content moderation works, and educate the politicians who you voted for and who is currently in the office that we do not need to compromise privacy and security in any way to substantially reduce online crime and abuse. + +This post answers these questions: +- Why [large groups on SimpleX network](#large-groups-on-simplex-network) don't work well? +- How do we plan to [make them scale](#can-large-groups-scale)? +- How do [group owners prevent abuse](#preventing-abuse-with-anonymous-participation) when people participate anonymously? +- How do server operators [prevent abuse of their servers](#preventing-server-abuse-without-compromising-e2e-encryption) and [how these measures will evolve](#privacy-preserving-content-moderation) without any compromises to privacy and end-to-end encryption? +- Which [privacy and security improvements](#privacy-and-security-improvements-we-plan-this-year) we plan this year? + +## Large groups on SimpleX network + +When we designed groups, we expected them to be used primarily for small groups where people know each other, with not more than 100 or so members. + +But we learnt that people want to participate in public discussions remaining anonymous - it protects their freedom of speech. As an experiment, we are curating a small [directory of the groups](../docs/DIRECTORY.md) that currently has almost 400 public groups, with the largest ones having thousands of members. You can connect to this experimental directory via [SimpleX chat address](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). + +## Can large groups scale? + +Currently the groups are fully decentralized, and every time you send the message to some group your client has to send it to each group member, which is very costly for traffic and battery in large groups. + +We are currently working on the new group architecture when dedicated group members that run their clients on the server or on desktop with good internet connection will re-broadcast messages to all members – these members are "super-peers". We will be offering pre-configured super-peers via the app, and you will be able to use your own super-peers, in case you are hosting a large private group, and to have better control and ownership of the group - e.g., if we decide to remove our super peer from the group it will continue functioning because your super peer continues re-broadcasting messages. + +## Preventing abuse with anonymous participation + +All public discussions are abused by spammers and trolls, whether they are anonymous or not. We have been evolving ability of group owners to moderate conversations by allowing to remove inappropriate and off-topic messages, to block members who send spam, and to make all new members who join their group unable to send messages until approved. + +As support for large groups improves, we expect the attempts to abuse may increase too, unless we add better moderation capabilities in advance. + +v6.3 will add ability of the group members to send reports to the group owners and administrators - the beta version we just released adds ability to manage these reports, so group admins won't miss reports when members start sending them. + +Other features that we plan to add this year to improve moderation: +- message comments - some groups may choose to allow only comments, when ability to send messages is restricted to group owners or admins. +- ability to limit the maximum number of messages the members can send per day. +- ability to pre-moderate messages before they can be seen by all members. +- "knocking" - having a conversation with the new members before they are added to the group. +- sub-groups - smaller conversations with the same members. + +## Preventing server abuse without compromising e2e encryption + +Some categories of content may be prohibited by servers operators. An extreme case would be child sexual abuse materials (CSAM). + +Many people believe that when conversation is end-to-end encrypted, the problem is unsolvable. This incorrect belief is used by unscrupulous lobbyists and politicians who attempt to mandate various types of content scanning under the guise of preventing CSAM distribution. + +We [wrote before](./20240601-protecting-children-safety-requires-e2e-encryption.md) about how such measures not only would fail to solve the problem, but would also make it worse. If our private photos become available to service providers, they will eventually become available to criminals too, and will be used to abuse and exploit the users and their children. + +An absolute majority of CSAM distributed online is publicly accessible. Many large tech companies failed to act and to remove CSAM from their services before it became an epidemic. We see as the most important objective to eliminate the possibility to distribute CSAM from publicly accessible groups, even if it hurts network growth. + +When we receive a user complaint about CSAM shared in any group, we remove the files and in some cases groups from our servers. Our approach to moderation preserves user privacy and security of end-to-end encryption. + +How does it work? Let's go over the process step by step. + +1. A user discovered the link to join the group that distributes CSAM and sent a complaint to our support email address or via the app to [SimpleX Chat team](simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D) contact. + +2. Once we received the link to join the group, we instruct our automated bot to join it. If the complaint is confirmed as valid, the bot sends the information about the files sent in this group to the servers that store this file. + +3. Once the servers receive the file identifiers, they can now block the file. + +File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes – they are securely locked, and sent in chunks, across multiple servers. But if file recipient gives us the keys to some file, we can unlock it and look inside. If it violates conditions of use, we can remove or block this file. + +In this way, the moderation is possible without any content scanning, preserving privacy and security of end-to-end encryption. + +## Privacy preserving content moderation + +Right now, when we act on user complaints, we delete uploaded files or the links to join the groups from our servers, and to the users it looks as if something stopped working. + +We are currently rolling out the change to the servers that would mark these files and group links as blocked, so that users who try to download them or to join blocked groups can see that they were blocked for violating server operator conditions of use. + +Later this year we plan to do more than that: when the client discovers that the uploaded file was blocked, it may, optionally, depending on the information in the blocking record, disable further uploads from the app to the servers of the operator that blocked the file. Also, when the client that tried to receive the file sees that the file is blocked, it may also refuse to receive further files from the same group member via the same servers. + +In this way, servers preserve privacy and security of the users and content, but they are still able to restrict the future actions of the users who violate the conditions of use. + +We discussed this plan with the users, and we really appreciate their feedback. The current plan is quite different from our initial ideas, the users had a real impact on these decisions. Users asked the questions below. + +**Can't users modify their clients code to circumvent these restrictions?** + +Yes, they can, but for this to work both sender and recipient would have to modify their clients, and it's both technically complex, so most users won't do it, and it is also hard to coordinate between users who don't know and don't trust each other. + +So these measures would be effective, even though they can be in theory circumvented, as any restrictions can be. + +**Can't users use other servers?** + +Yes, they can. But in the same way as web browser is not responsible for the content you can access, SimpleX app should not restrict your communications with other servers based on blocking action from just one server. That approach allows different server operators to have different content policies, depending on their jurisdiction and other factors. + +**Wouldn't these measures be abused?** + +While server operators can indeed abuse such restrictions, they also have other ways to disrupt communications, as described in the [threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#simplex-messaging-protocol-server). Any communication system, with or without servers, can be disrupted by its participants and providers. + +But server operators offer their servers because they want them to be used, whether because they expect that it will be profitable in the future or because they want to support decentralized communication for charitable reasons. + +So operators have no reason to abuse users - if they do, users would simply stop using their servers. At the same time, server operators need to have technical means to protect their servers from abuse too, and the planned client-side restrictions would allow it. + +**What additional measures are considered?** + +We published other technical ideas that can be used to prevent distribution of illegal content in [this document](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md). What is important, that none of these measures compromise users' privacy or end-to-end encryption, and they can (and should) only be applied to publicly accessible content that other users complained about. + +We technically cannot, and we won't scan all content. We actively [campaign against any content-scanning proposals](./20240704-future-of-privacy-enforcing-privacy-standards.md), not only because it violates our right to privacy, but also because it would result in huge increase of online crime. + +The belief that it is impossible to moderate conversations when they are e2e encrypted is incorrect. It is possible when users themselves share conversation contents with server operators, in which case the operators can identify and, if necessary, remove files. It is also possible to moderate conversations that users made publicly accessible. + +## Privacy and security improvements we plan this year + +Not only we won't reduce privacy and security, we plan to increase it this year. + +We plan to add: +- quantum-resistant e2e encryption in small groups. +- receiving proxy for files, to protect users IP addresses and other transport metadata. + +We see privacy and security as necessary for online safety, and prevention of abuse. If you don't already use SimpleX network, try it now, and let us know how to make it better. diff --git a/blog/20250114-simplex-network-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-privacy-preserving-content-moderation.md deleted file mode 100644 index a158fd517b..0000000000 --- a/blog/20250114-simplex-network-privacy-preserving-content-moderation.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -layout: layouts/article.html -title: "SimpleX network: privacy preserving content moderation" -date: 2025-01-14 -preview: How network operators prevent distribution of CSAM without compromising users privacy and security. -# image: images/20241218-pub.jpg -# imageWide: true -draft: true -permalink: "/blog/20250114-simplex-network-privacy-preserving-content-moderation.html" ---- - -# SimpleX network: privacy preserving content moderation - -**Will be published:** Jan 14, 2025 - -This blog post will cover our approach to removing CSAM that has: -- NO user identification, thus preserving privacy of the users. -- NO client- or server-side content scanning, thus preserving privacy and security of e2e encryption. - -The current and future content restriction will only be applied based on the users' complaints, and only to the content that can be accessed by server operators via public channels. - -Please read this document: https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md diff --git a/blog/images/20250114-locked-books.jpg b/blog/images/20250114-locked-books.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1963b52df5f2ca09c77c6255731fe852a403400e GIT binary patch literal 87845 zcmeEtXIK+m*KR0+AQBPjB`PW^(xpgAM5GI-h;$-N1*A%e5E2CesS%N?fCxx8(mSDt zUZuA{q)AJtffP>O_xry0oL}cU*ZKXdw0%wX%-(Cw%-U<+>zDN#VPv4E;f#!oOw3GlVLrwDFFD0>>fgfh-@^KDIsGqT`;Yud)HCVVG*|A>sx*+#1F7ve5y^?00$3uiG z^7h`rZ0x*z`~rekuZds3ar3rd%=#~V z7#RKOA0{s5QEcfp8g?n+CCCzMN@{b8T_TEG6yjN~7TqXWXrGIPm z|E5sz|CdJpQRqMVoJ;{Ym>B3MjEM^X0Z>@N)Y$7Y1ESgAT>t>`DP>i^1I%KBv?9(! zZxLW>roWv!jFgLhi}^7W3P?85Oh@djcYssp&Y;4~!enX1-;rJIaI5SLQrs*vjV}g; zhx)@E%PkuD{&uQr){Sh%L786)s2Kh|0gPNf0qk9})dt_NNv9dBc67r3N{wtv^hjw& z?se29DVq3#MklW7Ur zQZH^gBDEK_GV-(I%KQDbxCxpGUGwIgiuPOEu$~XPu?vBeu;M!=x*mhM{Co)#>7Xd-JcWg zh%TQwm)($H97mCapkzd@=4McAQqi2PhO&ww(Xr4EA!mm6ck%*;C7 zjZe?YZ^n+2&_#MPRSEneMuyJ+GjlrN^~#w$NN33FPgno_YjEEP*TpTWAx zj6QaXYv%W;pEhRplb~4pGd5rss@mSbIb$x{@3vN5lpOt<6Y<_hsBHI*KFk8VrxS)} zMkvAsN#j93Tx8}g`YIP>YuK9NbSe<3FrBslWRx3gbiJUTH86E~&{yqQ0&8qIvnW5j zVz(k+c`Hyh^+nSOfbi%9K)UiX1@C$WGv#mquHdF-AJv9^NPawghoj@_>ZdBhtcY6l zz)r>z*@p0E%>UEcakk`zgRRchzov2VOHMZf&S`XRjuaz+%b(xTC$31 zc->?WH%)cF%bDJ9^JQu=W-?xlT(kQ#gLa>`MzSq7R+HWl=G!qqHG?-el&JH_nq_eazq{m5Zig)3V6ciD zrTF)YV%X+VQ6j>vB;t9MSEcS(Cz~MklBq^AYf6W~rytmBcJX=!(+lNExPccW2w4{j zv*q_OJ|F)3_Seuz>t$V}C}|Hp3_3+xF&w|Wy5GP~yjq1gzW@WA0K6Yju3^~F^{PP& zvs36$gPDog=apj8=MF@E`=QNA8pLoDf^9CfV&RCRL|<0D3=}s$6pmc8QT{6I=3n)C zDK|LOs#YQq*+bLW^CgTd9^@?aX#l?t);c8I!B?^prFYOnm@p*&E8n&AL16QuiaR=e zLP%`ReEHkMJ+WL*EhHFv1|dcAzy`Q^vvAy0auTj7cpFv`SRAQ;1S=7(RR$NkFiY#u}o##6EU^aFhq4#}&@HPJr zJljmqp->N8;yy)fd5O5hA3JXL4msqZemM=4xgXOhMzctzY>^y@Q>~AJMWowyt9Lw~ z)tOWV`r#O@MrJ74o&jdFmP#?VG3PXRL-4?|qpHaBLPfyRInFM*y;u_p7}FeilrkGw zs#B(4`{F9u0D;BOed(*&8*m|Fp@A_6X~Ds1WIs?1K{{@~#PHlm&i)xYnJ`L8r%i%> zDPg52#@pqb=g-|bI~JwN^`dZEYTaU&x-}$v=PB-BAH5O2DGzh$qo^--UL8|YPJRJQ zsvjW^;k`m`NlcD#zIFI0Q9aHY?wi5bwICgpI0PGMQYp7Jo6gaS!$pulF6?(SI zA)~!g*ISl(uGgtQTF33OY3%eoL8kc}JB}3%PkrQhca}S5NO@0!QZoFjsMuFh`k`QW z=$U5EXrYb2y+38=u7TjlQiO*-?IY3yO1fMX2^O)Oq1aI49X+^6&pHl)IiE!TPJuDi z&yettU;Q{0cVXa4hV9VvNDKQ~<7+Q7IHDEhN(nmTDXM4nK4#c1bQ|2ndWUTZ%hgAYyPU&GS>@&9X4f zgWSQ*3aXUG(j@Ft&r<5_mo0k_47b08bk7>JAF^3?0$_easv)q?tuLB2f0~mg=p**s zC68(fpZHId?QY> zOLm-&!f@I^m_@%&eu*g_?{O$U0RSy`3Dw6FK`MUgy^881PiH|Z@%}%4?#MjWQib92 z*T0Ik4rA_P9Dt-7n9WnM!2iMZS%lAs7g2m$J3D#@$_3lT=#{-D!`&i-mMZ99dDnVV)zSGRpj8rNN?b z=#cyE;(L<^61vf1$^J4JZDF%HCyNcIVx{N44stV!io&HaCjd>dBQ@~^;I{o1QgH$Z z8#g6ADnK86mW`kXcAxSCJ!m@|M-dZx0u!n@PjGf(P(Q1&@h(l+?5LZh+TM7Je)t#Ym>8u)1bhs3c2l|BTn%1`ApFM^3$>}DwxeLAU)H;$6~ zZPwTQo6_i_$yVCHYKs54dx#}BwSn%k{Pu2xC~cc+&&8% z(5xitgzTRQGB&K&xWe~z_*O}0cqT_ZLZsGf5bCTm+?a2bq=^^ata5?oVv6A0QJ(fcxb!b+^Sx1<$dlaBj+_)i1e$+ ze0N4A=d^5Mx0l+zx)fQ(*UT1bm8W%X-jq|lzM=|K5dRwFnq&|kdls^3xNc4s^SzBc z8KiKB$#yE(CA~a->Rx2MBhK{ugAd4B<-Rwkk83Wc?liMPEr;$;WA)L3a(L%{2@O7v zZdRj;!L(Rs`8eW!wmaJo8q|(wtQZKbN8wS zy#tihC5FI)u*_FdVWDp>?fih$J{J}{0lY&4<+CIuMo4jMLw;(%3iBrqy*h6CUNTEh zAN`9t55Hau$6J>?(6~g!ftqGJ4-}T3ovpjuAyIZVGRD%yOI^k5%6pcJ!m+ORAlI}^ zbs3^IebLvdY``0_^T;hZJ$ z^=Sbnnx!P1U2Q;C>XO^1T+PkRZRlf)0x>(FqCP+^_G~ZH;Va|^&OBt!iUEs|g zdPN*3-l3gvzGIEjhr99tx%B-Q*+3nc!dN$+hMSDoR~R$HD*DD^5RjoI@n!1((1H)u z^$+p3WX;CQ(Sa}YJFF;!@IXj9Qc!DP(}b*IUX+^pgKAHA#zvS!=uk=Xx-TdTW&lEoja)DnYt#Mu^R7LhU+~jrWpKApt><{#I$BPAHn#G^< zkb)LaJVw`SfHu9a@B4+h%LGYm8;4*79fwjO?63k&$@phiQkEfJo3?&-iL;yef1YIW z0o^ngD5!?|JBvaMD!5l}D9{6Bdct9Nvqwy7Y)qb}znxN&XEFzH)@yGc-Ji>L%WKxM zw%%Py>n3%KXK*!Oqf#!PIS<+!7)jc&<)ohdRULn9LJGpWNn;Pz7NMuBt47Z;d3ki6 z=jzZQ+rCF!L>v2)f)*RBT2ns~f$yqxZrzWHQ3spU74ko%)LcAI@bOAS{RHBm^voq7 z7KQXsuV0o(CWiH^_4UpvajkpQ+>3MF6UWFzXUqMSy?B78JK}vsFw6=4Tc6}9X;f) z^>d|1zsg=&Oq0ClTNS~7X0L!G7)AqtQ``rRB;X0)6d%b=I<)c9WN~i9X2PdAB9C2N z?87^NDy|+XX`qwh2>Oi9ecNlJsvRiWe@5;Au>=j@f%Ka^8qTiD*sP3xsNVN{NjT~PEv%+hn6w$X7%NQa5DStIkjx3s1xYyd8t~R1D&A$)f z(7hHKb^^F4TpGKqwo5YWaVTwf=q}XGVcU5?3!{m`pOi%zR|qCd+rFF;a$Q<>1y4H- zn3LVrRn!AF-&+m@S4eMS7(c9pt*?AB6w)fj+0jZlC~_j~>4uxZ-UJCr+0;k)NmHl?2`Ieqs#x;y}lqd?76weQ?C(knbB~ z;Xyaovi4Y;`SP7#h)00A#HY)D*jLSoWGsDRg#>m!h17Wmv}uoFyP)awl~t7Uxj5)1 zCTkXMO%k|;5FvhN*}aRhH@LYUxx*pBG_9tL7vQ%fL-gI`+Xk`tZKl7^C9;LI_QYPl zRo?M9Y_5@I5dsmEy z|4sLo<#+R;ZQa0BAO*EcBT>mJ@Tb(T6c0%2e00FADeX+&2Z0^-Dc%T}(}Fedn^-1* z2vwb18_{5RS0b4SQLlU9tSqEUTRXI*J2+HK21_FcV%$~;84N04310{?jngZyVOn~@ zM0HEUn%d1Gj(zTkAmcnp*F7lbUGb0&rvhXA*;7tL@poy-I!H#|)JdRLZH&sPwdLzK z12_12_9y+k{GGDEVG1u7v$`I+PE%0rg*SYmXKjGzV3vzPCHqVf_SnO>MC8`O0Ck+T-~+lIwKQgqIG&jjz_gp;)3Ls zP+}9~4Pdo!$K3i@W8z++_ORM5zX#)n5FXk~+Piu&BIlv*j<3kg4MR8SnFQlbyok_T z;~~j{e9vk&D81)NP^o6jr%Q~wA66Y8ZLEht)=G&ZBl`Ne-f;pr^K-0MDdi>U{N&7v z9n_Q-iWH<)l)z43xj%;%~ zB^G4(tYkGSA7yzpPaqOVCIl>gpBB6(Jg|J1^i&6s1=KOYlKk2cX>dX6cl#hE^398> zu_>Oi;Dpz^#C0Ymagp#jk?MB%`_=yVnl(-CGMxqVCZ3*uSdwbU>YA^KAe=>fhbe_IUReT7;zahgl__iS67Ynml3|NRk%U; zzvxtZ>?LdbE%7)3m}iku1G)U@cMw+;>1y~!582aV7Df*Y$eK8`$k~ns2UJgG&1Y1d z7uOQM=m|jon4=C-k!7{5z5%uqC^UtFGYG&x9tSqmvPLypXX^1^2xkRLyl!=z;arMu z&Bh7<7l!)Y2w)fiHVHJfBTYf2P|5`docN}V153gWn-jn@Qrc(MVd-qGxM|E}#ygo4 zzzcSqFC!)aGzC4dAPteFU|SyHP{G?4KGVs1=2zJf12^+KR@2@+J}uA=-@TP2sGXN& z=`7PD&>6=$QqA~;z;b#sHsM&xw2kM6w7==Z(F{36hLke^^t07DzMxj5XT~pvUcfGx zPL`?{9)(ilAh=(RY9!bg5(s%Y0d2EKIYLA80 zj{4aTU1Go13_AeU4i{+tj`)9J(svokup+L5YIEK)o--!D&&>=qFvr8jE!4L z2V>=@s@&A-$j}mfo#c$a{Sf_?{H&V4!x7t!XNjlKQ9e4u`_{Khes*0r0SF>tL6DaE z7wxvfUs?sTCiK~iN-*BxKZ3}Dncx5Vr;Gl6d@vnx8RB2YxlW2aHnte1r_Uggoz$z& z=VI`)vF|xeh-)OX-qEDr>$}py(08k?noHl_1`)BvMg_X~oLghVQ~iVeUu>*6)K8xP z*gduOo6%WD>zcKZAc5(T$kAWFpAT<6KJGgx&B&M*5h4_2&kCLG$f>diiRB`5xV}V} za~Sa|WOiK%D#k(htza33a@D;B?_Naj*-?qxmbcZUj#dhIqx?0w`H@uXCP4*)g4r4 zHiYDWBYnTmW=F1ge-swoC-Too&V}cPwwcokXl$_K=?)baiaHU#C{$-tYCe18&$F|o zmuPGJbC_@!a@E3oq~yM;6)JoV63wD~{u>UpWyp2xzd4LaHxACYU_buq)}mF$wZG@V zeX{g5?*THj!NJ35^!SjFwa5Xv-~%fs*YNhV1w1{ks4jm3P@5)qQc;BQUeld)89_(S=f%7tkH01< zdzJnTl7gaQuYo(N+K)1Nl%mx{SR*czwGt;3%vmWckJk2oDz!A;{KPh{ITki4<`y|H zN=J5d(4?ELLirABRd$AsI zv?l)bgYQ2XbH}br`xk&qND9P)!Z%#>1XnvXtKiRJc&m+-Gl z)l*uT`WjCFDXnU^U0T=qZEAmFR~1pk*5-!g1hZfCyM&GqoS{k1ubFunUzisE0F>-% zdgVZeq&24+Z!*K7U27Od_T4R*>47tVuPbnGR}3`f()yy~KZmS|aXjS@od$&#;b zDPEy{q;XN657Q)_2~)Pc^rf;k;i>!mkXpGDz&Uq0Rf0}0AH)o12Cb?8T|aS!*K)Qt z+igXuOND9YO0=q9tleRN46Ycd2sw+m4t1{j*ynM_;Il+Y*|Y~;r!5i z)~Ir-FYoAzau?1~M(tc+J}%myvC+F2tn)nJqrW96AE8JvZr+}@T1eY&0miD>8{RH( z%h&{5$(I3LJFtDMwJw|=d-`I6GI|k1W2axh%=4spGAC(K@8@E?DIs)bqU3jFP)qAl zRSn3;U{+7nQ8?P+C9MJuTI^EO?W!1Sg_b~XaHQKw1s#CP1U}8vEV_m=#cR`BFt3ofY$V~qb_DVlCiKLF4L3Yvw$$ljIT!QJQ#FWVx>z)-MER zZn8TLY)Rc+{gk&*_7`ji&79*QW`u&TO;m;L2Rc?YfEt3pIvvZl@fO_~KC%62Dl*FW zqmn^i@`s$5GkNl_x%1mhINLxA9xi0n1teB3f-`>6-#c;R$BoZNuctA0H96BDvtrv` zk))!hED!x7o=DsP)ylx)d^RspWQnFm6$|ZRf+26EUJVd}&@Uv)zFX^H4@;uW3Z!a+ z77Y2w;@A@a(S)S3rCx}5q9<>^`n~eF)pLu^?@p9$p zN9jZ|CfH|{KUR_2f&N~8rWn~|rVVAjCti^dW%%8l+WT$)Q}gtPubdB9m)xVQ+5Jb3 zk3{sQwGr~j=KC02Sfe1ZGR2oTesFy!E7jY5WpL=T)dOJP7bT`wuaGsgB0K<%Tw?72 zvl3au;F@n1JT90UI|M!NyD(P8m7Eik6fAI>pYkgpRu;OZj$2=FbC!;#o$9Xg^eXauiBuid5WL#weeDz;$XdleenhVA@Kx%k4pkO zTUoC>G{zn5bjd}SfQD#Juj_#cieMC5s&p`Bp7!Es-X-8(_PyZ+L0G^aPvTc3lPIgp z%3hkQB_VXtRptJGKxk=_tdqXOH@FW)S>gC9*_zf45<)tlK5)b27nhSADnEA}NRU2P zq;*UCIJ`y2mi(Hj_iILnZQp+WuJ_;s0Q&^|!3y%H5TRb7y%)Z_qPp$J6(A(mDI^=l zrPJ3htQD(jSM7PhrUUTw1n~2NGIk`AdX8Y+_9aHS-OKr+??t&yWLyKglDmr8?&#}m zh0>IAyNaDN)9Dw;ZOHd0fHK<=LKc4I1BLtY+7ILMDHev!+#`Le4&kehLb}q8+^0&Y zeVPt-w5hu15b1ve8eM6DPU&5#4&9a#9otf4cq0mLKLI>@(AKF zOXb5X`M5v~Ne`}ileRPY>+tFN>Z3-Bk3b_YYtn=6p8{)D4!>I+oQ#dqFg+zSHP<~}bEDj6-W!5>ziQ*-E1%HA9mjMz8ANqE@^VJ`i;Wy2 z_P~fZihWB{erYCX(*@7Y(W^+)fj#Hv06x1*_i6oR>DoVj`MtNP^HeEA6EcYqeQF_gbpYU-e;V~_>t zN)_i9WS-6YUmj9VkqMK*p+bfuXV>?w4(`z>o(XrLrma*EfNg98(e>i)er{>R z*Nhw-&8i2WR-xduu({m5=&}eC>jYF6Dg50FJ@Nyo!V>CBw_7tl`HZ3ucjxLIaHvgI zLdHQjp%#W&cwQ(%(Rec8K3`W|sRl2*H?I*EB>wUDHB+iV}pgNh>=dIufAGXJnuB>zJ+8(w66P9^SF$Cdlh z>0aZ%(1s)@yabAKo*Hh_mgv189OwrLeRGp7qDfXIAA%nl^t3B$@3NX1Y9{>N-enjBQLf z1T{-X$*u7{nl_fQ_f#ZvjqL1iw!_`+Ldm*^o}=+Y8Kd*98NySP$HR8SYr!pN1KOBB zT|0X4%{OA;5K}uF6J%%)7b8|RC4rfV6}8q@HXR4MS}GpX)vtVfwz5TvFzwC4ix_qq zC*0z{T~jgxxu`EeLtTNL;yo zKqGD1UaoiYaaNuhj;eAvqI;j=?-fGa9pia~0g-9p=VtJXQkVM^T5y zZm7gH{}H5!5~V}`j5zIHy2|6}eFyuEqGEQOL8@woazdQv!}t)4#J;bBF+Vfi2QQ?+ zob0-la+O<=XE)bxIiPDGUq%0UcJ6p-4UjW5&N)db=Cq5{+Vqh^!fQD$C%$jaY}}gMhJ6MS>1! z&V)L4NJ~byf!Ikkc#n++0|Ap**T2YcD0js?<6k!aoN8BpY>H6OIP`(vfx-&Dl=h-W zva2vW*A`^nLK!-KD=~-HK5&0nrtMvA zTt}r}4)T{kO4C?~{aHV3Ti>|di8b$>qQ)?kS77qn*Fk!ahMbnF&I2?}gfg{kZ=@O7lg2@IPfdej^Kcy+7Qu%5YG)pg|o zYv&2Te}qGs_J9uHCDn`4hY^K3XoM8T)7& zMXwJC<(uodiBE4NNd^moBU_n5*7wjGXc~J1JzTJ@Br-y2L&(yY;Gml3^ljY=faIE6 z@6Np}A*nlE(RS9}$!cah>IG~5sR~CU(snWiZelvOjI!ypmTrwd&!V60s0edK9{2Lv z1?qDgV>i4m93_^3t;W>^K2EKKyo>c&wlYfD-@)z`s3=9WlZ%*-_#P1lm_fNN$3m8a zo5d5>8sZ5V$+95N5DKQH9*RxpDXo2@;L7r(=3Qh;rpG6nj5gMWpmg4PFRro~lC}H! zA2w1h0nK~hVG@XJ@K+QV0l$NX+pZazdcJX@S384bp}CEG#ITgS3C$w&xibxFsF}GA z#LF^@JaCIVzUxX4rE@w3{qi&Na4S=d{Ukl2e;bXJc&eK4YR;`_`6PbyKh?cvYxnB5{3Y5l44X$Of#%G! zHY!p-b=~d>K8}s`O16@I{UUbNSX%HnXo(-oLo&#sSF%n3-#>6RDfQU{`*`0-yHI}z z1ey&tGLTZdzU%R9@Le;2zvJol3fm|}+R_pT$-OlmOzg_B;d@R_H|MOecogeA)-_Kz z&rpuysqTX^}ZzV&8iUM~@V7jrCY5w=X3ixr04O z0+g8so|!T#6#h_rUDWXkZP~*b>6u|0p5=q4!&mFJJjA&qj-hQkmuCFLoXza#jJeS8 z-mD)_DADRAwzsl8K5WHk4uUVi!6aj{3F+}J&SO*Ho5sOZw3S@Ae`-GJjSRx_JKFBX^v z!?yMgU;cw^U$VN~AYb70hfJ;QBxPKHnXk)*6(u7B&9>vH;`cGm zs=Blg_*uMO(bQSvd+EB}mmic=K6X0N=uYPZ{^F;P3u*R19VRA40 zC=eN(Q6U{(y#1`sFs}S97p*t33N$BjFI`)L5V(+w?bB%;Y0dddeh?J*<fn&=S{W~(c9gf7k+n02BlJc|7~PS4jS=)lWl#gz$Sr$d6dUHmeW zXno-LF%qu&Ys_P#eIR^mY*#-;*R=McZUH2;`sUg?bmbjAW#%USjKB{}`!CMrd}}_{ zm}G~ZaULUT3To%L87FF=V@ULM55TdLZ=tw8q$lu3jLU@~d#gD9E-61z=S`b|9+-YNO z+GOg}f^%;ISiT_hF?rs>blhA_QQ~+@>#DX2Cqzo(fF^141c#J0PZ4M? zZg?X!pd+@eP46$l_^S!<$1KWtq2yq~{ZH1COJ(@8f~Is##_$h|I|J^m!$tdR>bI<( zHaBfaJhfnyO0MyH@t5;G>JmjtP%Q_~U)EtVv$3qA138k--RY=oK z5Dwa{NfLT7{nJ!`oGZ_8Lps}8iKaCMr&InnyPy0T&`2L$KOQ&$w$T}Y3hkd=nO}b* zo?7WbZXrY@{*YI*>fXA@dSb(kk@_1n0n)FRg6N5#(^SC~29%RKo#6j->L_{|@b{{1 z29~>)OIdYOZr!NiQ$OidqI^ZtnrWQ5BWHNzvB_da2&6(%=fmyl=F}PZUra|x6@{hI zMROG@9-YTP=ohEPP%Zkmk?#)^7Pn$?BB)Es?_!~G>ru8H~ z)KbjiY3;n|efPP;TltZUC8#+?mejHMcjR6pi*wdTO`aA}-L;DG<72aqspvQjcM&=} zuox~_^;;TzW3{r@{Xyw$ZesOzwSDc&dkQydy(dVtYd!XxHmbJ~#p|MiRhYN1-1||k z0D72BM43cpXqG2p-Lveci=jVN@_*Le*z)OVEo>I;Fj<<@Sk)1rysV8;Q}wJ}PF3rZ z+73t2-o%fC=MsoKc@`5#g8U&HNcvrKZeQfu%ZsV@F#;yS>C_lRHAR3_61ZTar3TIZ zR9SjXdUHJE)(K$12{fxfQd*Go9??m?;y3GjO|25ry5YKKg~PU;-jKc&I;+>g2l4$K z`y>`G5+t$gmHf0q_m~4LspnmG%uCp+vyWnJgK%ud8)di}pZ5vPK*<2NDp?k$YCWdR z4tz)s9X?|(%K5S0Ue6#q)|XL{fr(@-DiX*AMOki@dF~pzUexBo ziCqNn0333Vh9CkIIE8MRKZkUk6<^p6TP9mu@}~DlWxC_ENOZ5XF@BrP|`@JDyptl-a)L6GMhMi~>AjLu6*6SwG_^U6^ z3CnA<9{*`{y;fw^n}`Ctyl#LJmgWJifxa=uf#}L1OGcE9~jLyH2VEP z4}4cD5bGq>R!dX}T)}RkSbH=xeXgG;b?gyJ;U0%vY%k;+I<@b5`*76XfRsOC>~o8^ zrY6Iy9TR1e*bec+9)~8j`3n&*swVxNFNvCP2FQUlH?8j$aoMDlv={vBhQp*$^{h zbR|49njC004`HLpOiN}5XjdDKMt^ft9~Xg0+h$e34Th7!L z6g_gexO3jmn)#~(n<5MBDLS7f0;{}nD@ptJ>!2S76`%h&8QW)jM`K!gNXMX}BE%&Z zCPd!Q!Ao^=LCX!cKNO2q3(!rWI1OD z`7RtuJOQ*dFp68bnLLc|*+uMqKX++YXryj7vZoVW-Nw z;IgqoTHPzHd%Zxrc7(^Oj8Kf_=~_k{f)SJNK~geUXs`MJaXag}c(o1TXv;}xSZed2 z+)y96TDvlD7w;CNeY0D9`HkBP*ce z`<0(W9j(u{tNQ$OH6W=IzYnzesqj?3dqd4FoT8SFBKqeH_kY=t>8_rMN1zXBD%kin z3Bf<;^AxmmzCo(#&^iy)U*(%nW{|<<%A#Q6UdX7(@<_)T^nDK<-nL*S@ghxA!AoB) zcL$FnHC}bDRij-Uq(JhmC!7y&ym)i|zD2PU0>BaQZnF>31P2g|J2FUml0E}mR(;tw zr-I6DWoApy)tDx*=FwOv5__r!gxR#jq=g{C>yPE{ke^IHym9LStGLLKg434JhN#q7 z?e^ta$aXN~%lH?w>+dU&V@WEa0mJ3?w!cEie9ht(V~@qt7jHY?UFLnLA)vh;myv4} zTs<%aT9lxGNr8*f=bRtE;jI)Ez$86K7&bndfPj;K?qYuhp;{^}g~hhZdH+Ii`Wa&w zH4LtRrVKqr>%wsPUL^CceVh;<^9P_h$My*ivuhr2#HxX>`4+V6?L*$F&g#y?;-Ta5 zcjS74RieG1ci{KxfNpO5Al03q_20MaCb!E~Yp$r;F1z&&-- z2elz<2g_aE)9Z1R^Ix!comG(G^b|%hGar8V$ncDgdmLx9EM{4> ziP6}SnxhM!XK_AYN>Oml@e}Pe08xAk^Yz5Obf_eLoA);?H`gKe4eX(%VCH&-Sv%bp zp}MOnBB?b5CQ&q#vtjY2RqNPGQ$c=I>bRO6kMPnH*_E&7B=*K}(2Xn{XcFCe zP9s;|C3W!LcD@l~Ph!jFL-5~d9Q(1RcBLFC&^R2VPVGveKgBpdTv1VB)u*lMDZBh2 z5;Du=Mf3gq>q*wOmJ5$Od)cAY;0oMhaor<)N-A?PFp+C|HNdcaF9!yyL%mU^vl4n9 zZ?`9bWyXDxymJg!rsnd!0+woowyrt9K2lnPu0rWCvlHnny~>SzhKWYVzpJl->d?BY z2K?(KSXgoUG+(e_Ijr~;e3n4`qF4gm!wHuuVIcG0|!~wsN-&7H?AjTL{t? z_p2gm?|mxOnCmB-2qu?l4?X5xU|kK%+T9XIZ_4fqZhO=j55|17rrZXsD7Y)IPZksesg4EjNOV|%o{8lNr&rw4H;I36 zk*hrNlX~k{`+WW`zQtxd5gmhcnKoHjQ+n^`gD$lfof+?eR5+EW@Fsbo>J%Ze+Tysu zaR4Sz2z132_HuJcghS%k(M&1&=~zznT4FY53D=Qe>)GaM+bfFTZJER)_CkuZX}@fT%gcdo<@JXS>OlPx zwkgH^d>3q{;g?i}ooU@?@zf_75&=vTCjjGIk+rF`q>0?_kn)|}>FVRit|?%zw(OCa z4IM$@4rDEXtUt5q>StjEje}wX%|txBcExJbHUh_6&;pgkbi(K9x$o1tp^85OZX?3y z>M@Iu5KwDvO6@zy;FgsHt&6tlY3Y~w=DW)y+sQ4xXAmjnC0GZ&h!GU%?t}S|&O^nC zTCWEKmhZ6U4(^i1yBv@cvS+w}!B$yL4)|a1H1K(#(A&bEu27pH^lih(hEjxc#I$-J zu@aDFuZN`W10SA!*@9f2>l^->ZXC1DcZYNA?-68ebI2E`^!&*hFqaDg9_6e|!teq; z^v&@lwyeg%J5IuT=p5v;V%dGrl$plc{JT-N>wQ`}kR5em5|6&tXZ(SWpL?RO2>FR* zLv+lufG?8}E7&P&QQYipX`wfgB~~tP*+gs589v)2@gHAlqSa%s6tGqXF=V%FlqcZ4 zY=#S+yXXO&K%DLGY2q>%pqx1Y6r1|^SQ&@yF3Z6B*8YZc`py~+I!w>&wI^#-Fx4D@9*zl?&IM)=XK8O z`Fg&dujkpi;_KtxrCyXZYUc7|8ToOD=JXFxNCZEbzCkOB%4JE_A6R`TTjh9X@i?PR zdy9a-DUEdbVR7i{FOCRf7;6EpJk*_e{3>tRS42>DvKdXUaRsj#XDK_Z*gtRP=~N11 z2QQm&lY<5k7v!O|1~*GIE|2(mEa|xS@^uj5HbnUoa1b!pzVyJM`a!r9#SB$OU*0it znxL^>$arb?$yK~bojKq*XaLxP^0$!SmWkGoGUV%>GnxeWq-smEKy7p%%4lr!g?A9g z{J%QrCuj~bGr5RpxnR}-`&6G>Mo?!Tq^%Bg#NSCkrSv{XNEr7*FB}2qVGH;0$f=Em z@DI@qe?~Nh$2YG;P9wzu(*8|PCw!klu8ke@#53swG%r7~qc@5K-C~i@6`A)(z5s`7 zd;!QvE47Zs;!fkQrwW-S? zpP2^Fx5^)DdU8G>*0)Uof!*h5yK|#WkmoUMftnTTB5`Kec}+izX|$<*xUQh7%5{Xf z1CprQq$15ZF{O!LGu8Dn_7;8*kr8TNZs9vXdy_q>=Yo`pQ?umHM@Ef}RZRB;(Y<_U zJx+qfk5Pz_6fsA|LsASLs zXkd;Et@A4aF3wG`c5(7fEw`;vu1_v@zWi0-(dqM`W%SDi_A78!?WSLB_PUv8DBdid z1Pmp1dO$C}-fg^HI}gm5wRI#T+Kaf6Ver&kamTKUKzx^!9>G&&IIb06j`hp>a-^c zR$6|-jXDB1n)&aRzR&Ov^nqqPK4G{oO}^^;-XIK2?_V`I?BTcIJWjrokp%wu zX}&Lj1V`!jE*pY|TydR@sWp5NQOc_6CP4Pob!&KOw^CEakh&anui4;?6(MDKCkWGK z5RWL9DBUw@^v1K9RcE~Zji#MDWnRN$nb9^W+&ZWDy`4T?1N6dgE6pk(M+*=UIL#mK znnLpL+06`o&!Ig307>1GE#-c4gN%EEmd{LK+mXsA3|Q0po27=XG`$ipj?56m`%`pr zRdtv%z%?kUB~IUl(gL}u8yZunPmesbd+nSg=TN}3s`*~pZ)vG5Agv>F8t7=1rdo+~s_1N_yFZfSdWziiAlcb+~QW3|m8XKO& zTu7F$7q4tgjWz|Wt37f}Kp)qBIp|`xft2{oB+Acyy8hMUKb%d~fs#v($X3M}io@U@ zJY*|XR11G5B>P;H;>9@rOvXt}9gHjtZ#N_I(zpob&gM6qs zP*rlRCEAe0Uhl{AJNhkdA&h>*@_Mv>{~bBySBXl;5E-EA!;gKKq6%xE7s{-vTtC@# z{FsNLeNddg$g4v0WjlKDNX!lQmM&|_Qz4sZJe-pMl9T=n87Uumk*cN=+p{0Z35v#x zfa~R7M*0U~KX7EF6}%Zu1onG2ah}!R$AAFkx?h~R`?5kSsrL8-n!wYoFnb14C5b;H zzH$w9mtmt1sSPK3VoGw@^B z3|2o*oqv5!R^?S5O$oVASJui5maP5lRP#GCei8T8lZ8xzP+JX$&&dIEtjISzr)e5V zJjz~uoPF0>WYkt-bfuv?4QBn~jRXOsBrWF-1u-Hza}y?>U=~~3rHmeb$^Jb55XM(x z_|G-?Pbk~;vk+LsXvSFjmm|UTm`^^8-T2th99Ha|TA`2cVJ9Or#ZRSMeS_#*rwBFj zI$qo!pqg6{`r^5>>=}Ymo)+xGh0F0DRUPtWFv__xZ=!qdF8XlqrEEQLS@C0H#ZXq> zeQb`sN1sQsC*h%$*7XL&w&f?tB5zoJo{0_9mun6kWFcbmz!pduo_|R;J=MBq&G|dA zom56}E}<2r!IOOAz9YqnYf=M8UwypO`QqDlpU9{+l~810gJsKnKUxJr#e>Q;F=gUm z4XcM-t_Xz`)s-}|{oj|B$816T0Yft_qAbY`Gwaejsr!K-1@0mzohbP zyVZ0PbGiQw)S6Af!68QOVWflQ)7}n+!~}Aw1Rl)=GBd9Qz6Z5O(a(TcL2nZ4P1U=P zfPe0>N#6`@?sB82=Kb6gcytTiun(&eV`3}g905BQ=N8fDl+&p^rRQN%)D*yUWzZG=2kvCW^EE*puwXFYQz>5xC4o!qwFj z6rZrGEpCka;M?Ot)^6x8vEGjv2mKUMz!ut*0&6=Hg&7N@;{CS1h zp>Cee6|!B)Y>nl&Yc$_(P`TNgLF2E>ELrKAgZkC>3!Ibt^wedfi%n0rw3?I{+1_YFWu{hgsvtM%34t-<^J8HXpee2=_ zOQbb(yxl0Lyqc@qABHNj1Xy7%lRQ^3IUX=6Lj`ugVLjT^0^Ry`te8YRe zqFlfj|8=wZ?3%?TXT}*Tc6EgFn4qS=md4wI^8Cph8s-5-e6Zv$ai;Qxz_(KrZD$rd zLhKKBammx*ek{c-v(2t%NHQz-t{K~VZK0$kBJUJYOhW)LQ_4v#N259eaJN4 z78skgAErZvH`Hs~)H2rAxT*})5CklnMEV_9Aq&-1T=G61=mv}bajxn2^7<&>rSUxL z943=H{iVb;x^vJ!cZsJtdPr+_m9_`BBJonscina#iV)=oET)RVDsHoQYI|+e{MgPA znJ90&s!$vR&6>Oz(bVXG8iLUSh6nRt#6)t2HI~EjQ^evH?QkrD+tnatp}9c3%Bc*3yCjbnxGLoTf5k@L7P19ArIXT2%_t*3FzC4A?yivZXQi;(kxSSLVPSw<3Kdt!!=KqpK48 zbS=MKvGVG8sEMVMto9E|AR?`9mCQ4?V`@T`N} znxPlfkRCQ`g?THx+Py1eu6jwnI<;(GdrFHw`xTuXy7UWITI6qgv%OrSnf?v{0o66zn}`gEsB#B~%+G zWR84gr9EvY5>7OfX4EXWQd>cs=B@8GSa@(j)fRSiFoVlqYR>PoK3^?SylQmLo@vqnKQiZy=4+;+pQ}I4 zW;59fk+FNZ?Xm!FR9<86(PAF$%ncZsRH{2pk+2%5V3LO1|A%l`+B4`c^E_theBBkA z8RzDD9=06{%Pi@8mlONJv^rHGWxMD_kQzWJgfag4% zNfg?8e!`%EB1<7Vj>uqbcUG1h;(jUJ1rv_1785QCu`VJ8X&N#U7Nzsgus&}*Q;zqaHK*NrzcLZ3;-&e+cV?y)OfpF>3Kt3rE_ zILex-4h3OTY0J1w^CJ+mt@Cma$q3b#r`aoaFU2`N{XqMbC48!n@yU}{QE*v?(V=!OES^&#&x|kl^A!!mJrf3rI1qjSR zLBe`&$bHef^xq3je>W^Y61MOrKT1UyRub_21)6!Vb|*49^r+=>PTh{{T#o62evb~V zyW-kD6G*>bhz1b{?rGyG%i|X_+0(yH7?|dW#fle-&3?--Xqs1S-_W)VY~P`-FvoPw z_54chn0mlfcJ>YbM~zzG%M1s8A*Ix?_JrYm>SU-yV95>a4OP7M%>(1Xy7hm9>h(rt zA6SL%;A7&3#zIzB-%xXrA}a{x9Sjae;(k??dzejx2gM#se67LVcX{8v&ti3}TdM5A&#MFXmmLs47Gj zvi0(&Z8JSg1^f1=4-p%W_-LoDHY0*jSdnrS>5B~MY*n9- zg2N)a!e$OWX;o4R8nw_rX6pEdhCkPSpL*|_0@WKC?zuNsNp1o4TFAkwe9QxJBdHCk zG~HZ)^a=6MED_Pa+lXMNXt@jguNyAEk(x&fc-&mk2D@h?OMUcrfGlVI6+erlpBZD9 zr5XI$Y?J5#`~T{0lb4M>sgIp}-}=wpr0WIY@N#qGo`3ZRomLHEZx>^r-fLfzCkK(Y z;VR^+icHnyE;AdbJw0i!Bp)Jo zV_P%$+ONs&DA|n=R8N!InAs(NHRf#bNB+&R6R~6=?hKyfbE3_Wen5euE#R1&( ztBy}8HZ1*;u$7X(ut*j@po$EZu4hgU#HMTplK49e+Tl*Wgw@ZOl2G&lI3{3I3Dxcc z+aCbexaBT%u+b{0X*nff*MGRK%mX>vF@#+qZ}*6_d>pZah9P5}*IvikS~kGimXJFi zxJ|nsW^gPc23k2GZb=3Qxody3bVtx;Mcp^)AjE&X6o>t~&vT=d7~BZko7BMqu+%Mk z;SHZKz`t#a5?<<|qdjq_$a}@BaQuY9+tXTL!o|ZLy8Ca6k^xjXxSW@;Y@WC!3T$3M zk|qHd$P5sqxuiM(Zep!l(=G51)YqXm*4>w$ue1A!EYjZdp%+#Fkmti2mLLXFg(CRf z8QfCn)6)B;kKLOB-X6+*4g5>9(jiFMIa5E5M+XK=v^55+;P4y2Fs)U0X=j7ay0G91 za+g~!W*(dMrS4}qlGKNL!@gCo?4wJ2g(8OCA5`7AO9#28P!rmnL@cs;u66Dl_Phr2 zlk@hH=cMX=*RP1DW5@F3&%_%ftsHDmfottP#U0+BLgODIF3=3Xdzyo|UAoo`x$rhM zE)$6AlD=}D_6CosadGpS<<0;C997~Ohu=>CCfKj)mkbhdV>>_DyZKX{CC-k9y=gh+ z;;?dYt(ME`-#2VnRqVZhIqP#FhZhR|2lFiRPw|u${9*2Oc>NpY(heOhlib45uBr%S z%=y=bjSBV_6M1+h8+2ZN<1sN9( za;9mbPjLZrp~<*)C)zLo^DCn{ft||EbeZ@ahf+JY(nBe_6P*L*>c#^BPgw;v+S>~$ zO;v3l5{DixKDh-`Ibl#xog(kJRt1~KA%;Ato@)&P0Qa4j0o&G?d1M0UK53wbL~lIk zRdehTl$#wvY{tf`Gv|2OoUekg^Q$vW`a=7eLnsV{wK%S>s7(fYk zIxhdnjAS#nIJM_!b`Oj|(PZGE8QzV?!MjjMW(3Y}6_8ID{_N(F;;MXA6n&!vs|!%h zS!UZEJ_M?p?odFgF0VgZ<;yPQo2$G{&K{?w8ar8KN>ZJE z4uM-xhj7ID9WPE~WdbuP2=i6GDLZzw2a;VF{N5rbuhSt<+rEgtDA@ym_u#>3RVY?q z)=20~4D&#I>Ow~|wv6pxN{1=W`K&(Q?8WellHl}|-;0<)Pb>YJK0L;HpbJSFur?{d zPnl0^D#>Y!o=-YrI18BcI3n-F^C(}xv_dR>A~;l`9>7^~gP>-c^ZY?q;IT>2jRi+9 ziut`Y>plA%z2s9q|G7t{Uov%ePkef_H>k<+^`y>BNZ4qFqPF+KF0#_g2aJDdM#{)s z%L$wo?pXB3ux z56dgMe0&o#twRyIi6E4(B0&P}BC+{=O^rw~39@8J9#70O?_j1iK7@&w0V3L%cGGiT z&!R=3iBiuSA<~mO!a*YG~>(2Ax5%SiG6U>nIF-LEng$mklEnGS!NW`Z8CfmcE)-I z2wEl(I|FBSW**vh*Mn`%G^CUN2Pj1TKYp_0^{cfc=!GWsO$h1AIx>B%gVHz`JGoDi z7V+J%IZIHg_H(AZ=KSXYJ7u#)XmLeI`o%dDb|867jUw1)lje_Q0*k%}%gyaKEjs;0 zc4R+SGZ_q%`c#+eBWB*Cv-j=dyCQ{@4uz8Tnq!5wjJWqTlJ=8RN8Qz~+hlrTiDOw` z#kO9>X}nUz5<_I5&V$77D#GKht#j{Y|6{;-t>3B%j9r|b$v82YZPh10*E1A4|7y^?U^_oB3;MZ}|8+XPUN_>aR80YBB^iHD7{ZYmD>NR1j&@K{ zSe-T*mf)O?oO>cc-6EM_C)6(6t+F%#k1dbKDE(l|@7}>E3AR;M3Y*h0zk9ftioF09 zPP`LPg9M`nq{vd{gSkJS?(H@^vs0N3+L|3JI+yF&JuaJy?V*1O9odyE{q=ito?oN3 z8_i67DBFwY_xc`Gx7yrPv?=qrU)|=aqxV`X(h@oj0yeSvXNh=tCp>B}|6j^a^R=GY zBV)B}#8Vca=G+neW*Gc}HH{QIVgbDxI${LjMg8`AoM8>jxp0v!Qx{oUh>4^~Bl z*1z2xg>k%1HPfuDl17y>@0V%;R{_DHVgr~fh8+>IN2{ zd8Z?UcFMJ1+On0n1;P79{v-0I_>S*HLwEa}$bX85Ce}boN6P*oHuLFuK&$Y%CF3tY zC;c7CIY&3@n^^vZX7)P+I0z7-l&2@FPBJ`OuG!PE| z;|QBD9lR-{aKG>P!eDO}!U6j89i5v`>x5x+ZqW~@pG8L7*>s0*jR%@llLMGhdQWRb zdM=nH(5yTpTKAU%=^joY|0w5Z=fTuN)t)y9y@e1BG9u2kH!#?zS&leY&VwmPFXvec z?Y7Ofh*sA4fycL7kCTE8_RZ;`8hm1H2IIyH(Ds+m6tP3ioWBG<`eOItHyCL}{!rjZ z$a=P_K>LJ2^3`)LfC8S9kywdGRRg777>LD7m;E&Ehwz0{m#&6@(altGSt0Gd4hCi1 z#74Fk+dtjtubPqkFKE!Yd^GUtKxKLl_tYxW&Z@+lQ3nCX%U`LTjiA@Zdk85%sg)$y zx2$3L_7jvS2}ZrBxN!eGD(^c;x4}SxxLx+X1;?*9Exl)a$AZ=JN4c@(W28Q?p(H_j z42+Toh!yi6jDqU5ez+G+3$Hb=b?9aUmyI1>s{uqGH))JW1Amz{Mu4CUq6IoK4DU<3 zl!e`UaBBm}*6xyp{dbqDcDEKvRCMh5k@T!VWcttBJu~gU8NX5O6NsC@n9W(D_g{ov z(`*%6NB6Kl&(?DH2}4}21hvU6UCRfntRz@O0sZ_!1!?NYi@>lV;?}nd)d26nt!rj4 z+j@OWXa0Ba9VCwXc6WW^WT$c^4%jbe_1>rdb5-q_ewU<~4Pw)k{VXEk|0_R)*F5~X1#!Lx7f zH~7Eof{71C%7(Tpp&9$Buj^Aj61p^RzqL7QAT)aT>+Yq|_}~jmb>i#?Y6%}Bvkr`( zVvigbAKXBSkKXF?fZeE)@Cn0CCY34xhZgg8{I6d$NsOquK*m8@-_W+W19;WgwG@<= z`(Q|nG8TKyF@`F@*mTDscTG*LJyhfyGRgVvMV;$ByBAdc#2(uoj|fVc><89O4ulB4NKfSBtxHPKxO1sKZSE<`j2Wq0VA)JN4FGjcKpW z=SB}`gec22)(wcHO_wcarX6$rG0M@gTHk$&d(BhATBwQN^8TQ#)=Y@0h(wcz=jz#K z1}XXTCP`=XWw|*;;UxMRgyEf;|w(Myfa|ZV09DHEd zk$$(eM2KKcL1$Z`DpI9~szd(LChHq=EVHxOcsDr|t5bot)L++&5b(7%hKHL2#uG^<$}Q zG_E1|j^NcvLaxTHf7rewHFf4ZIm~B~bipuh5kq&RtQ@}RH+w)&M_lxC=Xy~|98JG_ z!ce1w>1uvo`7X$yWcK2b;;9n`h6ix#l{mBgE2yt=vmI1AD=7a&|0#abB z*`KuCa>0wLCWE>FuG;A~1ysvYz_4y{`X~dxFK;i}|Hh&v=&%E>Pi`nX1<{#WR z4X!K2i5gP4=#ENSGvbd<7@9=$K!H6NdI8lQc{CSFtZjV#4-(TQ(7d+lOvvx*N{sOog&LWsnWteq6_IeKdJ? z7CP9BgV1j0$vpNWKLk;-H2DfH6t)1u*3egEo0sQ48gM$*hZEdmHx9#2*UhFg`P}^9{u(2D6=pvz?y!j}%{~D@~ zZIAXrR9QW-I_*WvHm1=O7QGG2ELB2#pD@^H3F;?q&e@Z= zlVXBcSsAfZ{T}Ak%XM{2hQ;eCO{TL$y-?FWuivV=cqxk=0j;Yw@D?W6F}FLH_3gJ7 zrI|8poh`8jzoB%;f;AqxCT~9g77gAw=q}KC{BXSK_s0jK7VZ}rR8Fy+T|4MQsFCLkcN=A8^vmmv zv<28-DEuLfa#f9_gVd{1rTQIw zc`rA%^YJ_X^tnGf`_jq`uO0I9ZJGELmxAx?5Vdzs$6f!U5j1$3b3nhyDSY_ib5wge zR9C6n=CjS;y6%H1wt%F$#<^FHCWrqy1&=KwU+n!`KOW7Rac4m=GE;^Ys&)_9Zgrx1 z9h?Uhhu{Ay^tW-1y};KFw*sX!*a}*aW+{iwdQDbR_!*H^CS+ST*OS30Y|@IA#c}^0aGuAN^F*&?6Y3x0q$dZqw#ZbE95+qCs_1wRx!;; zf08e1C=Y({XS0?j3t#;Kl3DU(G_-YYJPV(PI@$~jW(j-_q083^-3Q9eC>3i8>%Yw{ ztvq*k+=7V65~uK}FVoH10{t5{oZUd1l)($G<6s|=9M&~!)|W(frP)jIilwCZ8V@<-F#>w5=9TYmD7D+ zb{D3Bb4QHv8X=Gg=VH)TQ=BduzZgzKif^f|?z?qohk|JfW15pP-YRRz${^3$H3);vBQshvz-FEZp;L6X2?BsQNF?))DG#l9{Nc z+2m^ze+sNPG~6CfGTjorq0F$)of-Cy(nJ(bJ^M#e#WcoYpdKMKQt*wELrOhcLB@F2 zyx+`5nBXVtH=7brCBdht+U9BSs7FeG5?IpA$u9(ew@F9agDGY7<@ze)wO!moa-+t@ zlI<*!V*8pLpMdfpXQV|`V8kOSleR%i6K}a5#T1^ zG10cGYtAnmj!^EBJl1NzdqB&~9JjZmEbJMN3k9Jjxc+^b6U(7&?s}1&*8)-9K#3+% z%QKpsVw&68dw*fV0Nwjp+}*`ub^m4&DxtMk?Xkagy9y9i4Uz}Sb0{! zHYULAXS+Z5Y*OKBjmS~_M?ZFzeARDAmX5JTdH-Mjp16z8zHwxoFjQ5#(wzjj^-VUH z@@)Ha2f}!^ec2g`42e0~&aVnjeYvXP-ls3)vn7-WP+&yc;~Ovs265}zs*}9QnWcT=|!)c`-MN))-@-XJ{?fu^;bO|0;klOAMf(Pa&JM2Y?98+g)lpY4e}H zGe8W0D#S6em8qu}zr(wvA{8%SO9oZrjhcVLzYv%(nMks5QUz$*WI*_Dx5c%bZ|f+v zISTBR*`@p;H_IRvn;`4_*s?^=Uz#m2XXw9z^}DgUTK5CGB@mCrPZ(m&5Lv%EWPv(N ziP;ZD$K!iD-vWZq1`(!d>8uZaj_?BF)>jnvKN{1D3d^k1uQVVZ0#*Okp^8z?Ot-6N z;$Ro8*}p4p_`0k=L*&sUoZ^!|ych4bOQ3mz;uf+mz{2JsOqN1-XHdMcfXc{`<%Ql? zu<<0!ZFoA2Xrx;mF>g~#V3Y;3Y~1CA4jNm4P_B8-TWBj!ksO4B9JiRx)_W5K3TaDN z7Y6|~mV@3J_!gUg8XwhsT?s1hx6-lpm7a8I@x>7u(+h1nf|`HF9I^_aK3v_Cf2v-z zW^iFmZqD(yd57|k}XTfiK>O6G6g#WY<&Zc>-E|;z~ zIrIA-G*&JAgdydv2%MgFY$p5qKQ%F=9J1FOME}7mV|}Cjv~tL|)UqoEY!i3gson9w z-HZ+EGlsllc$^L%P7A0g71>=F+(PlanmcL>p*z`(GoqhNv^=yyPIFrwF^|XnDPJ2q zvMJYrJwZM;PjM-!gl=}g? zoybrko@-%Q`p2M7HngBbZYL^hUt=z!Ah3)AeCj2CpEdZnxm7#8`;kCTS~Ej%I3%ur z@|t>CuiKr>k-l5qNw=swVTj5h=&20UwRqwd;I_zCHMO%46vhD_4qT{3d92Ug99-}g zDMmWRG^>X$H+TKWdStbXRv?uJr>r7#abba+L0S#$*ZpPMAq4oXjQnw+vVAp+ka9QTKytv;xc@D~;xxcM69l7r;|ax?7n zn0b>~$5;yU_JoBq4GOMt<|yk!3|j6lUA}#dw>|`WtT+5uBgzLtXKKWC)D+~B*;(_1 zCGMn%jeCsDSt$>xoxfDUaNE@yGn=>!D+wB)nv;{Er-L{~4wU(B4(&UD!wgLojFeIN zb&F_(&KR3+s)K*VN)pwUJc#@RtFn(#)4RRh*|B5*YB4X9Bd~*fl|{N7$Wev6hXP}D ze$59}Ckz7QXmc&)^!ct2-UM*N#lbQiTMV_Tu7tK=@|A$3H9N1(UkxJrL64W(4R8;N zh_^CC6B41lv9(8sfC+s9*$QqI8*BkWj}5XTX}xW%Q`0TI!;v^E!8dvZG9n; z=v^;;#RHTGoQjgVMu{jrR_p1 zIA=3QWh>Ay-?7LjUHo?~b~Xm}XYB9n73`F%7xy7#%l(b5i^9Iwf5vbja>(7!%6I3x zYSGf%v}eKBO+jaqmjEa^T!_epC3#!WBz4BK>s`Cl>q;fG(p*|tl+i|dno^~G)EjOI zAvpbKEF{^Iu3w)uLO*s%f#E)NXFo1TG1WlSqnJ0UWT7{5#jg^x?e_K`Vs+vpx0$}e zVSSoK?~(554CBFX`Pf@&1=q9O#(Wmm63lC*KOEfLXM3X+W|XSaHFx`hdJRaDT5PCk zmdvjE$Z2~}|GF`XRolvk6lh9~SJXantu>0EFN(o(#a;BDe~{vs+S7ESgk$$}{-k|O z%_@x^#cC)-OtL$!(+54WV%(n@#_hDBH9z;(ED5}6uvAz2p%C}ydYO?NqW?RpaLkbO z^df%Zozm_Q#Y{R^4n6+Zy?Pbgb>PO1kYxPNe}+ygxh2sNS# zkA2XqDF7R-nrq!gJ}HD5_Ztw>AVEhlKQNp!IwEE!FARFlDMOQxT5zIUdD$?{^{0EJ zGh}vSPiSPH8#D8bBw$B;M1c-TM^eV_Xfk;0LTT=W36z5$BKDebpbs2f!m`5_8F{Bv zhTBdU*>XAm{(n_J)YB@tGHAg?9bbY8yI-3mz1S7WUE|P&Fc_I!n)c}FWDY@E$s1Zw z{O%hml|#GR1-i#w@=L=12#vjf)GSecWcM2u>`>_;^%cC5Xdpoyn*R3%8B)Ui5A1kv zRvp7bR@#E4E&){(HxPmA)mtLpPi85SWT40AfU4F=r!NN{IaG2MS-_peHOfJf9lbK8#iBiQw4wvpvRQ%uuHCcqkVio9QJ%ZW)Q4^4dvXjWpPhbeUz# z`AaFTZ|2#WD)zaLrKvoA{xoCi^B@J{eqJ<&YwfLH7ejr_3Bwimk68wz^4ro!Um!gg zOYoVpda*A4>o=|Mw3ILnCE8d7**R4H!`J6`MWG48@pA~)ml=<5LpS-Wzazc`Wc7sf4cZa3J$hS@y zVqoO}gUv*d%bw9)n7|k*?dQ}(FWuKE&^zBWR=DZ<;0@U(bc4f>WlD!x$muE`lU#Hz z`G#}JpT&YHO4MpMe%gB*5%Kx{04i?-Bt`b1QAbQJh{>xmMhp!G&6TMIJK+ zdqzsvfM4*_-`YdfA9-k=_5{`fj(aBW39&iA=7wPvbjO$16MSVhyaNizR|pi-2{r(W6pEN(L-POJ;IdB*)_U2R)8_*{+D7#k~EQKZ!cw{`>dn-(RUfsh2fl>6~T>@FHI zIKHkSoW2P@Uh&A&2H@4H8K*WrF%G}sYd;8wR0jTd6$pilUw_m1S@mtdH#q2cgZ58? z{uJQzyFRqA^FBy~h#xugj_muevXVvh+zTB{4)S{*jf-_52+50ag@>mz`u+X=2+!&i zX;{x!JZ=SB6GY|+Zcf<`yP1`LN&ySL%Zuo2!@X&1cxth5y~vGI@*gg0z=m$sKr=Ti z&I?;orv||8fh`Lr!l2*Sl-qJj6}eDda|_8KYiJR(7Kh!j);o5$N*<=1+C%|cO?w0oP%yLr)>umSIZnEhzrtVAvdruhzn?X-*1l2M2k&-mt1s8*k8g0Ag zh`c_pt<|eBHK0glZy?n$d{UdT4Mw#!a8sM)1h7buxU8e}4kzy;{1c(CA>R9Fg5PZ6C`Qd(u>&SVj_4ZOS~e(kOyPYp zu_GE@aXfbL66{6lk^{jI$8EM3`OOSDfiNsS%l1s`>u69>h3z#Peb>x+V7nNM;sE&) zW9v-}x|aTRswSl0-Sn07<-E^IFGJRhZ?1?8Vjjap?!?0X^W zlJ;W^hfSJxpJ6$zo#9pS)v%!u>vdmkF8iLjsvjw7^Hg0h?IwGSwFUI>)Kt6EbR_oe z4^u0+g5(`^@QO?rm4gQu-RdB(KXeIPr*weik;e~Xty^ELW`*x1;<(2^TaNEkG9?O; zWY*GY2CtC}`LqhrcdDdb%f39lA@kSaU8&hPqm7na4W1;a#^6^%&Mt8nLfeWhco+)01>|uxf2H`)`8V z&D~=V(7x*lBJHc+@Zq{UTG^+;A1!sCb4l*7!#nPNw&&LK`d_?pBZ;9dLrQDY&?Qsv z4Y`zS{n9%s?BC<6?4L(Xk2{RT2aksJ9?OVDt!Agleck}H?Zl>663;9YF5X4WkoulQ zUB%|AJMLbeWs>pZ9zIk*x9R2MCR|nJyw%yi0#LX|gPxlTNjv*S!je1`94&gp+!_wU z`L@2ZY$2p>YRGs$W!^TNnwAn@!diM1v0oo2&y_%hMky;N_R-w}j@E&8mm!AwOs+L+pj|R|eF9C3k>b5sGdpPmcHJl+gmp5HG3Nz_>I zl$sA{`V#XFO37zjxIsA-mRo- z$tbK@u;+Qq`}AltA$O(hOsISIc1T}w)K7X=h3$ipm5pV!FqBrpUBEum&!Iad zBYR249e#}hRar;Va|lHr?;Vxu2#qVDw_pHUD$&AX?S$b!gyhDTfeP7bHY#% zvIskBfrAf&UQ~zv>fV1x<$Y9C~`w$dx9W$WD6Qe7s8?RzFC$|3>XgV+-aXyG%7IwXQgqOrjO1q?0cK6Cfr_5nV*2F#2j#FF(89!E zA&+()R^#C}`W6(RE?6@iA0^E{axb5OJrcsynz6aReNW#g`Z4!j?AUAYAm;H$UjL6a0- z+--2p8<7R*9{XXjlPrkSmdxoh6(bF0 zn*T{YtX`KB(OY3Gc$o@p!Ct~n!`e|3kQNQzq|kU*UEyS{=q z=yYLi8Fjo}sa%;~spohq>QY&+P zNFz4Sjk4%Ica)voO-(*A3yRmvE_xOCzLRm<7_Ka%bjt)i>*?`qh!fAmdFr;2k751cv*} z<9Fw|{f*<2NQTpNi7yqWUDeoCOryGV&Sixcx?c@JSq#T&&)7_xMp?&9yBa6ljd$NK zcx9cptnS#6z{!5|ilEY(b$rqQRtg6tQyj08?SqQ?Pr<(q6=EWO8V_?y93b&@)RcxG z*yRVFXAEM*wML1w1epaspZ!uRHfeKfXns?%r7`*5oF+gx$lqf%x) zrKZ>~NFIsCvd@>2T~str{qV@6580T?U7kLUlIxEYa_}lXiuF5Wp#W7*)*s9+*ByQl}VIUp-;zcE(RR4%#Bug`?dR3gT123-_WDUQMZr6yW1CD2T!TGgO43WaL=RTuuH!E z>s~ktpEv7!*-!mQLXz);`(FM59y_%4(o`hgp$K#2D-9?~=xVnN9Fq39{>LAW4sg^+ zY2db$k9=fMOYm?E(OeZHw!i^?TMsz>?8ecZ?aN=VsN`pKUf3Y!3~atK1!hmSJ)Ml* z{*KVS_HNY7!9Q!F8zjb1gywEq$@SsHqip2b|8aEHaZSEo8wCN22}s9OP-29Dgw!m$ z1clL)P)fRMQ;?1c2nZrm>6~;k=`iT-ZrF%1SbX2-_x{1Ze71YfbDwjra|Pf4s?vt{ zdFOYPTryY6dtI)F4&5@67ec{bIwe-xQ8zI|UQLkf9@c-8j(1mmksups^(4%-whH(N zhRjovcZRKrip6|n5pTXLKafpNDzQP4{jeDZ7uy$JoA7^C^2KbQQm?lR-z2LO%P^Ys zq|htfw^93j%JB>5VlXx>CprK1WW)X+*hcKr^9&yVmo^`lmRdIegTuM!CJmx(^i@rb zk#e%?zTK$&$FqMf1GJ=XWdR5zvpgS*)cKocMU(|Nv(3?=Gvqhq41}*bCfq`5w}h41 za2Wg#-N<1Y*fqNgJ{+n#t0=qn=5Po`w6ilSyOQK_abm)Yzm4OtT&FXgexkjO;}$Z2 z8rk?IB*twGdNct5%%926TlFAIWn&|}u93isy2nKnrEbM(tt9~PJ^*7R#T7t}S($dx z>-N0o$y=m0(bF2D6RJ%{)nkBT2L8-j!znegNy`S)N0w-%G;qpH3VH`Fy>pnWdULcK zO!LV@>f|cLWv0K#{01uN+=TDYFpGptT7_P7P3Va>c}f;F_<4*u&I8&paiS3Q#scvk zs)NU<32xqi9pmBaJWXMPp7vk)k47H#VH_BmPj3f4qoz{xkw1IF(g7W!H_pG!v~8qW z#7->xuh>-)iao}G`?vz9u<=$Bi#O3ke??4rP_sSO25rH@E`UUJtl++CO zvXKPyg4|o7+iJ)d_2n5$Y|p~~R>2G>7;we1oQ@snoGHO+u({RgTtd&X z5^}eTf?HKyf-XuG(w-Sd+uYwK{1oP_uC_Z?zHn}0$#SO3DQ~rU^m>JoFNpY@KmJ4K z>0h#0kXSR<-SH=9RZ3JdHY!k{>ki_1x{);n(Z2V)(zxzHb(?^%`B-vVqf;sXx^!vn zxU6nBGh}#Uurd>X2as{*v)KZ~isew-#RG9Kn}HgS#|vz3y(A`{ynXrkg^9(G1w4~S znw$xZe;?T;ml$fF=kG%oK~Y$)$AC2}iLpFDWc$h?28L5wiq@X6N4=ew9LM+yt?=Y%{EtGo@)Nc@H(Sb0T^V9$ zJO9ar$w1nmBLCtF2&zP5+`i9Y-UwOA_6XxCHW*_+K&ehD*-A!?I54WXIm{xj969$j zfIlx6{53JU_fqp$sxtgxx6Fz*$4{1oUcMW`>4k8KDD9y4X7ddWn)} z8(cy=mSbO&{&|<(wh-BXpS>jo`Eg>FU8qcA#9XQb1rs8LD<5_p46x8-)s7p~^O*XN zX7CYaFWL9Lg@uLQyDJB82?KYqe+Q^nlN_`BZ+?dr#J2i(OOQGkfr2eYf=wm_{xDgo>F zg{X?sc?DvgCzqQ(58gn{@xd*@z*m^a(X4xLQPOu$-~*0 zE?a4orm(H(0z+P~Q2&o^o^qK?>);W)SK(RSUD|7pZdx)96lj+SPM$et>K>Oim}aZd zSU7b%wR(*sGZLAEk5p4rqQQXCfWI;4R+r^V@rX^)YMKq9;p%S&b78YU#H;u5nbMs< zp5<~UY(Y1tH53SiwzRhh2e4ExPjfG>eL3SrykQsnAq^Lyz(b0IL2yOK-rhZc79--~CC zAp%In0S?^PhBwI$vP*L+zI@h7cb+87qIChlJ@yX!bYG%O)~i*2K8V26xl3n`=Mm8U zI%VA-0b;&Pc{x{H{uLjX`Ke}qZFr!l!vn4OU9fE=LA>|zCx5+lsKzaPP6tb}R;~=`DN!FE z%=7i86fE^}KW^0V+97%%%j~wi|2*K+pG2pex}riCVLFREahej=E~6mc#|utd?dYA@ ze=eG~p(euglhe0FnP&VW!T)Np;#)I%Oojdr1+SGwZ194Ouw9IIc%N9K7-r!H zr5JB2Z2CRTOZe>&Ke-g8mDU1U#xZm`2DH@yg&J+X^j16NqoC!LV}rO~pqo$NVXisQ zf6bzClwzQOALIvsqw6k(J+KRJN*c!&VTtl#G}#x3;Sj>uZu);TcLgzP>6UcOoGY2s zVqlm#_9r^wFZbMXVFwy#!RgjmV8C>6==99O`cQ%T{Vm}I6!&RfRoY|%MLK{Ee_7hp z8g=Vt=>JGfDuWvL;^}`(cF!PJ0hXA{v;J+4gNcoJ@UJU4bIKa3e+8FbJ&+dq1`(L{ z^jx^?s{nK=Z75};j#ABrDoCzv%<5NcLP}X=I`7q1CTPHg!NAm{JtR(-WY$O0U1hrV zM+!h`ovDGq*jvY85pu!R4Pi*|drP?wB{)+fk}6ZQ7mkM20m>d`gO+-=$g`@QkXDiv zX9qL^-OxNw*@K?ZQ|Z^FgCugk>38byk(FbkHmn3>9m|83>5_jNpj5Ku;yyR!gvY;U z55RmToB9W&?O|syLx;Va121A1_FuI?1;&~$F}m1GqEZ#@$iSX41ld;K_*5f@Y=`GM zi%QQkFCi%C6!g?tr;=lc+zudrevVY* zXf9w9fbEUN=hr|L!bN8at(b0)1fwQoE9lI|a{i;Sy#-awxBF;n{sVsmT7{SZ>wQ)& zG${_12Vwjd5Bk}^@jq3vK4b$u9rx0TXK_47;TEW>ZX0?&cKpcFi3{8bI>N+5-CgZS z9E*NA*Dmmy0{P&r*ol2@mQh}#iE)h@xZ{Mj#cIH|@|8zfHu z006k#e^GJ~TtsQLIeyG?g2ZK-1FojH0P;;>@P3YEt-rtR7Jks1B)RNTz&jB6*Ok*K zDhaOt;n9Oh5Wg<{Q{uQ<@d1u395ior1dtlQxU!jX_Csq0F#h0eAynYy-t8WOcpUNy zm&GKFl(mBezuac>vNv0ISs_N#h7p^FEPJz~Lx6+2oR~L$C*CIFP2i`EdTlCh2W+6h z``}(~E62;eSCxP>P@zXI(jX(AipU%(QLh9qui^@J_m;KTD&mamz_{D7KoB8P*;&30 zHvEcGythix>O#7Lah9m)gQbC~!~JoF=?QPm$rI)>gwZqE{YcpKlM8N$Kb>YA` zYav|4O3O!1UP>IFvw6huh@N39Hd5Afzidh6Rupo>L?Y@7Z+O91iGM2e zG*n}{4}s}*;?zG+acs+@P&tbm-QP=1R8Lv$LuHJ~Pug`Ygz!Is3FbnXj_ITNn)u^L zm7c@W67;-t1wAKY>Bbpo&2)5=A(}d*s$4^Kz=e0Usy5niJ2vY5={Mlb1vr8CA> zUlFq2vQimhL;PM&sO$*Z$TS;50(R?ROJrO3h=re^*pb)&aoSPt^LL&$yaSpy z5zOLIySZ+9I$=7r{x@}Bkni!ETc9n&bdAi?+~)KXBG|v<=BVuk#qobWW;iW_sH6Z6 zg7R>UQMTp$?WX!si#d759106o&+d7%aJNFwa5HMrJ*a<7e7(AY< zY~J0~u2_w0N6y1N11*%s zxP=6_WUI7>NPZ;@H982|NhW>OMo8{qd#F$dN=O>GSA|ItKWFo4dOd{sjJ;HJKm z_1F^=gI$psD7c6%0TbozY=~BP*57?1WoFsHw&sUyuot{u6&$}xNzphMFMfYwr3Xt{ zy)-c4YmM73S+E8^jmj@_g|WN(UJw;(`T~F>OR~8y>gzyi?=QZ_;aT*Hg0Nfp%Um2XJ{C5ls; zD$i2M|B!6~?)*wRrs=3-d+?Y42llt3MJ!}{?A^yAkm3tCYb9J`DSO$lQb77~5;o8K z#dt0(V}Et~#B~)ZJGjwT{gMACXt>n`fAwtoWG4GiHq?O8lkRf&SRCrlMBj7_s8koZ zRkW$HbNse;w)B`-vQ)5h4t_-B9{u7`(&yS7`acK{TL*xL3+i&v3Q(+U!+r2`yz#G< z@>_M)mWUd%Hc?{XRK$(m>!r_Tuze8h6gU|=iJ>n#z%}7#Zq>)Xum`%~EOX6AAz8Q* zdN3DgMCDDKm(;RZOt(k$=%o}-*#u~6^^6q@+ZoqkLy$MoMP=7+P1pdI*bzW`63YFj z?)ifl+(UOf=8lUM=pRdkmuAm7x81jaP0>*ymbre#cfcN8FZH>#MZq^}4eulIycIvC zF4~5h5ibnF

@zpU0BxfDcnd%i+2EQ z)%mogCz)q7CxL4W;1u!Sv0iJ+&WBx{h#^1OYQQE^w=~EhmKDJG{ns1JX=WRxR}I&& zBVMqAMcF_nlY}?^e1tLJJ!2-`?qVA*Q_Nd?Xu*MKSVo`N^%l?+z;%~=&kzTg1}|JG zdyV=N*?qWT^qNpzlV*5!pN$$>*U+G2>W!|RXCL?}OV&WP)u~=X8BuEO@i)IQF9EVG zXV}#q^-mo%6hT=^b9eoyPcMV2xsF7TUs0!0)$$Q$xtlLprY2Ix)R_r=IBy(v0$wt| zPHM54MgA`P299$eLljVQJe*PF+J?EakcHvA#-vOYBhRs{XR7ZmSc=1+_O_%(wrA80 zyIK&x7Pu3c3{Hw+Rc_;SWy6g82LEbTFZh0_GmNOI+SH%E7Z*|d%|T3GmhPrxF#N%u4E>@SlPh*G1id?JlZ!n!;KNuS}9ds5;mAtyjJo9d`vnUR6jSRhQcqLqlk|`QrWbn z?MIg4nh)8bU#(kQ!7vUazf~`QqMOq>r2Q<_AsK;}lL-g$ zcizH*%p)y&qn^JifZqjrxSrrTe!-&jaFwEH!uIv!3V+lguxw%wB|nG0BD&y=h7B^E z3dV#CUR@LdpN=7}BRb|Uz+S9Z(A!iqojzz+M!WDGgqT00FRIB)&m>sqLPtg3uVkK>u3oV$r2m%i_!UPUJmIo?5YY$mO^bykU>l-$`vRhQEa2tszOCWv zk%t3$bp7%|F@6|rqjz?B2H-y;!|IRvPh^WD;+@|+-L# z;okS5>=urIg^Ip28{e7hntVS3QtMBQ1ljTdB;DQ z*{WlO;fNq;$^y%;SZ)_!mYar1gH)ul2I)CM$|>dsS$FNWhvT z&)j(5pK)v}unAU!jWfwZMcV;}v4Ov=*;s9A859_-04Xy<07_98M;<4YoIM|3_hG!T z7{7YqFVX~ybNdlR)WdV!@-8*!S6JD_z#k)LO^FB@c$0e{!jW>`jG_vq2X3vY0iG8x z=h>|$cw>?DF757}Ez6M;CR!G#VHJ<2L&qog_BNb%v zs{dKmi8IcZsu&>e!kgWD$)Nb=it>LYZc$rZA>SpyCSJ&mx)V(4K4;fk`jx|(avB2L zp|0+mv8P8z+6FuD@2{tgR8;T6inLW7Mq254#OabRL@4w6EMo^*sf=^^Wl>~S_%DeT zH;2)CcH8~P1MT|D(c9r;Y*qkx;DG(XZp|$!p3lO zk;}7*#Jl_5{OF`}Nnrfy#Jwlq=!R!Jm#!^SI>#_@Tm@oRn|cXeJEG0<@2 zI^iVoR&#C?J;y3WU!Ixw!_#xmkqZwCcgw~cM#|8+&(ndk_h@nNt`~>kC-=wt^;4reO$}UAqsK`3lQAqyuKQok@ zr=@9T)~6ZSoC7@pA|KG9d)^T*+_ohy^Bu*NDl^arqZh(A&1BW@PCHm(* z*rR(cEYW4i!Kd>Iz`I|-r)GXJhX@cE7SWL(%iER~m?q7B0+DyO9xVVa;6Wp&Ac5Fo z^HfF11Wv`sU_m`P=&zi?l2L=8mF4*odnU(Q{H?8V-}oG%$cyrzawA|LK}} zIWEBkBJuo#YExs8i?OX26~S%b8$^QrMLh>2cD&Ti>a-A)cmz9K$mNoFL(;{{{5kLl zUj5&qWQJpxV^Ib-KzNRGkIJ`7XPu(d+EB;(q>-C~uvw36j-+#eEroV$Rhv2ru=jma z@)3bxFz7Y@wHP1dXmiX_XUIQ zXj2#DJs|VWiNE266dioFaJaIN2aCDE``+9Sf1P}V=zSt#t9fU7k>A_gi6uPNR@k~@ zhAq#X1FyoEG>$)dr=V;+!e}g@A~K}T0K`ZdhqRG$!XC02`rXm&i=g54hNH1Hl$58- zR??J!Js{0Z`*9VgLQfE}iO~NOJT>h~U1$(8-~5DO_Z}X0Bp*#Yzpk#n?}5}F#SO#? z4cgS#MY~y~PKOp_qtjncI&Ah|_YPlsXukL|N5QLVb88EdQxo$n=#WLG-rjQ?GTIFG zwJUc73=jgk20#8FiF;-;q3;!_q#ovP4r#hOS1nm=J!o-*Jz`7kM|C&9efcm&$0u09 z|4q4q0S{Afuo=WN8OWf%2$$*J)EpHEPxic?T!!(BjVCK2I@0 z+L~x`ePL|cD+5lT8$VvI)_D8cF1NXoq*9Uei1{~5D$NEjEENArTI+{4+Gp|>ZH-5n z*QKiIKkDNzAw46g;U)lL_uQ`<{M!10z@~s1D4Br?I$%8Q~2HWUBAP-L%Vsv`JV0$HM-z_b=SBr4BG}2!}Z4APU%(QsALsmem;x=%(dD4D?VHgVVL;QP`hl zx-75_r08-1!@uYYe==*`l4$s8>AnAGhBN*mv&$nGt4D70`7>pwQX{}PEu#H@G*P}G zx1v|pN8SL%4lV=QvG%F{k%8f<@c|&}V-|J#&?nXnVo?0F+}E_;fDS3jCG~n2iw4ir zwYSIxKAnl~tPbwWn$ zT`9t^Y@8)m)%;bFV;vM3cn1fWy`&RhT3IXC40pImJ74nz_TBqeyE$B3miA1DS*}2h z{FX_j0igQgAmW6o@)qHBCA(HG1yGYSBDon)4<+jkRp~0)wR=bv-=P0mgK!^7{t2rY zK@Spvh~1!b7L{?_g7w~iG$!%LgVetP8tsm{t+!kvNrlq-imxL=6E7onPrXqq!*P=U zlrsQq7sEk-R&DXCHQ#8iUc&Nq{}-bmig&^|9pyG=qUrXAc_j1@?`MHy!w-;IVeL@d zd&Dm^WA!})@t(+>7EkKjzpVjk6x`lvr}pZNQ0}M1iLXW)8})rh#wkZ4mV5;Bh&Et1 zHNrS?%;xvj!rE#MqvPW74FM>kzdMmgJc8h$;IBQn0pg2&T7!bQ#8a74yRF06b^CiS zM(qx(g~fig**_!i9AT+fp00QYIHxyeVS~0e zQvd`NLp!#i7!~*COXWvwYA_-xawN?U+Ca_<; z!>Q2()j_^ONA@hE_n^3yrT&7?jp*nHoA#E--%u6ck;IS5Qoja8U&=ph-BEaU4(MG# z$TnI87wUhH>*Ejsq?on6DsN5881Puy_gqK>nyt`!%s~tl4)GkgJFsLq8TrXb)j!rD zL>p!QW>!AUEg!H~VOSf^3-_A6<)-{OihuQ`Q=^6BbKlh8%qH|&nYteO3`^FYM{iXA zyt6(4im+kI*~HNZFo<3bF?Oe?c8zCWt@>h5y*Ij&kgo8@Go%Mq4mrO#YTwE6!tUDY zt>djssyuda<4}=gsXzm zyRpnZ?w2jfS3>>W9T`ab^bk_rQ1wnIk9yUREB&3SFyWU$jDy0IOUOF_46JK#0n9@k z%jR=h6sJ!cb~e~lPD%xu{0jJlqgOpP}97#Sl{t zN}(SQ?>`uQ{@aJ<+^=Ah*9Z_Hi=sV}2!(N=YbHM0}vyX=@W|1_{&5HI*GNCZNGxN8D|vLrNU zmZtJcG7d~gU+=PUG3n;?4GeQVGP%qHZU>4BH~tPL$h{0@1bXIS-c?vRzW>c8p;Auu z!zv=OQcj}@h)*3Z@H&$JB47{r-lOc?Y3$hcyUgX}UZ_Czam|?v5x&=-jJh>e-uTms zPkpt5>pp0&Mcj)_(ZnrTUp2h|M4us2XF`}Ar+18ELjn=H4;KbE1i(vXNZtqaJ{>tz zGhrn+jf5E}YnV@ol3zHBRDpf$us%^^u2I!?qPvaN_ZGmTpu7OyvW6jjdb$_vq!Xq17ygzo*nXAkWhT3jBV4zOJM_@JUt^&c^_U2+yPR$Mzd??& z2GFE+g`N}45{i0N*5f>V&#>sD*PTisDyfl;GC23Of}L*_kV>_BK=Y9FaGZ%aY;U0k z9VhSvH04^BLOrj)P7%GYeUfyrispezH|uH+VRB!*tN2Nw!KC}psOK=L01mbCEA%&r z{?EO-~5n6X%DBiHy650GPYoPL&!%yW$R zI|!YP2hbvj&*i)sp;qKUk~!^Fqmh6b7{v%qHSZ(m+AXi<;he)>;pB z-Eq?80j52kXlYI6q#nl$@cN1m!Zh$#;FVG$?5#U6>GvyNOm ze}Y6UI1Myi=X(F634X8E$}cV_tx8T10qZVTxt}oF)whUtFh5BG9T7*j6sW_R7nY|1 z%TGgPB;ayQ(e?67`NtP~|A6Bb_e4dD9EwTePX%QhX z_6@+pIWuz%tkFS~mH&4Rj*xF`X6I)6o14>@t}#eX!m3Jd`v7EKw-lDsC?jSh;H+RM2S0xYk0|Mv`cnAk8m<@FerA+e9Y(# z=nP<>d9M7jMwRDb*+6Vow4GjZcNEpMM^}T{G*UI9XGweS^F~sT)XbT&m0oj+$usIE zTW4T_c%j2C0e_z%*j<(y1iZ@!v0z*c(jCsVegF6R<2=o`wmmkW{a$h#=HPT072_HL zUb2F9*16V--|M!BQVn|`4qunWGjyKvD+7K~AWk^nuybQpWd-5Qn{s94z!Hok(Qb$6 zoSM6em`Ap8G}8_5%!?A^yc#TY9N*q@e|lSD;Ts#}Lct%;nYtE)1F}s-`N}*qCH>p@ zI?8_fWVcR8fCyt2I+Q(sm0d$(x|lA`n6n%Z&blX+f|RTe_PLW?k_y(2r&oU2-@v6` zGo5GG&70BfITD+MIL%WR`DO2E{{D8ilm0F~HeOQs$`{V9N0-U6-1|MhwJDD9rY*Ch zce`~@)e+SL-RUUvK@gxvUeN6DDq!@Rc-yLB%H#)6)E|X!ZI!FH8R#+H-!9&KX;N|Rbrc`O`tG8nrJ;@dcf%Y7 zF197$^_N9WBiOegi81K@)+o|8gZ3dRzE*u_X}o|5x|Tt?lfAMA`wJ?=x%#*cVsWSl7_dK)B6XT_ zcydrxah-5UnaMi^HF~8859)MUE@My|1tw1GIKdYnEmkhJ5?qCUM^X1AWgndt3I>nD zGKuT+dK{g@X4vyD-#5CUALRKR4l$n!^r05OY-Vn{MIbjohk6+Rs8ROJc=blprAa^?d3MKrSE3@zK0 z+;TYxJoLAHe`1xkAe}QuTa7(Eu7?Mxse0aMqUH>^i+jX}NSLuk>78tJIZvHE@w3bi zx@^+j$GfJ**)wP|UOMB-GnZp{Hg<<0^%Zo$`HT6hidUy;qU{#kL0sk)j$+aKP5+<+ z!`Oii>J?Z~`FD~zxpowUV4AsM;|3lR9^)0D`@Zcvcd^+dS?0U zQ({f8o#D1XuK=#_w`>`3-U4&|_6^wbp5NI@DZg=DL`32@+MO_f6Lb|t%qTy2+7*oM z6w$(s{Xx&C9RGuKkcBfw>vtjhg;;|BX3$hbUZ#;IMSzl09esjI%%&UeWtzyOX7H=D zj&Tqt&h58B)C`|~1=9uGQVDq-n9MSR=n(fPHLWaL>5vu&yj<2yXh?%GFdYLcZ#Bv@ zTE3B#F~B`Z*JMb8flF>D*U(s1ts1$D-ZHAV8r_)NkwKOv&hbd$3X3gjM-FZLZg9z2{?Fj zo}DNkm_Pfc#8XVjmZ{*13;E0!dS%;OWrVoyK5x2nX%&<$ko5Uu!fr6^IK-Ox8V4c75RrKK z<=@hqnfy&ofk8G_!(vXtu5H1)i)c5ciAiuRLpvw}dKuW=hdw!K=f0GW_VsaHGE0+n zLIhqA2s~>9^_L(z7k&L&+));A>YPc5m@XhOpU&zfDMxgG@w@Xe z%^LRI+NNP@l(;kCc3|0pI({0}b@~;8B~2F5Oq+R;YS(#gQ@4zy zEYmQ7o=9miKEX!drE7w7hsfZu@1U=-Q$oMYO$vXz(|^tB9yCWacb2cdd@&G2eA*4I zN$)&}9^rd7k8FqLJib{arNP|88@ied6IC_b_JE9}z5QfccJQFu3iPocBE_H*`leRL zmk?%eB4c&oU9f}_%z$kul5oD&&`}H`(hQtnI&`2P&^6FRfb-#ko`x-1V+x4=?G}Qe z@)m&QW~cWKQv(3!7=LgaxTADUFoiQU>W`{!JmYY6}e!J=< zQGK>i*AVuaH_(7rEtW-ECZ!IgeJ&d4cJJlrECQzSsp979bU4%&=>pFWKPjZMgR>%m#AF>btuH?A9VJst7ZUB#NwK0l45`cP=l$ zm=m(i;mF7zEdx&hUnI+WFZ0=UJgL+hS(tt595`#pDcX!MuW)W z4DVI>9a|Zzo+mkR`{u&`8sr`NLu0e4=;a5{{)W4d8z?|WCFBSqlUS}=3WHQ_D+Jxyju8&)u5@84lI+{YG>wt&iZ|T1bJ71 z;&SFqNmIVEaHCa$=+cJ!?$bE>Dn1_hTlST~rpW3`N}ic&reqV9mo)oTRKXusGq8J= z5Qy_22I7?3=b4{PyFS>Y17fGj;U^g9h~L}-iNb?V!B=vwsL&LA=m=>h!FF&{0*67B zUMVTt`d3EZ$NA>Fa&qqJUky)cD`QT2McMxp56Zjp>q-!LsweMhlI4$EzY2y$dO7n# zRx(Gv=&(SJ!P*~0+yw`W!C7EvInJ8Rn=Ku(xSL1o>0h!&F?zH~AJHT7kJ5Va??7 z__FAI7g3t+`hg|b_kVjW7s~9kdw%W}Rmxmy{-2VHL;S zqra2%-}_&^M%|4^unlQ{jW2%L2xWNo$SECHhtuphDZjY&q+VEIxy9vq&0&qWTOTqE zc7{k@``1LZg~W6ZJ2XA9-i0TzT<>6z4NTR37pjn3QLFV@Br)ggW$DFk23@?-+Te+B z@1_r=$nEI$!@1Aeh3CeYtM;>hyS?eMLT`>kqw*35U|}#))4Pp)5X0F-zLP4ytLpw* zfx4xptz}{tkTFuy-`V&0LC~jt$#9=CNf<7q@pZfHa&Xv?CIe)x+|yd~ihaIZ@$~{6 zMh_QqSQgPF@hI^mH0s}HYU+kp8!yZ&$NNZ5>ZGt^VOme7M$7aR{vj|ecyrdv=jdF; zf&6MPuVoN!Qf)P|)iYoW?##I`frpP?!Q%X>Phg&j2?c%RuAnzX&fIcl>T9pt_$Lqc z2CtlzzSg+1@4037MG8r)$~(Fid#W9_&%&n|PP}mT23rUx$~$wh>f*AtoX;a(#~Q?jlX=Dhlxz0keQ?2)RqmF+v=J zvcaB>kC$D^ugr!)?@8_QT&%RC%+*kXIjFuJ`hE`UL8iGkAd`ihJq1fIoQeJk&=y5d zpELRkczTc1Pus1;r_A5YfDB*+Q)=F0h@A)81Z5#YEb%M219aJX$FpPO&(EYAD)KfJ z=se^FgfNb8WA8FPYFTCWI=9OU7n#{zU$G9 z2tS{|r<26z0fFs7S7X+9gD-`1=)cjJ1u6Q>aMJK|5?vltU?e{SH#5W4xan93hQg-z#Z=t&OcD_d@Ypr%eUJP z(3fv>beF7>rN-#OFGARXL@f9Ka(5_y+6B(yE`G4WYGJ8un=CVwloh7Xw^7G7)}f4y z?p&B-xHl1&sm$9DM5mc+0NFM7pCh7NjRaflx1)Xf}Z$`X!S{EN7~8D z%slt)1(y3?hkey|yKgbB7Y9HIC&U+cY40Ca)!Dpb<3sy7wWC+m({KDZBpijWY2OT8 zEaeRH!N6G6%O14kx*jTsy!=3A@Wuf>?+X69WQ{xP%ro-99Sb|k*-P8jhs29N9FK`N zUu*nD8KgC%fq&lQfEQY(|L(N3lh@2($O0DJ=ee28sEoG`C*5(rx~%krW8b_z2X7Zk zM#bBNNEfXSj7V<@u7CvffNoE6%v>_O5fs(-t9A4ausVMuD0H@r)yBR_;0Iks{9(WG zb86M>Y152=Ae9RP*wqT-Ty?tsqX8-32Hn%L|A;CPMD}JRKrAy*|RJF)+mN-Elr^Lf3QeRd?&K!c8@ZwXW=@~ zXP4HY=~dZ?Gm8ZgA@@+8%QNm3t(6mM{9_FLU3QHA4np;qIWY0A0(sVpGvh(5L%qPA-JL-;Cc4hu*c*CiWNSkx5cP5JX#=z@gQ)&LtR9QXlp@t>lsh911}Wk%nBpG_1< zB2s;ccK)OXQ~jVaxx2K$a}jo__(F_`dP`+b7IemZ?~JD%TrhivuynS!8Iott zoT302m4iDA@#QqJWZAXUV{TeIZwIt*%MSh9>b&Zy0p zApV=)<_t8s-Eu*;+Zj_iE?mDMw5c?4J+#zG&$$%IiN$$fI!E!KJ=UdzDE7*IJ^nVc zl%ZTDY;x?G0rfH;?mrqTi@~&_>nQ8hq2fEvxH**eaYF7xgr8-Q#Ng z}j6V74NfL7}+kkRTm~YYV(;KE;ODMSAX-|W9I zmiZgrD0HH_0%nt{B_8uZ7Fq?PM^@xY-XaVIULoEzyBJjmxaGy9@7F>S%Tu`yUd?TF z{;o~$%hLW_7PsR`c-Hjdw7yg&&+-*aGg-|)FS=(wdcA(T>A`?I$TiW6<1qJG*ufGs&XyX10EnI}mOK#<2a3`_84ldt9WIGu6zo zWG1036kNPds>EeSvM>P=!#jIr{+@}k2jPn@*g6q!VQYC{8yc~foF2gP|;M}2Sfqr(DSHSr&0?2fGxO2E`;u;gF%BlJH zP_DW)Dhc%gf%)qeb*xpsZP-(CZ+-FBR=n?C>=6JI+??qsSol2f1o<`apmQLGTPVsQ z)WWJIwtswHd{_rUXUug1?Qf+8Y^1{8k(tt5{({;;p36K%f}%f$rC|?u4LDDEVY{;U zn_GQFGDkr#^JMpPj%_8`sv9G+G@IYj=QmC0Z2Lj75EK+u2~IPIm;uaXnuT3!K+%Mv zrCz_q(Aw?!dn=^ET>yi47eC}c@(HtJQL`lO_iw=P@fzH!hy_Cb z;^?Bf20|?)(pS&;*Tg4e6VZ|+R_ymY?uY{IrPL9J`|NNqc)c5^nbrUqu)c%ZMRObQ z*R%mEY)ySAE4&F?UA6%*2^>%?mLhAeyrI$x;8etIL`SXqO4qircSh79CSl3H@8gOP z^=6Rq$sZAoQR+7;m!L!Yxvh-rk;EQr_GjsD8(Y>9Broc8V_jkh5}>_6mXr_H^7>-; zHC2z3rP{k&<3^wnA#E!Q+}IA(JE5+M5R8K$i$nlTN%=$mD7G^t9Ew21xwnB5p7ylL z3`r>Kc)CAGp^F)pmHBz5=-YK{K|dRuXZ$(WmR^eS|nC#Czy+@Vkf^R zLwanb{FD1RG?mB8*g|YVu(A{mWn2T3(c=45 zCRP?U-2W>>@GX+HRtqzCe^U50(br`DXr@o1pxb~;6f=cIp9O*6fve%497efQjT4iZ=?RBHUB(@4$E)c^_8%9cVWGw&uE8-5*VF{v9Tuxt<$Z!f? zLP1O;UQ>3^628}~H?m4Rm|RvWR0V8tI;T6+>gHyfp{z^F5B9jNS9HFgeGkU1ETd30 zGr^ckj&&UV>C$RLS9?iLOP5HH$a>LFv~f z^zmM3e6sN$&7+IhrvL*7mWgfuV!-fQKIQuK|50?^@lZZ~oP=)*8JEnfC?Q1lI+Jxcm1B_vbyTmhR2hQmpeP zclk_hR!`&tq!?zO1KJ{$G0V8yGQj>458j12kcN@BUH2M|Eb|=zMbj(Sk_WCd)<%{o z)B#(qPk1VC@rcwv7dvz_Z3x~lnt+uJ&A6^#fVlu_uv`0zHd84+WjV{x$n7N1yUmR~ z(N{tQ=}6QDijy(vFPt`nk~E%Xaj#)g=ux!d=%J8GePPRyB_k;2LPIe@()DsGJBSyb zo=-3WLWR*0WP*+9X@jFZgBej$pAhMFR_-jACgHObYlU% zCV?7A6Jz^U(~oK&F3;)dEPCO(LFaA+ChAq*ND^Pja0rhJ*UY|)7>w?l_pI}LJMic4dDD;IkH4mrtEuZ1Pu7H7o5A-lPkn6EL-~Qt(%W%60i5fZtGt48 zwkl5HQvHnbIE!dHJDMCpEB^gJ^l)Ry&2z2+FLm!LSNw51Nd^axMk>~8_vAR0P0r55 z16<^yoo!&ELuTp}j++#Jq^BNo=?2fCvc=Ie=baeI_5tmckebDJ6{CC8U1P!n+#rgz z>p6JTceD-P3KqKewal*`mP{J(^gzDr;RpfY%&cEc#FxUKkQdofpp$uPz{WP#b&uZ@I z(m!XWDdcgO`HxIR4}TBQp0|XY=I`zhinImvzuqC5q!pJa$cWRW0ClwC(_aP6ecA`= zu^C1+G>d$7#YRLcD5>OIvCwA;i5LZ+>>aTCn zdZuzN8uW+g)YmmMZ>Kp@GiM{V@gbY zaQyAnF~?h^>Pz8U!)poqHPNUp&`-6mQm68?)C0e8fB@y0%XuMnF2_|s;4ruI+A&Nw zB&6nv+BBz#0g?Z^8J7&rXQ|mLgBYf>lk)=EkPZHZ{+^-r=Y3>dp31B)%!ZwThOfwE zjEZPykpYDQ_I3})w z%j*gmADbE+KRYYIj@rwdqGJWQ zr)}Lb$~JyF=(Kf~@bW$FUJ*FyKa0gW;L1yDU@;-|j=o5*SHV~Z<3$aF1>}@5 zcCOTV?yH4nJx=PnU!DU-*5ywf%UtJsx*Yyv$WEO2ytfkg**wE_=Xx~pRo~BtjZrLb zm^hU~xC>1(2cct~hlU(hWhxmj^S9%1Zwdk)Uy7c|LsYO$sF=Okzmm8fo|_Y|0T&i% zGQOD!9Ak?l1%3d?DX$D180XC$(oLTAXlR^&%gNA}d9?y)Vo*=!5^E~JAzx|zPWav% zR?7d^cP2X&QO4$%Hrzdtvcv$uR-nBE#!matla`f6&!suX1%V2$gVAuv5@^3tMWHWO z$Zy-?jssxlB0RqFxBA1I1p>eQGVZ1Z_?`;<3kq$CA6Z_Une1-|v&-rK)-2k>lnzKm zN4K~0C_9`z5jehDegeZ8J$WHzBWFFv0c|3US+GpVSzn1a#}>CYc#q0uy#2@_Jq>%J z*p^ZdHU+wPBz#jQV?fFPI_uhVCKdOs zVTUp(p@mRS;;>Fgm~i{e|C%}QzDx9b<|uQgMW*7gYGDq?I|in$g?KmJ=~ls0P7}Q} z+4SX&*Q~~5w;%wG8KOX-1Q4%zoLfZoE~l>n?(5Hrr*_0^Gn;+|Zl6Dh-kl0S@vA99 zw62iLn|s7%;pjzbHwBg%o!}COn;sAMk`~P*lf{TdX&8DT*01Vw4r%&z7aE-*N%+dg zd8Qc(aMh`aZR<7Tn+(1EeJSQLfIOic9l1yiw;OP3hcKVgDv|E{H7Gqe>o*$Zu8!*Y z%d_yvX%>I5fwu8D>cN(hQMFJ8hv5WBH_4Y#m~VW|oFQWse#I))BST;B{4AVxm|A@2 zO{siC(3$&>o_p^71KOZE;Z}Hz1caxcBcr0dS z8*0FrbTcw=Hn8AgqD}GcW3r$=jk%{HogpQi`4)a|ZS=TjW)%&;hrJqX8=4yJ3tn@7 zw8wN}-S2YA2SB^(qxU1Qiz`0pTzfE8boRUEBcAkLoA@W~md;Pvr@zZOtvw}Z;EXPC zcirricJ{tj?T?u!PK9~p?QI)w+8Zv`Z? zIvm8T^D77v&~3#2j97r%-bm@2&T};ez8RKPgkwcQ+iGWN9R2Jg=EsR7&L4}P9Z zGJ(=%0bGBf1in`7ZxA~LS-hSB4!(Crx;w^cmGWxCw%#SiO&;McfLjDa8sRPkS6uKf zoJCowgAc>Jh>bo#^IVFkzD-77v<$06vjMmfK@M+e=U@J;`_@R6_IkebBSl%d|Hwi> z1Ae@#!tG5yhix!AG4|rdaXDoVP~`VGiN_C7rdD`zy0^k zgva^&Qo3ib(c1k~96RauVR#Prq^+zLCMkbDx|x1Udb>+t6A=ynwHf0Dq&x-i#uiOE zmx~Lsk$(?Pb`g5SAXb>dC#j}n-Rc?&9sNgk_aB+%A$w2=Hi&c=Us&$wF>T(wk=QlK z{_rvR-D6&t{!ek|T%Pgy1A3xv7@sj!H$epJ^0tvnIz~;i#*33i<8;U;^bCMR%sOpzb*J1R>&FwIjt((Db(?Q4t zjtXjS(>|O{inhLU@_Tj*6*^l{jaOc#|JQMrx zPPE|}Q`!9(b&Nr<6&h6s?ycQ__I;NH&e4VN_I#4`#zehFbkFe4laQ^|BFf;QE8%T@ zhXtnUGz+=WF=!{GIw$rg9(wi-XOXaCYFyPYW+C945JhGDR0(Dz-NPL**}O;0DR+f0 zzcJlm;kf%FcBq&NEwU@s}8ULegi;ToBEYAezjB7^31%KjYL~{X$&m2 zOe^MRA91CRgT!S`^=$9n|MVTA*H%xD=QXOBXy*1qb{-v7?lAsx!?ObuH9Ki0U=J9R z_UV6oa9J>j2_=-xwk@qf<=oST<6!SKR=uU&OU~=ghW0M5L+SRenOZAT}d zlN$5zSGMWzNIA{4`|c3A6VxIq3XD}lV9MHa=xVY18);JpWZDCsewA8Bn*A)vM;#k~ z042OZ3ldPioW~Z%A9#czlxombI#fH>u36A(fa1K4Vn@M5fJ`=OWZq=WPXIxYR zE98yIbYkLY-MeHkq z*lLDl`s=6%N9X=`_mj|*jlv`v^)gf>-1h6u*2zsO(uO!U@ylf6wEpFwFl`jf(dl6L zbSE&W1j3D*U7C;3_Tn*`YYz+Ch3!DDXb1{=k>HB%b|gjV^+0o~i8TsRfNrx>2nx3G z^NKY{z7BT?=p1P9&u1ul%XZSzzN1ek4Nd-$0aJ)h&4^f_4&#iEssr=O2bWK@0BHy* zwYzV`!me*qU+b$(h-z#B`D?mq6I5UG;sK4#pB_WfqwgR0=jQv#FQ73)ND<+6nmu@Z zHDL_i+6H5WD&ueD5#(wZw>{-}0?tv>;uyj|dIh)zS^c&%jrDnch1{&?(7N^!Xr0(S?6zJdTba-I+wYM}_@L zTy}S$8y%(azF3CW03zL1(Ir*taFkia9F`6J8T=%ubS^t$HZHz9VnEjS1@48fYk0D) zPeN=T?ogZjaYyg4VPO|lfZ=;SF`8X#UFm#=gFQ!$0{!_wV-0qyk%gjIp7>Q%(937< zCAwKxGFv1m5XB~m&oIh(LmZhXJu|Crhd!SaMYZ~R?N9UV;a5mUsv`KmNxbumLJ1Nm z-|1uCUO$G-Gx+yxb4_|Q5p5xZm^Z?tugk@4maSM^X=yzPy!p+X1M z9XNk)%b&{9s8>R!M}?nVmBQYn?N`0U)yB=oUQa$S%k1m6u}akUi}8#bOuHxDEj~Hq z+;ISjwC#1&=%-V4U}ZH7>)2&`A&Ch(^9<_ePwkR-!lAolIC3cPS)N5b5-LYr^I5gq zu-cz)UG&AvuxxM*Jap}MV>K=~GoWmBpE^4QQ+(Q zpT{&Ovc!61bl$k)#ZQv)C6ty&!GJ7(ecw!MAf;9ErO)@KBHwN++MBOPofG-v9)HwYQsu|J!Kq#f z3X@_3Zl;oKJZb{=s~RJESKPU0nRidckRcFcVQs5|9`4 z?ER8}HiRt06niLCBz=aqG*CzdW)8mwoySjATL8BqZVz^DnX6v_=QtrK#D(CNEOj1W zza7HH1pc@*LmVT)Z$W1k1Sj@Z@lH;`-@9si{kZ0+Rg<;? zxZ@QS$juX89sVOuQBGaWN9?OdFN)@_V?y376eqIS@51sS*MWPUUKACKSvbCN-U?3V zIN!IPW8%odkoLE^ClDze>Wv6*-uI(7Cuc? z|20P~em+@!rQ*l5lpard;Aa4hJJ%ma1-+7QYqxXUuekLxSCE_h_By>90pO(1iKkiq z0-Kjm4*abS6zIBg!Gl?1yQfNw3?fEngZ3}kkhSlsUGjqHCRQp{q8{&Gdyq?0TKW`Z z=tbrM8(?XHvwR%-=yLfH z{=+gz>Edie`?&-dt1ZlA+Rb#=8t+nI#wbHJ=`MMST7{k6vOjDV!s}_u9gc&B2$W`V zq$P;kB0E7EYqzKRBZzHEn9+0$yV*85Ix8S_SUI$2k`bjusUCE`f=0j?2z28RD-s&e6`%5E^Ld@f~Yu^d(rE6}1o82i6Az(jv-R_)k9wymUbMpRH2*_HhhXi!+?!wg;m$?aa^jmBV+2F_ry6R zCfG3aHH5qwAJ;g^kB5A(&*a>~-#>-O!Iypp6dUjRBskN@&NIo=O|AY9xcH;31Ux&Hi06m4h?l6qp?<)@h`cJEr0v){d z1&Q6yvx6;q-|h!*ocFxLk8i(%!F`WzYKvL^*0f5zr|jDEypB~`S_)jkyR5elT4A?8 zC>$}7eD5>x-CRu~{jRj?I&=)vY&ZsY&`Y>9^ZShxpCrGsE&Fq6!&fi5%Wh+f9chM<59$x!oE3~a|1GEgdhZ?I zMIkXLzF4!~t0VwCKSj7b)MBlIXSMrop0}`-O;IbCB7F%xsVfvEX0U#_cP}W84z7@j z9{~oV8obFR;JlvO>frw{0;FaaYgqYMk*ps}rCt~kGk~LCPHoy6Gjo!e8=331zcRK{ z#PZq6LTzkl8{aZyuJ_yZs%oCfHSU<<)2%-en}gg6@lI!+<4aDd2?y&@3|vZ{X}#p? zykW4vZZSgQ^H#WoZyz7);WQI2x^2NeXjcG?%u|qPfY4OQ-)zsMuwS|Kf8>+O$XLY1 z@}77Q?Y`aZND#q&)Lhb|fe{RF?_XeizJd=Q(#(=^SU&6W>v9y)_vR0b+38#Dr_~Ep zko5!ZP=xoxKZ`#N=zyIVyLoDPFQ+zZ2_RI*u0+GK9=VFU;9Cby>ayU)bqhp6$V@XG zz*rbi@8aT(Gi87H$2(%3Q8N85MwD8)GsK-ViDWx*!%(b#^!wDVyKl|-Lxh!zjym^b zqbZSLIosLui&2MxBLftzllZso2Pd7;I8h0x9`^}+FfeI$f$UeN{P6a|CX)0 zR?Y&Lb=m)YSaRflf{Iz+OE#3~GC4^S%1?B@w-bl2raDFhHeHYHb2z+k03qS zs4OyFWBk53SfT`V?1_&=^{zrbvQ5QGx=Gd^YnYO*dzkr2Nw1`*DD+1^-dckz!LjOx zykp3#%9h>p#^(C+Sw0nQ( z=vs3dwDoKsFt5w+*HIx{NtEr2Fr{bsO3Bgp6_I&!I#vZ%#Jf9=K82;!$gPt(=g0(( zesh)X)=I7c7&2P#>DwX{je0SPC5=`4moHh?cQr&Cy>hRM6q+Q6gzc;N!Bc+lwG7I< zq%aznZfU8~kHKO)56KOAHxe_rO>!Eyt76jcI_!T)d_1 z1`X*ks6G5c9XI$nF8f7o=+k>Eji#$^;a0fxP?XnvolX4jVAa5H9d*;XBRV;Kd~f|` z@lnGf`{q@PLft$g1QPz8M9ug?~lJu}v^?V?}ZvPd$csA%x z#ji=%kZw1RfNyWo@l<47bJ^Z2jmOKF>o~b5eruBu0`rYcaHYylM3}-{&TItULhIr(T1ai6)86-Glpfg>1K|MrLx#RmrGMi?=I9sX1LGX zD)8r5Qhuf@)G-B>NZlmy>;)Z_RF}-uHl$rR99-$vF6%YMUE7>? zeQ?X=LE_*8X#C1ENOOX}_0 zl-*GTI<^&X#>3w-k}{ofCg z2}nVj4Kdc2K1tO_K45rNh||$sc!b&B(<1y1gh~&R@p8ESae&QU~z@dhlA-ErRoO1eMVa2 zBmY0EIo?_%Klx$<39uo-3jKkAt|8}IDR3(*DfR!*0DIl%ApQ{-Bu{Xz*LDj?nREl~ ze6+(lv#Lz~N%hErx zFa@?T?s%04WBZ;t`wuRn0i_^nbCyJNfB6c<5i$y)4WfM8P+0#q48GtEB5DV5RHwIF z&{h5VRhb#|SaqiI+%fKU@Cu~Cg3b~~Vsd}&O}q#``b|jOD z4vJFxS}=tPo%+4Kw{Q27*|!@q+8D*D-Z|Q%S`+V11{mRr@c|EkeCP;ejAg{{xL6+C z862dHy5meNED#{bgIFdT_Y^icjZ-SNF?fv}brsdY1A51c;uZCeCwl*Fu@yCy>gGBB z*!VM()2!uokKUPP26yoNN2{FX3W(~}FVok{&(P~+p#D`U4iG9H?L0XqM++plHM|^i z2`Z`|Td#RzO0+zV8cBO^nzx;K3T+Yxt-bGJfqZ`b@DFaIeu4MPonV;0q}x9-mjVa) z&tQiuOC4hJ71+%5KF0fImfz=q@Ku7q8aOoP@#tyb;vVZ8>zuA#$FjGs2{H^hrzdv% z-76@1iy#Qru3bv_TVU3J?t-S^M7vb1XNuyMXuuT|4Hf#SCswHDF|)Wy`3Auin$rlKgVp*H7)0CT%|eDDl@FWuwMOz? zpsI>w4HlBx&N4Tsq;=!Ty5%?#cwY5(9jGBbZ^?h}$y%_i=DgT(X{9QwMvu;4sjCSh zcjIg=MCZt3KM_c5@eOdlZBIkwTS!UMdasjFUcd+I*fF4Xth(1k~i9yNVo;H z5TMqI=hjWB#>n47WES0r>$Sji=?YZjn=Jqx^`jD+ofF=LpPeVyQUrt zDgIG9BIG%x;&p5{9Y2bIeF2FYQQ(A4J57+QaV2}ZgM@=G%^{+c6^S_nOB|Oy_HwcW zRA5KSFe%d8nIPVL(J`2txHh#(Phi+wUc!3-b%lskZBXM*H;i-9>_&aP-wKF*6;Y$? zglfx>02E6jd3;1QNSiP2^{1& zkt$SpCOkCn)8_cWXP48PuptzIsl5e`%?cS);HvaGcG=&&0!&Hj{@Xn5^u`Vd1stV+ zZQcbtMG^<;F$x37Nxd*HG3??8AWc!RLBDVEu{&h?rNB=sW*6A{i&w|46x3s*#VQon zp}U*wcffS;xN^GweIxF>`-%M1Era&7u~3RNh*k#7txwiSE?+`+d^aUHlKjr=CpSQE zU3|LuK`*=IsekJJpzcQ$J-*@0-(_%cB5tV_&t|nLSI)2KuBP_**(;tNxs~iJe;R&O z!|N+q$6SglVSlC*!WR4rT2IQZZ=^)1R{R^rukrQtlqCy`4KyZH2Z(c)t=bBJ3mB@N zXh094c^}lY5=9JD3Vw0_D$~kM=-9~1P0Y8J;-J|U>(IS>`a{Dg1KaNCQ=T&Lns``G zX>H)d`Lv=LWMfADruI%;`@LYybk&E?EoDb7cW(y^WQx{A8%~S>8ESe-be|6^m)<4{ z_yj)9)sQt{yw;fWt$t;Y_j(ZRl|zuU41g1<$NtFwN2XaJ&5J=xoCsbnMsAMH61~8p za1-JQt~lx~4ie=i7Fc+zvI~0mYz6%v%Bvq4jb?Q9Vo$E-{@kVPF2R-h`p*JVbjvBd zSFKoB%|EN_?~1a1H(R8|#3VjTlQcBG@f-2MEeDcUZVtih#&xCY+5}U|i}A2S1zWFW z{dyAec1wmvm5d{(3{>(M|Sv~DJV#Dvxci;)n}@UQ1W06Xa6oBg z67#KqQ&c#7DaBWG;Oh2v&ua><@*e5E$$k>(Jne^7qMAd&fAl!6oTdP1LVxM5F@vB9{H||T8mgpjDD23x*S}RfvxBlqJ9O)I2vG4$$kxOllrUsIN5(> zKF>BJUv+U`CxNaWUeWDlCY-m$JQ@`{_8HX^8j@}vy2}a~al;LYs9%U{w%)2A4yI%C0Yn6zb!qH^OBp|q5mrO z31d$Z!d*8Wvv7Saw#%pR_qPk{+yAWP8kE+;$v7iJUIKWBD@!Yk7H#S84cq`uXk`{V z*bMqciov_lf^3ApdY<^H2`m3%&9UOL$}ti-&d>X>dkqed-?92zYRDxRokM>cF)xwc zmC_52ebEn=aIj6XVayGn6WD;vo}Wbl=?P9m-gbrmQXBw7MUKYbJ&^_*+qL3nK+O;u zq#jYr&3=N2Cn*DmzwEaHmUxTXC@$l*c?6vL;gevs2Cz0D*c4x-XAGHNAj5TbWr$$s z{cpaMx(aQdcyue-apcV0jlK;CA7=i~fS}742W!L9THv4cteh;H2+&r6^pLdM*NJ z&KIMb$tG4u4z0(lwX@LGG9u(>Sf*R~ZsZ)H8oj$yzb?l}4Er>*?ooZ9F0wXZt;y=} z#}ptVol-vnm_FW!3oce3fXHrr~I4QUwP;Cm0)znwksExUPGU8 z82rAPdk0&aID56kKpel;C~ayJ6vYoMc_evHlQE}HZ~N8l{JxLG6i3a$DUYqvYJG?2 zeR}7H#_y;l%jYP=eMozOa2XOm0GHHp3Cf@mE?%~u+>ddHbEhjITq?G5L}-(m>Zu;_ zNC3|ZJk8&JC5_5gDuUl?RIWk09giyLvp`xmi;72WZjZh{g0P1?)~wXlP$$%+UjR;8 zC_Lrzw)aoR(Ju13@*<7T+mBr_e<_&(s`@sm zfNw?3!0|krVPoxZvQ3=9y<_pfHp72p6@XNGAXuH!k~krpacZUxu?t^2frVZEWDZT} z`Vq|k#Y|#sb`0}Fm=`b-R;#!ue*V`QU8wWv9Q{>bGP^!v+_mcTc|O z3&!ggtt)jiv%b#0^kFW|9ix6wV9>%0;-r2|Ayqu~o_QsqUn%e3$i)klrxJA4E|#s` zx*~dfBYrF8YS|QV_!3`;y7Ge$l;i<4pS$#2Q+=d7yY;mG)U?OPXJ&0%rsgHMH$+SI zl!s#&{Z})i*scDN3EbkE`03>MlCaoUxaW4qWhTlzpJM&z8^M0smiLZ7v(>pd@#Uo$ z^2KToAhYgNom;$i&?iw9YM;dL2MU$1F%_8lX)DIpc(A{3Zn*AcDV7{(rN%CjNr)xZ zDR6M!>`d|-9g*mM@4kVyifq<}Urrov0_2qq2p2)Lu}Y&|?;0!j>S(<~|6cZ}@p-y3 z&e{;Re@HXyGy?xwsbE-la*y1MUF|+R+uB#_Ac(HRV&2c>D)Niy+ka&Kex%gC9X2OS z-x(^@(yEv%yzw$*)mcU$%rVixe@c{LlL7_WQCByH6R02{N~KAxodQI07LvcU;&_Yf~1^wI&m) z|2kj$vX;oJM%yFDfN8G8MMn|DmuEG_1%mH6GP-F@*e-H^;wBUMBm0Z#Ae$&*0W1LFR|W_*s`*2<)nFe_Mx#Uxdb z6g_~?F}VG8oi%^o(~;hWlkw$5U!}3gg%N;-nPdQ~sV_)>nb(zEFKqj_)6WIo|92-b^7J=a8;!)2q&4YY|F1Zp>d zhs4f-g`s-ZaMT(Qg9gPeMtcoW^%7%q`NBt{4xdQhtG$<_xkpA2D5h@v64RgQqn`0) zQ_Rs|Eo>Leinbq`;XNbA+m;4d;l%Uj%^YwSmGYVF`?^qf7OQ>%{u0+ObopG>1i%W$ z0KQa9k|#D1WQAMwsPX#rxBTvJRHJ?B)AvnMM-ZC{W+ksJUwYKN1nbN}cToh;CGH#X zQ=>|p+O(*CQxWnZ3UP|cDzEA?FzBIw-}!RS2{U?eFM z7(%OV$J;cPh2a6?{_QtKcM?qaLEj1*#EhY&Q)%#a@zwoV9T#AAr*c}NT_23J^LW2R zW|R3kLN=SccOdv$#QCdDMCKp*h)l;V&F^%{1-OJ!23)I{xkLRkC@}x- z!@Ks$N2i7UHHMGnYJ|tcDSoL2aplrvYF_IS88FP=cr@@l?CDeSDFzG`??JfJ1#lk? zuk?#893`lYWV@xmgEPUVgyUT)Ym%RYt~$SfYmuIymr6`_h6-V*z>k07t6g=4;DuJO za*DyEzB!`vke)g*&;wBi7Vjajk0CAaWnv=bI-QfPr-EUG-J0KATAC8>4hom{oKKy~VRkH=7vOC)7XADm*$0DVmPo==B>(oM zQ7W2&AC&xd8*<$~TP7*1qTBgKJ&MD==2??-i z5@#ET0nssa?sjG6_rV@=L)i2l;|Ka~aZ(_X)!W}26&G@E!d7}QU@7>{pQp`dGQ{Mw zHz4pFMACZI?T&_Uqs=hlDKyv#m#?l)}>xf8Ps&8<<|wK40<$q zI`jHoO;r*QwpR=~smX;$p~1jZQVwIH4u#ut23otM9B?xNlB7Q<0g=>_h5)V{sH$$2 z75w(XlZj2-VyBC?N%F^PA^|b=>G*d332JxqOdI>b;yMu-B(l7&6aQosdbfLkcL3HK zy*Izq8{~%bR2~NTig(VvIcNX9SO~YjG|vuD%W^6<72>Xq<$krrOb#n?UtoQnm=g_U zH6CSdez$zH+p#J?o~Gz$<7#eMMOpXwMspbW*6R<<*O@ZEY6`sC#M7JPh}8acP5nS| zFCs2G@x7z4h-a9-jp%;S^_)PpR*jy{*HHSS+)k(pZ?V>7Eh~Zu&{%vMC0M8__4CWMz3WB>HsyZurJYUTdDKY9xQLHCuCr0D+*0S|MZGu3 z*gC4hK1Y5k^iE^o|5{)SqKk7~_up5OWb4bhC1DsiWj}e*a_#qli;@+LjPCPR?}IXR z!B|x`48;vTOUh3N|V^MSymMJcvtAolerv&heJSlI1T!p4Kdf&O?LnnL}7M z7du6V1%3SpFg6yL(8~ztcAxwZ=^HRKXByzS?p+5`I&A z^uu-hJB2l!JYc(t!b|3C=M#T>n_7hH(aqn2H}I%6Irg73UWC}=o}teDg?TT)Gn~tA z`_BU-J3Z4?Zh{1+{+55Ug<#|mOWo_hm+UEj=){KroX;UCWR{wmn8$vKrp$Z2Z9`7Q ziS(S9{1fr&Dps&x4?v3vos`>6P<;*ehNs&07)%p@`6awpbDh+CxwCorCH)^67i#sU zQPrJho{awnrOJ@4%5>BP zSTlXBN!)Gsvf>CFm!(yA1@<;&ZN5SlT+Il%D9PQa+0o38bz!fspLLNad#I@k6Tqu zFU7;JWg4o6Pj^ty)8u2n&kt@;|3ZBX9!jcHQ+udrX;c7dkn`=-5#XScH40vQJj5}a z;SsDSfUodBqyNwGSjF%*gE$Lj(f@Ly!Ojr~T?Av5;Xo&9 zGrw{sDAe#ZagQrZ&UxLwZwbp$5c<-(l%VB05Oz6rP)lGBT>5gVhr!I6+4hWjWtEU3 zu^$P$`)-bsk^?rMv-X*;iZclP< z-b#X+X@*<%8(kCiG}zx&fc*$aPbedCjRCOsd3||2z^jLF#F6+L?L5zf>2(*rQ*WBp zF-wVKxa zf=9=ZEP!W;1~c(Qw`M}a1Pi(`8yC^vxfUbgI+%%(xcLp&P?583So=9(0KKAwL+*p= zqpgx;94aKBzXV%Wu_%dB1C%ZoJozMBUhCL%4*|Rz;bwSt5+ee`HD&1a*Tng~UQY|9 zABd>YQphsxae@NG-Q1h)r_riYjUHll=Y@hvs|W*FZJQ|BV|Cq$D~UH+miX(dClOAszoc_Ra3z=wM_9g zY9MY5LH5?jp3u1N=ReCT z;sZ&>e=o7Vh}{oU3VTqeaj}ZYrs494ID9c^>Q;SMw&GR)_uf#h@jqOjKe&+DG0$C* zK1u(don>mP_Vpb~XqXCavOl_W` z$oi$e5>;!@(qy=4>XrG7&p@#0=- zug$0k0#vteJ98JhVSaxL8?7jzrTyOI>~>F>l~ZJ)q~Bi4&TSRyl1QM}8cn{QZ1pE~ zOor#v_?k`SEs4ar37ZcT$JsRVY2yd;+pqlJSY)&9M@ZYUbN4Gez{}9H`2iqq`#HDB z5EI>3Tqj)qY%(1}7s8|SZE-zhKvt~RUWo-ou5vQU3s~f|LF#x9xi2Nf+q)8NZ1*uuffjCf4Fb0V zNJs zt3>?WoQ94pUS}r>e7z^^AsS-u6d^kqabo8~iSdx*?ih}Y0#d>Lkqx5N&`XQE(Gh10 z_UNWuN1%tOPHjfVa@!w@-<4<6>wkm>zH2G$cLAi<9WkXmP6lun1;zq~;ljhXXYh01 zEx4na*$y>wsoZC|`z#24I$HfIdiH%ls(6@t%j<5$#g0I0Sp(#mwu<_Ol^yjk{J!Ca z8$P2lfRd#4qlC)?vRSB)r1jGZ;9ZX!$Xv|(zoj>2?lJqBV`GB*jZ6_(cXZ5hcZ~3w zYu?m}Dr$F#bW>RbLZ;A92lsbx3>LV!iPK0H|Ecc2)SurUYgqP1pZ^P2g*auaQiG ze1+34)iO zbYX-~yOn8?{w{*cW)<;P;K~U&MYze`saM~-S~+BD4Aj}kW4=#JeTJB99xeKuB2}4* zWVg^D#g~U1KcJHYYU$#G+$5XF++}cn|LdvTb-b5wEfjb1uS+V(Xmdz@rGi+VSag5) zc`Q*NYw{7hWKIimv}V(JPxa7D@}p~WzolF;LP+8VuYlSH))d@_B;2X&*20K)r6e4i z4RCOSkteA2z!jGkbEbY>x$bM|K*$oxLV^vI=px4K(pk=B7AOo*Hqb~|tS9Tc%BrCS zm+7))jRmCa&deo9&j-Q3g~wgQ zm^nE^L7yU(H>ARiOK5_Vvpkb_b0$7{-ZS;FTtLk|UC_8R23?U|3z&-h>(`wYq&W#y z#a5pZ62dri5Pj$&;j=+pS&%yMyPWoq&G*6I+t7+Yrk>K3yWg2GMG(zYby{$wpp7M7 zh!Rf5row`}t-zNbZesrY%2)mz4H`;S$5Ut>=$kT6-?|OJ%;cx(|y-Cch zLRHbGz$(oohm#TOu%LrIDbNOtCg_>lGJP~gGAjRN_~fWK_oDryrsoeaQYg%FfuY`C zk5o>r#23|Etw&czsk2FrrGP=2!+!=ytx?e}s~5Q?b4`36f5ywIj`(gluT+sgcfMpx zx&9(4!0>H-@Z*?6nuX8g{@nbn>A5A7qKF8i8PaFG8B;P6b@|wt7YUM^@-QiKr}F8!Bj>I{W-du1b1Aq)QK=rxR!& z>9K8n_vdnsjoj^<3azIX+v8n#JV%AUFHjI2iSoaC4IHsn22!y{7J5hWPab_z|KeB9 z_h8CfJEZMR3*qh*27-N_RVN5>YZ9UcpmL0BA$a}H;Wq4k(DOJAIrFkE^#}eH7nzs& zr2jt305cdREq3Se@_zMg2$r*~KI)It1$XxVJdy|Q=6~&Y%rSHGV4n5|4K;4+`Jzy+ zXV=RqX-{A~Na6^d5??X0+xWF5R>PC5SV~vd;F-8fD8<-j;5v=#g@nw6&5Do2;}`3r z5Up-_!H3=H*mnza-hrt^feU_IG8mH?ze)6$uZ&v=o?RTgr z-vk6e3)@k~jYGA47{dociDHXLQ$f-g^n?#+7sjKIs{a!?$!5cVKL&_gIsf|zjikNJ zH$^7{2!GLqNc?@OFSY1eyXfM7WJpu?I{0G`qSMZ?Iy$d60x5K@0C6B=@QFR(li|k7 zGt#N~jVcxOrgtya?t%BAz2g9#q2~T4w*kXVu(ZG!NVbOD!0^D9+=hFMxt>kw1o9ZN zl#9vLd4w6}YM4e?!aAW{^NNlT7V+I_Jk1rbRiJ^^oQG+Z?05b_olF6!)(MXI(dEM+ z^B?o&5Pz@J9DS6r`kXC5hP8{6Lv;0T?goI8r`U6mK{v$>zaBbguI#hIHh*&LKwu{SD z;%*&WtAnA|5Vh-X>Bz5Q<9h3o+}veK;~E6kWCOnw$~Z|rcBhUPGcPVW$v==iPZY5L z<9rRxCEH9AW+fy#R)Hi#lsmWmyE9ZG+EFLi%Pp!tzF^hM83>Q+o?Or8UGrJmUU1~a zF#+!oD^6w?&AD(d#Pl^iNnsk*0|WJ+9kMAo%cwD1HD3}-2wFkfxJpu6mRg1jYm4~bCfZVqID6)mIOGyA+ zT^~tx!v9y&SBEwEzuym#5K%-JA*F=CP$Z;dsI+vA1_^19lpaXQB&9=obi*h~5e6dN z-Q7K4?EQT{zu$k`b?uMsd7i!Q`#$H~=U6uAkx}@#V;d{C+HntOFpktgx57Z#e7q?6 zV~+->4&DFL0`OKr6&vb|b4ZI+wMA460swa+5+YNLghloS+@2<^JG~kvo%N*M$_Dq? zX+18xUd!4rdGEvU*1c;?@6|Jw-C`Sjy6C&pEzkB{s{nE zsjtX^%GIg|q6H8UYttQy!;?gZGWCB$s}aMuVi|mlChTk;?Ug!PUe1 zQQzxeT29Duf*qsol$XMAK;e+oOAG2^O6(K45ZmcZET7Nx)zuzh>cVgS55(z!Zm|2? z6iD8rp%Ri9?Go>r$dEZ<*yotzW9f#`?dN2$keZ;q4t4}D5n3eXoyG$j9mmy2eo)&J zV%6{bGxdEg+Y{F#E7qn|eo}42ihwL@toF_OySS^;8zxNCvYsD&`IUSZC8w0%%TlzT zi|L@#*Vh5`SjBYJaSZj?8%pv^x0nq?KX14okIi9wW$*r92-0MN_kjleZ|HKL z(kf&Etbn)-VC zmxuhQ#;A=HM|UmSXZ(G3W2|}W9*(4D3Fw}`A~4oa9?d)Wsqr2f$1iPxNiXB!{a`7xw>dm7mx@ib?bnO24Rr&sFZa;T~c}vA~@7{K#`_ z|Au|ceJ3`zJxPtU*k1>PJFeWb$32`M(+6;M^2fW+;6=k3?zppZe-nrOtej4jxmR&_ zp;1L~cia#i>2GSvX~QX&9PnhMIy`|x(te{!Et-Fw&L}#ytbg^1sKQRnj)CTnMy8AK zGO0N_L+mlcPLeciYgrgeC2X)%w#$h9^@$k?XZ@l@ANB6w`g?hRCJzml1xNQb24Fz1CM=KS4{6n5f(lN|(ET7JG3Bz(KJx zkclzow<)NFex)dkdP=c2hFPH-o;tLjw=yGPdkZ)ZX+LP3-@U9Rzdi7(ym&HJ4y`@+ zhpKAS8Fn&wolge=StN2&iu%m#8ADdPf;bP3;k14Nr=dZValLHEzKZ4Fjtq;#Ad`DT zOhk_B=^s5Wz}Km_KA58IrH3z!mL1%7hM(SXOt5pCqYGpM=z#-UOTzjtb@`#(<2k=y z`s6!^6SvfWz2vs=h98=nm6Vt+Okyc;tuZmWljKqkr&Z`|5nu<8#vpy^vET}WK-O`k z+{w>=`Mmj|tQ8L2&1S(}s^4qjh!$9ZEgxvW+k;ApZy|+z&(tlbAWz{Yt>$hupbW@Z zgkso+x=IFf3w|^;Aq}qix@IGxR#E563#7gm5rBX{F0c}1t`&W6FGcxUT1QrD@Iqbu4 zaIBk3DP67L>4J39_sS6~ZNIAf{U3<0d;$8NpNR(L>ErqBZKuh+u50OZg-N)8ZT`;` zg6xXSF-5$$jC*BuBY_|a!v_VS^wkGOgUBe&>cLst4)`NN=dQv+T5!3eJ<}qwAQD`C z&uI@4?UKM!N6JsE)XqSAV8Gj%NmAPXNCa)?w`}#Twb-xHZVJ$2uq5B#Q78Uy@)qAw zkj==vrovqvwqp`lm9&{>$VNWkH7WHKOitf4+IyYgqBpt|>P=AY>z*cmWP=_ba= zMY+3hQIJyMrJ=x$8X3A>(zGG7@J9)K&rE+A#ZMnb=~UJ8t-Lj9SK4u?58p$e8)EFj zOcE>Yn#`b(&V3Ipo9)#eJciXDHZj~o;8*Rm;KGflmMJRZ)Ja!z(o$=>A!emO>0kA_ z2YD%S6 zG{JBPd`Z34%FiD_f1mK^`?P%i544S+jJqEXorW&wF5-W>hOas{Fdwm!Zuc)7+hd_Q zUFqCz*r6k--Iw!SSxTKPrss;pd5G=GW1ba)De#cVKd=)kYfdF~ZS{31PdBI5)taqD zu$~XP-wl1Gc3R$f6<#pgJ#owvLCpO6yo>|tK30r91$61`J_YeSE9!tBGJlh4n>NpY|iC;3a`{mdeu6RT^ukXjdq>h}GLLASY)hu+XXr7PAe;t{ohEY2IUu)|s?) zfW$jlurG1^cTE!P4lvjH>P>f`z*|E{+ecN`!`Y&PRK;P(VBkk%ycG8S1Jx&mmWUC5 z;5_6}s|ReZ_nR!I`=Rlkt@+~3R+!ZDW*y+^4ZZGF zE3SMRa3+6Gh!=Go;+YN4B!mM)_AzXU(K~=*@>Q;r{dxnxIlP%$H59=o4X3Z8& zOn`E6RY`h~<%797-Hbco)79m)r%{T#_7AM=iNSCace@|UclfK$3g2ftp+#d!%-ao7 z{mX10cNW_dVlX_!-A8ppR?FW-@y;yGJDB=ScqGN*LrE^IM?c?jPiXp+_n6KYcGh}b ztrbKeEdrc0mKv8g+pB)^&+Y3zp5X+^Ao3X8X)Cur;O;|@k8a&o{kOhbDI-Hnx%SS4 zm2go9tG@%)Rz};Mdv{#b<-@ymuB#b-#+gySuS3)YDWZuH($gl10X>8IOAhu~J{Rni z8#DAyOXwYgNzZtd)7-4z&Hfy}g=t=&MNjOBLBH1L*1{x8@@+{nt&kXxIcBZ_AL`^4 z+h~5F7-d+Y9>$eK`!RQ3unI+F$s~V?%?}ZPG-i2DQc*FdEVEg!QmB?x!QRHp{XDf= zAJWh_T2Y>M-ff3$U}dx$ojv6wfQXy5kX9vmIZUEwT={e@8fK*u@X{Hp2R&EI%arKv znIpx)Rq}!XBBLgtY%)bw$N3B`A?739+8NOKP&ESd-_-4*qV61#2}R?8zRbTAR+Ky& z@8H2h^g!H|zdWGLD|-LtMsjp^kzp>AHP-V|YGN&O{85NuDixV~NrMn!-fX`yxS7i| z!D46Z__EZIc5Z->E7R63k61Y%@wc6i;6Kezi@ZIp+qSpSIv}v7G30d%kwS3>Pt=Ou zb-!=+8wro?lkfxDWjm`@)2Zd&4*ve&wzfjklG9aW-Ml%7j(~eKl}*1x65jL6R-6H)fo=6OJT5=_%^V54d81?@lq_c> zZ{9S7nT+}Kz-#4%u`r@;@zsTl;;9%hvsXak6|l}0v!eO_O957WEDvCQ^9JP|?q)I8 zU8)ks$31%rw^u%Ia=l!}^Zbwc!QE->bUlSCZSw626CPY5Qdzk`I*-L=&^L}~O&j#h z9JePk0tN;CwWmKF;mbLhcz)-TE6gbsS!>mUzw!rLj>bMdyJQ7-;eP*k3-t1YB*(0A zF__G`3|)Qe+aJK@<+gLWc++dG+q>}|o;CE2`WT>{hKy38W1H3--FyzvH>ziMV=;f5>?+-V zF%LRp!H>TDYHZRLIUPg7U*e?VpFZ5?!0E4n?svQZC?Z?gmsh6fR|AD%jb4)%>u*RGM0iOu%~iQsE1BS(Z4=^Rr9P&PtZ7xEOx~_@&{G^41n&o&$N>tS?zpF2}nFjb`t7~ zo(7*30@2JbvW`#pIOyZ?o2s?y=-QqL7sX+J0w?X;3!V6`Dnm+K+cI1Xi=0@<9Y)=c z4V}AB3utZC=Ek(nj_S%qGHc@N+l5;`3MVel&4d#vb8Onrt;~8B#59$^V*UZmX~ziV zb>iSXcc$HBEqXgIr9ubb)?@7h4bVT_*LJ`~u)}gbJ5G+2fUz8yR~d5M+1~_YI(8eL zRFwV3qJMwlZ`FLzhjVIm)2rl~%~A8Qy4zezmn8hC;w7ccafmnr3%kWB0X7`=C-}B) z^sP+`4Gs>__ojcsu~d28uJ+87eeSfb^jG0daw738x^J$yS-4}NK`C})?$AHz}Y z-vLAnf|)8FpgdA=d5q=$`x`yGHqrK1s-G>iSa{oye6{J}Rb)15uQmwtKWb-r9vU9u zn1}hbdS97&!5}XHG1kp~aTvJFYw!?DSt#Er@q}-- z#raPxCQmnF&9Em%GO5K*lwE)KTuRC-C3oo05Mq(yI4s%Cqs&g|vYYi&S0iJLg=tqM z;MKGy_wyuq?fh)Jfy=|u`jk889%YvE@Akr&S=c32@MY-mX8i=uVQxn8=>i*fFa~xu zbi#qVFLJ9@N>dR$s`?^OL?L=u{FYx#Sd@2xR zYnt9hq|AFj^pTG^UgUY{$ZUprLv9@!rCL8HujJRC?JJOOuRK0H^GLEZ=T_`sM~jVi znRLL(Kv#eTC3_ues37NETHPJ2>A>9qF8_D!6Z;KDHB?(%N@&QG$inJT@HWO!tE%0G<+5pm)x z{C4by+QH*=WyF0n_ut_CH+=W#G+3$T5kDWStsDHu(eX2Z+`I)=Dv{v?8kKq{i6PxogBDdS%7?mhnL-{{_gV{8C)Rs74y3O0>hoT4MEd_LMJVyRZ z9~0Ga&OnhW_-7{JCEHKl{hDx<{fN$&khi6+q@jHGA?KXdLCI!o7Hr}&S;Ldv(XnT2 z_3t%Z4|is>rrWR4mgyI%OD`h5)@I#~RN~G0=CpbdjT$>T|76O2-pm9mO-Xs*?Okl5UmQn&_;tROggVomy!t6c^i@^c)<1 z^;YDLyAw!x?jI`%SjZ&$MIbWu3VQyGd${cxrWs0O`;qDERT8U$k zofg2ODKYtu8+%omSt{%Xj3=zDba9Gn8x@9sLUUsk$L~2oN%w_0Qkh2^W9WJ*1ooGD zr(WqC(yYUU+K<&s8ydeG#Q8wl?60FR1^+UcGMJl{rp_B~zZg4@2=>W;{@tD$^SwNZ z4k&Dtul&i0y3|rHFs(qZ%{tTu{%xouhdVm2JPOB=a25JzT|(Q&`Thej8+73i+!#)b zYC4Lyd^q?9c7q2L1oBtimHV=G-7cGUUuf^Rx81&!N<>=UhEUwn4)vPCa5_N}O=}bX zffDs?13ZSV(y9tmoZqRrnZ8-~Ytp@OS+BnE!5-W-c?{KhsqKfX1<3jz`103OvKCkN z#mr6p$x#;ba_-tO5}7Mv3e%^bP-O03Iw&m>T>A5Y{>3wrJPQQo13EN3?0KDp_Z3Nd zxj*z)&Hh!XpJlW8j2>y;+VOlj9p7GpTk>foCeB3iW%aIeT29GsYcr=}~v{{vs7=`YN}^`V8T)?Ra1u*b(0U97niuJEN0uY#8XfMtm;k96v@Kh)KV;O z|J+X9yjZ~U)KBEt`=(IKe5@-a+Y&rZ?gk_f*868C_B~n0>2^2X4XGHfm1{siwFqwh z`}rhshn+sNO+S6``_+Z~ao_P@;`Wm_4JjL0tjB_PhrqoS@_R)S4*$eV*2Ty1FQmWj zybldU1_XEw;Yh$ayG%mCx)}Y^C}ve2vSG2KYxyht8}>V(Y4~(U4)lOscO0E1n_IVH z3m@kfjwQu)ap<^(^iTSc_M=fE?(>tQ=jCcTvmZ^9CtJx?j;XxQoSIf!O@@@h7VzbA zq#5uGxNJjr^v-H~qwd83{7Q&s59cW>!=nqT0Qx$Me;=-4$ksNRd)g-NSWZ6tOoP}m zn2lIy|FNLGEG$UM@m&4&syGIz0N%Gs94z{3qtztqd%qz z#5D8T$-aYS{f*(sumH=kdA*YBQeA~6jI;pY)yN&UUSb|+7wW^OP#QCnL_*G}M}M2j0lP!g2Y)ewM7O5)Wx=UVdh&#e>=Eh6&KDOp zm}i$eh{=}^$YLd1xmZ91%|E&SR3D3-ysf=w#a*|Cu&fEOnV?BpO^K5`iS3G>kZiHz z=(+1qLnFTSPUk?YtGP5xsUVs;Cjdo7NQs z4A_Oap38%`>yP;e1PdHvBPsO93(|xN^-J}xIuaD1h9@n-1qn|NafSO*utEk2LBel~T>%}fc3rRTSj@v3$lpv>q zc$GNpm4wd9_apM9hkYId-H~uF-QnWmmpIraxq=v< zAa)XKYK0pBpq8)gx#V-ET8dJPyET>K831SLA;t6e;a4+{A8pCOl#F@`;_y>iA!X0Mg(~fl8zpC{9m(-2!7qON*`8WP{#(&+ioVF+%d{o&Qd&Wg z(-`&reVs%abYx=8P?3Mb4B6{uTr4gw<14M+4w*U|`+v71x#g=mtP(7 ze7geT6>Ud!m|??rjX)RgxTAXmv|2evK)p100I;{o`JG?9WIT7$&W>iXE_LfL#>)@6 zJ9ibsdY;n~wVjSgP+AwrL@-zc?U+RA=Kk3CGgcHG7;0L73JrFu;M*iUAAoko_LR{1 zz={mEcS$%Rb$6#_eu`EIYZr}5bV~@|V+{p6Ep?SV!jF7@C?P5-nuxACUOWE2fjp%7-X^soa68@+3^K!kvv{B5?8Y6_U5S+{*=&|X z!MU+?d1iD6N=VC9sR^66kGGhS3RT%6CfqXnAAG>|On9`&Q&q3h>Obv?B9O_xl!W>$ zrShkz3$3NHu@v1pTww(Sh8w;3@njv8-AY7W#q!LrKrj;NzvqQ`^IFsK=SYUAf(E7D zh<%xFZoxga>yG07fJ_5&rZ~LfJI2rLaBpLf3$U@b>s!j?lJWulH+m;ESN%ZO@nAV5 zQ(X5(2Cs)(T&q@qcG{&5Xo=5vQViI$vc9li<0ue^DNZCY}67pU&AbvFB^4m7JZ-grsoL_mq=FkCxBbj&kwK5v}cV zD?w``LYUpF%JmZBqsQsydum5N8OGENh}RP@;3y1-J5hhfJN+T6?|^Rp`F+}<6$8&| zb6&mmF-;+sa+%07UXil-GTVD<51ShV1$NZ?nb33t1}deIX*Hi@X+^rN%_qZSG3gzX zR|+PCT|Te@DfZ)^6B%V1B%PIwpW=)FYgjILp1z%(%y!vB6=eoXTeYXlLqS{m=Hd$7 zmJ3{QO6{ru!pRM6qy1n(4Q8g-UxLpzF0;NA*1mWB1zK(SJFO^vS!D5C!zSP{yz_k< z9W$BVhq{y2NGnIwx68K0Ogple;i-)mmCzX}9-CxFAtS@8q3k$?%ALZ?QH+BWtKXAx z^V;ton;GqPfkA^7>j3`R?zL*l+GAjR>3IN|k+2{mKQFhPKL32?mpN383m0QNZamhF zcZoZjH5A6ZH!xN@_b)iM&i6PK4u{&p@$~#SkJmph_yd_(LL-RxeY8uef>0j zGj$;hoa5`q!Z8=nnXazMq-KY{L?>guP6?Q?*oRs1JcivCtB``+fS@^J14Ef=X`Rn} z-TGhY`!{Et=^1We4H)4|pK*NTV9Tu=R`8rU>UgeemY-0YOxC?PKPEvt%VPcE>y@4y zpOu2h7zbF+G?reTe(|OCIBX)KKyNOvs)q=oSset}emI5w4LWH@v|A2^EK$75t2@~Y zwJm-r&$abT+c{bsFsjbzr3R|n5PLeN|uL*24$Z)wtJR?!Xyh>at85=^Bt#$?5shb0-syb^UDgy2SPr--9o`Q zh-tgE^}_h~_cV)?KihyB?7Wz}5YvARzky6!m2h9b{vwjz%sc3Id>FxUoo8ZK5Bpb| z&0vYJRj&L82@D3gGmIA8pRL=cUPh=FW@nQa3GJK5r4!KhK-6BwDo_nSD8A=j@H1q% zw?f!+N0SEKfaGVtXPsRbVsrq`x3u`UoJ5E0JTEAz=TiktQYii#yVLyc8YsrMClhxR z>#GyQEOqgbWo^BZ;YQ)l50PX#!5k{Pr6@TBj8xv>7cCvggF#zVuUMSrk4DJ}Y(=)C z2&}G?GMA=ks`ccbM#X+;U+E6hQmW5{j5xU<&$&-M(bvbsf-ZV^jk*ePur%szuGN&W z>Q;v7HH4dTaU_!@H>oI_#V?d%3)4kbEwO;_f$98eijyMoX_-2((*o&Ch$JoQ>2dqn zU(R|!YV&@!n0THf|9vdqY+dxGY>#a0shBT}Vl@VDyNxBVtb=uVCERXb+D|)Q7!;{9 zU~8sQ9y9bG2v9FPLM@Tm(!jTu>`?9y1-fg6t!S)g<(k`N=`m_5B;d92j-%7%$jiZD z?t-C(roXXmiMSBT=}ylu`oxF8rzN5Rqe!!W3HY%h`PVE|cvM-7M~NM5R&by>B-X%@ zo8_$Y-ji1QA$_~8E#=GOnYphTX;Q*>ap%5{A6A&dopmH;*lXWL`nZ@D;0Q6Yt3N%S zC>)S!SY(RBM%(e)tzv?gKoX1vUeGSjHdou@Z$I>LU&Q^7xp(K(YI}kl*GO8cc)2GM zoxWYe!Zzc5nRzj8mvF?y<0a+STkVCwsHx~u~)1T=Vz8KNdNJ*d##AbUb?{wEu3={~E>HcR@3Z$|=>)1`Te}9;mrsKU`!Jix|%o z9E#3DAr9%O0j(wVbgG~p8~ndkZC3wxv`=?)id)_!|^CT!P4 zA@UmsUdvJ_FqYSv)|s9gLkw!6S@*Phh(3S6KF=O>m8)rxwDbStUba0G)LA;yyBf^& z$&kztG~V{+t8cI$?*9N`Str`5xbZ6v_QiAJYm-Nwb6m>I{h#uia)Q;ztTSUY^MMb!5Bc^$iB=mxT)hTP!{_{cf z^OpXR?Npwhz)3vYv?s0Y^rA9?0L1;wrP{wDf{k3s>F%$0bWuO*rjf86hLYg6(2zF*OanSqp%7p2=}r46HjZ&+M?CVpUj&5LC` zFV0Ivuid1HQ%HJjX&aZ5BUQ4(upI?r$icd2VY_M_17!WYh@gI7yTS4vVtN(%9dxE{=`P6G+o93J zWh%|FG6d#aLzG3!GZHFT?n44y29J{RdkKOxxWSu|KR|Cl$1|&Gs;YQ-8{`O!XAk3~ zDduG=eyLB|&nlB}4LF{p>CQ!}6TKI}+d;TH3=9}Q$KRsr$fl@sktUi{^%>uL&fQy( zDpYyyj!|;gbXG2h1iHGtXMQCRsT-I4mZIiS zK>{Z$_hfn!Ge5%OLv4ocHDFB475wnR|I&6?p{y=Y>7Zg?DF& ze>!F}f_bEYcmzmN8DWdJ_>{kLz+yykZFXPZ&@Orp7)J8AX}z$teg9P`I1SZqI~O_W zt~N-hjGVzGraWZ;Tq%%D`Xbx5aiTr*<005-s)shDNL50$jL*MwWYL0h~H&E{kmw6EJ`=`IDUx%$vNLP!?6xJKP^$O@ z{RE^O+!@F`!k)m^q)Z9g!C8MBSu-a7tq2JdMEcvKCBNQSRuLdom)NZTZHs9$?@n#) z=xjC-@dnd6aT4e*zUxE$lrqr{*5|Gl!Ty|Htusrg@i~#E?s65-rI&=2OGkySB6V?; zi-GL#Tm@hn^<+A<$EvbMMsYEApnwDPY#D@X-9>*<<8~y&;#z0gZ=#FLJn&^ryr$fv zNB&%ypZFD5)3QVfWB!V%6GcLW!5&&DonVL;*eZXE7{$+UU!uGWvTmo?kEgwM9ewm4 zNXLdH8pEvgdET?;<&o^~Xj~#^az8vdyJ(!#$yaMaogtelHNx9K8W~@Ls3rMGyEvh! z_&YlDcb(S8wa=&6C(ma6qg44KAB_+Ek{?5o=(H)X2D0@LBNh8@#_b~y3L~x80fQ(A zue#0Ba9Y`)!?m+nSO;x$hvNU>;1 zLZx@^10bakKJ-Uj)=oCkpwC^EN%azJj6p~h>`D0sVe#-4X4w!#vRYUbF`-CTRekbl%B4w z4q?Hp9j2r$^1j)-PZ_(r=J+B4tdx7DS`NU&0pm+`f1b*Hhv(YDG-r2N7%%O<{~XA` zS1SZ#%9quGr1&xafleT%)Lv`<(&!2ft_s|uLHpdNkJ#26RE!*W>68I7_L^BE?C*|d z?(VAE?(5~D?sDQa=8$qfjl4YB&5j=lxSfv~uu{LgUE&=Rx?4%?({>DOxzxs)kM8vA zOYTuYkeTmPW35j>;>{rwzxHa0vlU!xLv(E7A%9R%H0s4NP26bCR%r&Q4@FEm(m})I z{h6g!10@qph0A%7ReBpPROimUGhyPS7-U%aLy3r%sQ-@Z6MtphX8{ zU~ZeCE|KvnXYqm>iGf+!1J7tn{sY-)WJD*FN;v!qwaLF1_3VAH%EBf&Qoc=inUo-M z`9#~gm;P@CB0&U=pZhoZg7UE%6IM!u9--6 z!pN~CCyaN(>7@`rIY;|)QQli7I_S1ij=jt0+M1IH(5ctKx4`_;mE9_b!T$8)xM}M>-SO z$~G^c!~2V-W!;lTKLipw`3zX6%5|cs#+RcJqEy&GwwF|)%!y(X{YBR<8c!ZrgPjQ1 z*vw-oC6xXl!=w*jKiF?Ch}P!J1AYE7(0JnT8-Ts z;0_W_`6`h)ktkG4%3y)CQ_5NCcCC3w7VbLfB>S!#bt0JYs$Q$USzJ}WX|ZF zP3*3gw=hKLvcj}GlcHth&CKgJ-+$yWR92!s>gMOsun%vCiU{Re_-Wnc_z52`sby(p zeT<&>`=vfu3IHh6JHIun^++L8Wc)(mUav{19>E)1gn#7)( z%=~9yYAJiQL}PLg>=Yqd)i@(gw+%-|Ilqt~RK zqxFOD;8bdfFkW4w{nZc(wKNCq5t|P?6&G1`(@zdYwHNqb$M=M zyl;oM%ngBF-2AMX>*Nbr#k_0%yush{baeZ6Lvw^BDKpyV*QIAhrc|5Y!Xe#S=kaq} z^$?vr@D&pxO|JNaT>Wjiv7)yWKDEdqx~7P+sdB1)=F6pP^|4SqaYT!vQ-oWU_v3?( zw>{iz&eLCT;EBl=LDR>aX(t@&lw-qF`x^&$Epb84Xa^tId1dH4G_1?aBI^5Qv4+WA zOY+_oGvUwGU7fO>ep#ka#&_30mB>e%P^_YYKCnKK5;9)T8`$pn(2H3;FDr}LJTr2J z0ICv!ETq6ESOK@XQkOQv`eo6g5d)B{o=4ER*2)(b0c7^E(6@!`M8Y8nAT>cvfft_R zf6)YA>};o%bcd@z^HiFm6h7)LnF&82RdX!ChQYD9IwDuO@AT`V=heZgj~0oM%EgWA zoY{eTee^0^GA6mmuA;q2?I(=b!)&5MWtSkOz(*NlpLISH$tnbIJd89C1g|X&h?+~c z442*COt>Yr4GcLrP|N~0u)F_Q$_A>b**zztA{I~eNgGi}+Bdl0DrR1K?O$RlATdTG z-ngujYWjgh;?K3LxO-c|W4G=XliBisEWx;w5i&}BU;A17`aO4+EVAgPs1Bp2Cqrf} zKMr=?^h};NHBtvy)%niodx6}`t*uBEyIBiUkbF~Pv+)mDGsh|uQQ-Onuvrr$l!tp{ z&3s#+`*WKAFSxC9D&U`_wEv8J(@KQuSyLX(L^` zM7|#2`;INzdtsf~Lm43Pu)$K@5bx)w@>M(1a&tRJYB8Rkaic-SlqG9viLOihj}mf! zyde!RNuXdeeV@T>s^rV+K@j6(Y3)o9IZ<|bp8dQ@=dy}4Kx?*KzxLb-rGJv)URs@d zKtA~c;!EAXjHoN571YaBNH<8!g3|SHrKWXGF^eppXvr;g?dX_0td`MEuZ^{W^r2i` z?!|9=H%kFCRungl-tRT3alB`d3Mo`wRtz<4`Br&QuHxL~8*4CS{7Da@U5(DrU4suYckV zt{mu@o6j?7j40Ws3Y^$qUVI-q3ClcQcdFF{?Rr*K{)Ah=$!*&t?g{{r?*l)h=IH+4 z$`&r$94a_3b$sp2sb&i`8<$;pZMI}EA{Wv(< zat_!!X922i>}&sJN@UmZBJIX4dsm8iV~uV*>d7OG{GORyx@(`f9n)S3=Omp@3wBK; zk4z{mW1hGgI zyj>Xi8!%{)nS=Bi$>bu9c`fNSC^w4>#uAqr+cQzswKOsdWcS^`Hm$qVX2#*a6@`i| z3WVYd`#Jg;kg0#{N4|)V`Mbgs12Qoti{cMoaMPQ~xNz!V3E}3SrDU~u{uX~M^)tX$ z3=O(qP^J`+xaq}DXjQvMzN1wPTKZMo`((rwc$={m-i+knC-n*swM2Hj!-R4&+R5fjnSK$lMxyh7ktA%$@WbJ9P)jpO< zTikI82XYxP)y>oF^+4kJWxh8)s@rcUhv~8OnTw5KzqN8j0-U$vSjC^oy;=q`k3s0u z5fyaSXyejDBZJZ*WLf7%$fmS7>@zDK&Z)7I;gaN!WrKvy_T!}0w4$cYZX*Q0s&tQJ z5ncrCy=vNWP9t~0tlQP>0+CV*Y6tG9e08F45OT2V>-3}@9ZI^@@(Q;!GCJG0x(jn) zekNY=aniS}U=f`5A0ug<1kb`hMX-lUh>EO5qB8VVD;M4~cD!@^eEHtq!W^{nr(A{D zwsjGyuV0($s&i;rw{7?NPAjRtH#AV4ODqqw=RA)2JeSsBw$wDYZTBkp2V~$EpHZdV zw=1Il9HG_Rna$eQ7Me+HW;mVaw5Cec4<3N=S!e4Kj*N3_El!WAJPu%Sh+NS+iBSU9{_LO1d47Fd(T4S~w!dxz)gD9S(@OBg_|bk6}DJ)R@Y zY%r*?doM@MRZQoPDBT_8o4JUf6}!4_WWIg($Dhz%WXL6mW2GV5}e6EFy@;Ri@h!JAecB6 z{j7L5-)E{Uc$fh3%ieR>lhT9<3dzykb&(_$nXOiQ5#Z+KdnwwE;V$Z56{inx7lt`{ z0B7bFbe@d9ke0#4)X>Wrou+(g`l)GT;OFnCLQ7MwXBLPnQD@#U`PVB2ucvcH<;Urjo;O z-j|9aRIW=}I|hh3MY)pFU0X~3*Z(qZ)j!B=dJJ|KOFwhd&l$fP8v}nCH1g{SLYljU zDrbfxBIF0o3#ipQbYl1gCW9in-&wt$V=VsHOHX^FWk{DD$|#>LI;TqZ*=tZ-`vY1e z?;mJuw1Q;dM}g7VhvC&Y)uUA35mrcSW@6DdlC4ohqypbLa*>k&phQVVMrc-@a<%c& zHL!aV-QFf~|AE*A0OY;$fA5KvPU7|{i-_LGVFmLuWE$5HgQgeZce5j=7xw1`GMDtP zZ`yySf5f4Zt2EGCs>yezD=Jn0flQVTWA+sHKVVe8^$-y^oPu3TIMn*DNgLixfAt_h zWC4R2X{fmNQa472m*?3gQnfuw?x&s19%dAO=~$Z=FqP@@Cse_L$)G34OK+v07GD(` z#7SLnW#4DL?boF|obbY(PK4an;R*P!=+=Mr2J5sPmzozCf&xKatCfXHd5e`CO7>Bx;9c;A96jq%_p} zA1FrnMpx(3N~V?OaRIa$^4q=S1YWFmC99{JuvSCK#wcr~nyBmnb`jG|`pqP!U3HAr zHZIW%xHAh(piG19sW8fl-b@Zp<4ci)&3Sq4nmtPS&IQNoS$rvOwV;t&JK;wNi?9wK z9GD93gOr4mBA~5rGa6pO34xHALO7vNv4f(;Y}jpkD3$O679yR0XS;XD6*HOHKH6Xi z+U}c&E*4wiAP8v7|9@d-J^iTNVBAci`Crv`0IFzdcN?FW!0wJSh3pmg-NcQ?>$lR> z+Hgpv+m-xeS><`jlqB)Ex@7~qbF0Qb5G$C~f7kL(R$G`o+9Wp>`f3O%;X&2ccC}(i Vg?p09A(G#heUV>ANBrN+{{c8W45I)5 literal 0 HcmV?d00001 From 9c44ff404ee8ab6f1c25dcce3666fdf8ca73db88 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 14 Jan 2025 19:48:37 +0000 Subject: [PATCH 80/95] blog: update --- ...work-large-groups-privacy-preserving-content-moderation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md index 01306e17e4..82201f240c 100644 --- a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md +++ b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md @@ -2,7 +2,7 @@ layout: layouts/article.html title: "SimpleX network: large groups and privacy preserving content moderation" date: 2025-01-14 -preview: "How can server operators moderate end-to-end encrypted conversations?\nFile servers can't look inside files – they are securely locked. But if file recipient gives us the keys to some file, we can unlock it and look inside. If it violates conditions of use, we can remove or block this file." +preview: "This post explains how server operators can moderate end-to-end encrypted conversations without compromising user privacy or end-to-end encryption." image: images/20250114-locked-books.jpg permalink: "/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html" --- @@ -73,7 +73,7 @@ How does it work? Let's go over the process step by step. 3. Once the servers receive the file identifiers, they can now block the file. -File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes – they are securely locked, and sent in chunks, across multiple servers. But if file recipient gives us the keys to some file, we can unlock it and look inside. If it violates conditions of use, we can remove or block this file. +File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes – they are securely locked, and sent in chunks, across multiple servers. But if file recipient gives us the address and decryption key of the particular file (each file is encrypted by a different set of keys), we can receive this file. If it violates conditions of use, we can remove or block this file. It doesn't allow us to access any other user data or files. In this way, the moderation is possible without any content scanning, preserving privacy and security of end-to-end encryption. From 6b5a1bf25d2944fc3ebcdff4ba32cf04e9246918 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 14 Jan 2025 22:37:02 +0000 Subject: [PATCH 81/95] 6.3-beta.1: ios 260, android 270, desktop 88 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++++----------- apps/multiplatform/gradle.properties | 8 ++--- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5a1bb05d85..e6f301a52c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -519,9 +519,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -677,9 +677,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -760,8 +760,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */, ); path = Libraries; sourceTree = ""; @@ -1943,7 +1943,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1992,7 +1992,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2033,7 +2033,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2053,7 +2053,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2078,7 +2078,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2115,7 +2115,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2152,7 +2152,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2203,7 +2203,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2254,7 +2254,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2288,7 +2288,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 258; + CURRENT_PROJECT_VERSION = 260; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index cfcec12593..6c04bf65d7 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.3-beta.0 -android.version_code=267 +android.version_name=6.3-beta.1 +android.version_code=270 -desktop.version_name=6.3-beta.0 -desktop.version_code=86 +desktop.version_name=6.3-beta.1 +desktop.version_code=88 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 From b070af5c747a82e397839d01284d23d219c95123 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 15 Jan 2025 08:23:29 +0000 Subject: [PATCH 82/95] blog: update --- ...s-privacy-preserving-content-moderation.md | 94 +++++++++++-------- 1 file changed, 55 insertions(+), 39 deletions(-) diff --git a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md index 82201f240c..ffa322aafe 100644 --- a/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md +++ b/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md @@ -1,23 +1,23 @@ --- layout: layouts/article.html -title: "SimpleX network: large groups and privacy preserving content moderation" +title: "SimpleX network: large groups and privacy-preserving content moderation" date: 2025-01-14 preview: "This post explains how server operators can moderate end-to-end encrypted conversations without compromising user privacy or end-to-end encryption." image: images/20250114-locked-books.jpg permalink: "/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html" --- -# SimpleX network: large groups and privacy preserving content moderation +# SimpleX network: large groups and privacy-preserving content moderation **Published:** Jan 14, 2025 -Many people believe that it is impossible to moderate and prevent abuse in end-to-end encrypted conversations. This belief is incorrect – there is a way to prevent abuse and distribution of illegal content without any compromises to users privacy and security of end-to-end encryption. +Many people believe that it is impossible to moderate and prevent abuse in end-to-end encrypted conversations. This belief is incorrect — there is a way to prevent abuse and distribution of illegal content without any compromises to users' privacy and security of end-to-end encryption. -Anti-privacy lobbyists use this incorrect belief to advocate for scanning of private communications, which not only would fail to prevent abuse, but would make it worse - because our private data will become available to criminals. +Anti-privacy lobbyists use this incorrect belief to advocate for scanning of private communications, which not only would fail to prevent abuse, but would make it worse — because our private data will become available to criminals. -So it's very important to understand how privacy preserving content moderation works, and educate the politicians who you voted for and who is currently in the office that we do not need to compromise privacy and security in any way to substantially reduce online crime and abuse. +So it's very important to understand how privacy preserving content moderation works, and educate the politicians who you voted for, and who is currently in the office, that we do not need to compromise privacy and security in any way to substantially reduce online crime and abuse. This post answers these questions: - Why [large groups on SimpleX network](#large-groups-on-simplex-network) don't work well? @@ -30,28 +30,32 @@ This post answers these questions: When we designed groups, we expected them to be used primarily for small groups where people know each other, with not more than 100 or so members. -But we learnt that people want to participate in public discussions remaining anonymous - it protects their freedom of speech. As an experiment, we are curating a small [directory of the groups](../docs/DIRECTORY.md) that currently has almost 400 public groups, with the largest ones having thousands of members. You can connect to this experimental directory via [SimpleX chat address](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). +But we learnt that people want to participate in public discussions remaining anonymous — it protects their freedom of speech. As an experiment, we are curating a small [directory of groups](../docs/DIRECTORY.md) that currently has almost 400 public groups, with the largest ones having thousands of members. You can connect to this experimental directory via [SimpleX chat address](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). ## Can large groups scale? Currently the groups are fully decentralized, and every time you send the message to some group your client has to send it to each group member, which is very costly for traffic and battery in large groups. -We are currently working on the new group architecture when dedicated group members that run their clients on the server or on desktop with good internet connection will re-broadcast messages to all members – these members are "super-peers". We will be offering pre-configured super-peers via the app, and you will be able to use your own super-peers, in case you are hosting a large private group, and to have better control and ownership of the group - e.g., if we decide to remove our super peer from the group it will continue functioning because your super peer continues re-broadcasting messages. +We are currently working on the new group architecture when dedicated group members that run their clients on the server or on desktop with good internet connection will re-broadcast messages to all members — these members are "super-peers". + +We will be offering pre-configured super-peers via the app, and you will be able to use your own super-peers, in case you are hosting a large private group, and to have a better control and ownership of the group — e.g., if we decide to remove our super peer from the group, it will continue to function thanks to your super-peer re-broadcasting messages. + +This new design improves both privacy of group participation and censorship resistance of the groups, and also makes abusing the group harder. ## Preventing abuse with anonymous participation -All public discussions are abused by spammers and trolls, whether they are anonymous or not. We have been evolving ability of group owners to moderate conversations by allowing to remove inappropriate and off-topic messages, to block members who send spam, and to make all new members who join their group unable to send messages until approved. +All public discussions are abused by spammers and trolls, whether anonymous or not. We have been evolving ability of group owners to moderate conversations by allowing to remove inappropriate and off-topic messages, to block members who send spam, and to make all new members who join their group unable to send messages until approved. -As support for large groups improves, we expect the attempts to abuse may increase too, unless we add better moderation capabilities in advance. +As support for large groups improves, we expect that the attempts to abuse may increase too, unless we add better moderation capabilities in advance. -v6.3 will add ability of the group members to send reports to the group owners and administrators - the beta version we just released adds ability to manage these reports, so group admins won't miss reports when members start sending them. +v6.3 will add ability of the group members to send reports to the group owners and administrators — the beta version we just released adds ability to manage these reports, so group admins won't miss reports when members start sending them. -Other features that we plan to add this year to improve moderation: -- message comments - some groups may choose to allow only comments, when ability to send messages is restricted to group owners or admins. +Other features that we plan to add this year to improve both usability and safety of the groups: +- message comments — some groups may choose to allow only comments, when ability to send messages is restricted to group owners or admins. - ability to limit the maximum number of messages the members can send per day. - ability to pre-moderate messages before they can be seen by all members. -- "knocking" - having a conversation with the new members before they are added to the group. -- sub-groups - smaller conversations with the same members. +- "knocking" — approving new members before they can join the group. +- sub-groups — smaller conversations with the same members. ## Preventing server abuse without compromising e2e encryption @@ -59,68 +63,80 @@ Some categories of content may be prohibited by servers operators. An extreme ca Many people believe that when conversation is end-to-end encrypted, the problem is unsolvable. This incorrect belief is used by unscrupulous lobbyists and politicians who attempt to mandate various types of content scanning under the guise of preventing CSAM distribution. -We [wrote before](./20240601-protecting-children-safety-requires-e2e-encryption.md) about how such measures not only would fail to solve the problem, but would also make it worse. If our private photos become available to service providers, they will eventually become available to criminals too, and will be used to abuse and exploit the users and their children. +We [wrote before](./20240601-protecting-children-safety-requires-e2e-encryption.md) about how such measures not only would fail to solve the problem, but would make it worse. If our private photos become available to service providers, they will eventually become available to criminals too, and will be used to abuse and exploit the users and their children. -An absolute majority of CSAM distributed online is publicly accessible. Many large tech companies failed to act and to remove CSAM from their services before it became an epidemic. We see as the most important objective to eliminate the possibility to distribute CSAM from publicly accessible groups, even if it hurts network growth. +An absolute majority of CSAM distributed online is publicly accessible. Many large tech companies failed to act on it and to remove CSAM from their services before it became an epidemic. We see it as a very important objective to eliminate the possibility to distribute CSAM from publicly accessible groups, even if it hurts network growth. -When we receive a user complaint about CSAM shared in any group, we remove the files and in some cases groups from our servers. Our approach to moderation preserves user privacy and security of end-to-end encryption. +When we receive a user complaint about CSAM shared in any group, we remove the files and, in some cases, the links to join the group from our servers. Our approach to moderation preserves user privacy and security of end-to-end encryption. How does it work? Let's go over the process step by step. 1. A user discovered the link to join the group that distributes CSAM and sent a complaint to our support email address or via the app to [SimpleX Chat team](simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D) contact. -2. Once we received the link to join the group, we instruct our automated bot to join it. If the complaint is confirmed as valid, the bot sends the information about the files sent in this group to the servers that store this file. +2. Once we received the link to join the group, we instruct our automated bot to join it. If the complaint is confirmed as valid, the bot sends the information about the files sent in this group to the servers that store these files. -3. Once the servers receive the file identifiers, they can now block the file. +3. Once the servers receive the file identifiers from the bot, they block the files. -File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes – they are securely locked, and sent in chunks, across multiple servers. But if file recipient gives us the address and decryption key of the particular file (each file is encrypted by a different set of keys), we can receive this file. If it violates conditions of use, we can remove or block this file. It doesn't allow us to access any other user data or files. +File servers cannot look inside end-to-end encrypted files, and they don't even know file sizes — they are securely locked, and sent in chunks, across multiple servers. But if the bot that joined the group provides the address of the particular file, the server can delete this file. It doesn't allow the servers to access any other files. -In this way, the moderation is possible without any content scanning, preserving privacy and security of end-to-end encryption. +In this way, the moderation is possible without any content scanning, and it preserves privacy and security of end-to-end encryption. -## Privacy preserving content moderation +## Privacy-preserving content moderation Right now, when we act on user complaints, we delete uploaded files or the links to join the groups from our servers, and to the users it looks as if something stopped working. -We are currently rolling out the change to the servers that would mark these files and group links as blocked, so that users who try to download them or to join blocked groups can see that they were blocked for violating server operator conditions of use. +We are currently rolling out the change to the servers that would mark these files and group links as blocked, so that users who try to download them or to join blocked groups can see that they were blocked for violating server operator conditions of use. This will improve transparency of moderation and reliability of the network. -Later this year we plan to do more than that: when the client discovers that the uploaded file was blocked, it may, optionally, depending on the information in the blocking record, disable further uploads from the app to the servers of the operator that blocked the file. Also, when the client that tried to receive the file sees that the file is blocked, it may also refuse to receive further files from the same group member via the same servers. +Later this year we plan to do more than that — client-side restrictions on the clients that violated conditions of use by uploading prohibited content. -In this way, servers preserve privacy and security of the users and content, but they are still able to restrict the future actions of the users who violate the conditions of use. +How would it work? When the client discovers that the uploaded file was blocked, it may, optionally, depending on the information in the blocking record, disable further uploads from the app to the servers of the operator that blocked the file. Also, when the client that tried to receive the file sees that the file is blocked, it may also refuse to receive further files from the same group member via the same servers. -We discussed this plan with the users, and we really appreciate their feedback. The current plan is quite different from our initial ideas, the users had a real impact on these decisions. Users asked the questions below. +In this way, the servers can restrict the future actions of the users who violate the conditions of use, while preserving privacy and security of the users and content – even of those users who violated the conditions. + +We discussed this plan with the users, and we really appreciate their feedback. The current plan is quite different from our initial ideas, the users had a real impact. Users asked the questions below. **Can't users modify their clients code to circumvent these restrictions?** -Yes, they can, but for this to work both sender and recipient would have to modify their clients, and it's both technically complex, so most users won't do it, and it is also hard to coordinate between users who don't know and don't trust each other. +Yes, they can, but for this to work both sender and recipient would have to modify their clients. It's technically complex, so most users won't do it, and it is also hard to coordinate between users who don't know and don't trust each other. So these measures would be effective, even though they can be in theory circumvented, as any restrictions can be. +Other services that identify users reduce abuse by blocking the user account. It is even easier to circumvent than changing the client code, and yet these measures reduce abuse. + **Can't users use other servers?** -Yes, they can. But in the same way as web browser is not responsible for the content you can access, SimpleX app should not restrict your communications with other servers based on blocking action from just one server. That approach allows different server operators to have different content policies, depending on their jurisdiction and other factors. +Yes, they can. But in the same way as web browser is not responsible for the content you can access, SimpleX app should not restrict your communications with other servers based on blocking action from just one server. + +That approach allows different server operators to have different content policies, depending on their jurisdiction and other factors. It also prevents the possibility of abuse by server operators. **Wouldn't these measures be abused?** -While server operators can indeed abuse such restrictions, they also have other ways to disrupt communications, as described in the [threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#simplex-messaging-protocol-server). Any communication system, with or without servers, can be disrupted by its participants and providers. +With the proposed changes, server operators will only be able to prevent uploads to their own servers, which prevents any impact on other communications. -But server operators offer their servers because they want them to be used, whether because they expect that it will be profitable in the future or because they want to support decentralized communication for charitable reasons. +In the future we plan to increase the resilience to any server malfunction or abuse by using multiple different servers with each contact. -So operators have no reason to abuse users - if they do, users would simply stop using their servers. At the same time, server operators need to have technical means to protect their servers from abuse too, and the planned client-side restrictions would allow it. +If servers were to apply any upload restrictions unreasonably, the users would simply stop using them. + +At the same time, server operators need to have technical means to protect their servers from users' abuse, and the proposed client-side restrictions achieve it. **What additional measures are considered?** -We published other technical ideas that can be used to prevent distribution of illegal content in [this document](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md). What is important, that none of these measures compromise users' privacy or end-to-end encryption, and they can (and should) only be applied to publicly accessible content that other users complained about. +We published other technical ideas that could be used to prevent distribution of illegal content in [this document](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md). None of these measures compromise users' privacy or end-to-end encryption, and they can (and should) only be applied to publicly accessible content that other users complained about. -We technically cannot, and we won't scan all content. We actively [campaign against any content-scanning proposals](./20240704-future-of-privacy-enforcing-privacy-standards.md), not only because it violates our right to privacy, but also because it would result in huge increase of online crime. +We technically cannot, and we won't scan all content. We actively [campaign against any content-scanning proposals](./20240704-future-of-privacy-enforcing-privacy-standards.md), because it violates our right to privacy, and it would result in huge increase of online crime. -The belief that it is impossible to moderate conversations when they are e2e encrypted is incorrect. It is possible when users themselves share conversation contents with server operators, in which case the operators can identify and, if necessary, remove files. It is also possible to moderate conversations that users made publicly accessible. +The belief that it is impossible to moderate conversations when they are e2e encrypted is incorrect. It is possible when users themselves share conversation contents with server operators, in which case the operators can identify and, if necessary, remove this content. It is also possible to moderate conversations that users made publicly accessible. + +## Send us comments and questions + +Let us know any comments and feedback to the proposed measures. This is still an evolving design, and it won't be implemented until later this year. + +Your comments will help to find the right balance between users' and server operators' requirements. ## Privacy and security improvements we plan this year -Not only we won't reduce privacy and security, we plan to increase it this year. - -We plan to add: +To increase privacy and security we plan to add this year: - quantum-resistant e2e encryption in small groups. -- receiving proxy for files, to protect users IP addresses and other transport metadata. +- receiving proxy for files, to protect users IP addresses and other transport metadata from file senders' servers. -We see privacy and security as necessary for online safety, and prevention of abuse. If you don't already use SimpleX network, try it now, and let us know how to make it better. +We see privacy and security as necessary for online safety, and prevention of abuse. If you don't already use SimpleX network, try it now, and let us know what you need to make it better. From 0c31c9f52310e529e226d296593dba62b178fd8f Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 15 Jan 2025 08:50:41 +0000 Subject: [PATCH 83/95] flatpak: update metainfo (#5535) * flatpak: update metainfo * Update scripts/flatpak/chat.simplex.simplex.metainfo.xml --- .../flatpak/chat.simplex.simplex.metainfo.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 5ed491f6ab..8141046d8a 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,25 @@ + + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1-4:

+
    +
  • important fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+ + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html From 46f9a7898a6e693ed9eda363c1ad017334604e0d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 15 Jan 2025 10:05:28 +0000 Subject: [PATCH 84/95] docs: update transparency report --- docs/TRANSPARENCY.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/TRANSPARENCY.md b/docs/TRANSPARENCY.md index 3ce7ed056d..bd0dcabb53 100644 --- a/docs/TRANSPARENCY.md +++ b/docs/TRANSPARENCY.md @@ -1,12 +1,12 @@ --- title: Transparency Reports permalink: /transparency/index.html -revision: 16.07.2024 +revision: 15.01.2025 --- # Transparency Reports -**Updated**: Oct 14, 2024 +**Updated**: Jan 15, 2025 SimpleX Chat Ltd. is a company registered in the UK – it develops communication software enabling users to operate and communicate via SimpleX network, without user profile identifiers of any kind, and without having their data hosted by any network infrastructure operators. @@ -14,6 +14,8 @@ This page will include any and all reports on requests for user data. *To date, we received none*. +In 2024 we received enquiries from several law enforcement agencies seeking information on our procedures for handling data requests. We responded by noting that we operate under the UK law and will consider such requests pursuant to UK law. + Our objective is to consistently ensure that no user data and absolute minimum of the metadata required for the network to function is available for disclosure by any infrastructure operators, under any circumstances. **Helpful resources**: From 951156f7fb3195c79b34511e3e10d0becdb9fd9c Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 17 Jan 2025 10:32:57 +0000 Subject: [PATCH 85/95] directory: process group deletion correctly, group of owners (#5540) * directory: process group deletion correctly, command to invite owners of listed groups to the group of owners * dont invite to owners group twice --- .../src/Directory/Events.hs | 5 ++ .../src/Directory/Options.hs | 11 +++ .../src/Directory/Service.hs | 67 +++++++++++++--- src/Simplex/Chat/Bot/KnownContacts.hs | 18 ++++- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Mobile.hs | 2 +- tests/Bots/DirectoryTests.hs | 78 +++++++++++++++++-- 7 files changed, 164 insertions(+), 19 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index ce165a1344..19c9405358 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -116,6 +116,7 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCListPendingGroups_ :: DirectoryCmdTag 'DRAdmin DCShowGroupLink_ :: DirectoryCmdTag 'DRAdmin DCSendToGroupOwner_ :: DirectoryCmdTag 'DRAdmin + DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser deriving instance Show (DirectoryCmdTag r) @@ -141,6 +142,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCListPendingGroups :: Int -> DirectoryCmd 'DRAdmin DCShowGroupLink :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSendToGroupOwner :: GroupId -> GroupName -> Text -> DirectoryCmd 'DRAdmin + DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCUnknownCommand :: DirectoryCmd 'DRUser DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r @@ -181,6 +183,7 @@ directoryCmdP = "pending" -> au DCListPendingGroups_ "link" -> au DCShowGroupLink_ "owner" -> au DCSendToGroupOwner_ + "invite" -> au DCInviteOwnerToGroup_ "exec" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_ _ -> fail "bad command tag" @@ -216,6 +219,7 @@ directoryCmdP = (groupId, displayName) <- gc (,) msg <- A.space *> A.takeText pure $ DCSendToGroupOwner groupId displayName msg + DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (A.space *> A.takeText) where gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> displayNameP @@ -249,6 +253,7 @@ directoryCmdTag = \case DCListPendingGroups _ -> "pending" DCShowGroupLink {} -> "link" DCSendToGroupOwner {} -> "owner" + DCInviteOwnerToGroup {} -> "invite" DCExecuteCommand _ -> "exec" DCUnknownCommand -> "unknown" DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 3017f82c8c..a62939b6ac 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -21,6 +21,7 @@ data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, adminUsers :: [KnownContact], superUsers :: [KnownContact], + ownersGroup :: Maybe KnownGroup, directoryLog :: Maybe FilePath, serviceName :: T.Text, runCLI :: Bool, @@ -36,6 +37,7 @@ directoryOpts appDir defaultDbName = do parseKnownContacts ( long "admin-users" <> metavar "ADMIN_USERS" + <> value [] <> help "Comma-separated list of admin-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" ) superUsers <- @@ -45,6 +47,14 @@ directoryOpts appDir defaultDbName = do <> metavar "SUPER_USERS" <> help "Comma-separated list of super-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" ) + ownersGroup <- + optional $ + option + parseKnownGroup + ( long "owners-group" + <> metavar "OWNERS_GROUP" + <> help "The group of group owners in the format GROUP_ID:DISPLAY_NAME - owners of listed groups will be invited automatically" + ) directoryLog <- Just <$> strOption @@ -69,6 +79,7 @@ directoryOpts appDir defaultDbName = do { coreOptions, adminUsers, superUsers, + ownersGroup, directoryLog, serviceName = T.pack serviceName, runCLI, diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 102fe4cf7e..2db7857928 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -18,7 +18,8 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Logger.Simple import Control.Monad -import Data.Maybe (fromMaybe, maybeToList) +import Data.List (find) +import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) @@ -103,7 +104,7 @@ directoryService st opts@DirectoryOpts {testing} user cc = do directoryServiceEvent st opts env user cc resp directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> ChatResponse -> IO () -directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, searchResults} ServiceState {searchRequests} user@User {userId} cc event = +directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case DEContactConnected ct -> deContactConnected ct DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole @@ -114,7 +115,7 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g DEContactLeftGroup ctId g -> deContactLeftGroup ctId g DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g - DEGroupDeleted _g -> pure () + DEGroupDeleted g -> deGroupDeleted g DEUnsupportedMessage _ct _ciId -> pure () DEItemEditIgnored _ct -> pure () DEItemDeleteIgnored _ct -> pure () @@ -438,6 +439,14 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." + deGroupDeleted :: GroupInfo -> IO () + deGroupDeleted g = do + logInfo $ "group removed " <> viewGroupName g + withGroupReg g "group removed" $ \gr -> do + setGroupStatus st gr GRSRemoved + notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)." + deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () deUserCommand ct ciId = \case DCHelp -> @@ -582,8 +591,15 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea setGroupStatus st gr GRSActive let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ approved <> " and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved." - sendReply "Group approved!" - notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) + invited <- + forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + owner <- groupOwnerInfo groupRef $ dbContactId gr + pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName + Left err -> pure err + sendReply $ "Group approved!" <> maybe "" ("\n" <>) invited + notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> fromMaybe "" invited Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin Just GRSContactNotOwner -> replyNotApproved "user is not an owner." Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin @@ -641,10 +657,20 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId} -> do notifyOwner gr msg - owner_ <- getContact cc dbContactId - let ownerInfo = "the owner of the group " <> groupRef - ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " - sendReply $ "Forwarded to " <> maybe "" ownerName owner_ <> ownerInfo + owner <- groupOwnerInfo groupRef dbContactId + sendReply $ "Forwarded to " <> owner + DCInviteOwnerToGroup groupId gName -> case ownersGroup of + Just og@KnownGroup {localDisplayName = ogName} -> + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + let groupRef = groupReference' groupId gName + owner <- groupOwnerInfo groupRef ctId + let invited = " invited " <> owner <> " to owners' group " <> viewName ogName + notifyOtherSuperUsers $ viewName (localDisplayName' ct) <> invited + sendReply $ "you" <> invited + Left err -> sendReply err + Nothing -> sendReply "owners' group is not specified" DCCommandError tag -> sendReply $ "Command error: " <> tshow tag | otherwise = sendReply "You are not allowed to use this command" where @@ -662,6 +688,29 @@ directoryServiceEvent st DirectoryOpts {adminUsers, superUsers, serviceName, sea ct_ <- getContact cc dbContactId let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ sendGroupInfo ct gr dbGroupId $ Just ownerStr + inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a + inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = + sendChatCmd cc (APIListMembers ogId) >>= \case + CRGroupMembers _ (Group _ ms) + | alreadyMember ms -> cont $ Left "Owner is already a member of owners' group" + | otherwise -> do + sendChatCmd cc (APIAddMember ogId ctId GRMember) >>= \case + CRSentGroupInvitation {} -> do + printLog cc CLLInfo $ "invited contact ID " <> show ctId <> " to owners' group" + cont $ Right () + r -> contErr r + r -> contErr r + where + alreadyMember = isJust . find ((Just ctId == ) . memberContactId) + contErr r = do + let err = "error inviting contact ID " <> tshow ctId <> " to owners' group: " <> tshow r + putStrLn $ T.unpack err + cont $ Left err + groupOwnerInfo groupRef dbContactId = do + owner_ <- getContact cc dbContactId + let ownerInfo = "the owner of the group " <> groupRef + ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " + pure $ maybe "" ownerName owner_ <> ownerInfo deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd diff --git a/src/Simplex/Chat/Bot/KnownContacts.hs b/src/Simplex/Chat/Bot/KnownContacts.hs index 4555bb9fee..644b744437 100644 --- a/src/Simplex/Chat/Bot/KnownContacts.hs +++ b/src/Simplex/Chat/Bot/KnownContacts.hs @@ -1,5 +1,7 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Bot.KnownContacts where @@ -18,8 +20,13 @@ data KnownContact = KnownContact } deriving (Eq) +data KnownGroup = KnownGroup + { groupId :: Int64, + localDisplayName :: Text + } + knownContactNames :: [KnownContact] -> Text -knownContactNames = T.intercalate ", " . map (("@" <>) . localDisplayName) +knownContactNames = T.intercalate ", " . map (("@" <>) . (\KnownContact {localDisplayName = n} -> n)) parseKnownContacts :: ReadM [KnownContact] parseKnownContacts = eitherReader $ parseAll knownContactsP . encodeUtf8 . T.pack @@ -31,3 +38,12 @@ knownContactsP = contactP `A.sepBy1` A.char ',' contactId <- A.decimal <* A.char ':' localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") pure KnownContact {contactId, localDisplayName} + +parseKnownGroup :: ReadM KnownGroup +parseKnownGroup = eitherReader $ parseAll knownGroupP . encodeUtf8 . T.pack + +knownGroupP :: A.Parser KnownGroup +knownGroupP = do + groupId <- A.decimal <* A.char ':' + localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") + pure KnownGroup {groupId, localDisplayName} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 799c4ea1fc..834599a70f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -353,7 +353,7 @@ data ChatCommand | APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString} | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} | APIAddMember GroupId ContactId GroupMemberRole - | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} + | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} | APIMemberRole GroupId GroupMemberId GroupMemberRole | APIBlockMemberForAll GroupId GroupMemberId Bool | APIRemoveMember GroupId GroupMemberId diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 8c332f9902..8613c7f892 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -204,7 +204,7 @@ mobileChatOpts dbFilePrefix = logServerHosts = True, logAgent = Nothing, logFile = Nothing, - tbqSize = 1024, + tbqSize = 4096, highlyAvailable = False, yesToUpMigrations = False }, diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0aa508994a..6cb11f3f93 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -10,7 +10,7 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) -import Control.Monad (forM_) +import Control.Monad (forM_, when) import qualified Data.Text as T import qualified Directory.Events as DE import Directory.Options @@ -36,12 +36,14 @@ directoryServiceTests = do it "should join found group via link" testJoinGroup it "should support group names with spaces" testGroupNameWithSpaces it "should return more groups in search, all and recent groups" testSearchGroups + it "should invite to owners' group if specified" testInviteToOwnersGroup describe "de-listing the group" $ do it "should de-list if owner leaves the group" testDelistedOwnerLeaves it "should de-list if owner is removed from the group" testDelistedOwnerRemoved it "should NOT de-list if another member leaves the group" testNotDelistedMemberLeaves it "should NOT de-list if another member is removed from the group" testNotDelistedMemberRemoved it "should de-list if service is removed from the group" testDelistedServiceRemoved + it "should de-list if group is deleted" testDelistedGroupDeleted it "should de-list/re-list when service/owner roles change" testDelistedRoleChanges it "should NOT de-list if another member role changes" testNotDelistedMemberRoleChanged it "should NOT send to approval if roles are incorrect" testNotSentApprovalBadRoles @@ -66,8 +68,8 @@ directoryServiceTests = do directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} -mkDirectoryOpts :: FilePath -> [KnownContact] -> DirectoryOpts -mkDirectoryOpts tmp superUsers = +mkDirectoryOpts :: FilePath -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts +mkDirectoryOpts tmp superUsers ownersGroup = DirectoryOpts { coreOptions = testCoreOpts @@ -82,6 +84,7 @@ mkDirectoryOpts tmp superUsers = }, adminUsers = [], superUsers, + ownersGroup, directoryLog = Just $ tmp "directory_service.log", serviceName = "SimpleX-Directory", runCLI = False, @@ -432,6 +435,24 @@ testSearchGroups tmp = u <##. "Link to join the group " u <## (show count <> " members") +testInviteToOwnersGroup :: HasCallStack => FilePath -> IO () +testInviteToOwnersGroup tmp = + withDirectoryServiceCfgOwnersGroup tmp testCfg True $ \superUser dsLink -> + withNewTestChatCfg tmp testCfg "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + registerGroupId superUser bob "privacy" "Privacy" 2 1 + bob <## "#owners: SimpleX-Directory invites you to join the group as member" + bob <## "use /j owners to accept" + superUser <## "Invited @bob, the owner of the group ID 2 (privacy) to owners' group owners" + bob ##> "/j owners" + bob <## "#owners: you joined the group" + bob <## "#owners: member alice (Alice) is connected" + superUser <## "#owners: SimpleX-Directory added bob (Bob) to the group (connecting...)" + superUser <## "#owners: new member bob is connected" + -- second group + registerGroupId superUser bob "security" "Security" 3 2 + superUser <## "Owner is already a member of owners' group" + testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () testDelistedOwnerLeaves tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -505,6 +526,30 @@ testDelistedServiceRemoved tmp = superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." groupNotFound cath "privacy" +testDelistedGroupDeleted :: HasCallStack => FilePath -> IO () +testDelistedGroupDeleted tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + cath `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + connectUsers bob cath + fullAddMember "privacy" "Privacy" bob cath GROwner + joinGroup "privacy" cath bob + cath <## "#privacy: member SimpleX-Directory_1 is connected" + cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" + cath <## "use @SimpleX-Directory to send messages" + bob ##> "/d #privacy" + bob <## "#privacy: you deleted the group" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is deleted." + bob <## "" + bob <## "The group is no longer listed in the directory." + cath <## "#privacy: bob deleted the group" + cath <## "use /d #privacy to delete the local copy of the group" + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group is deleted)." + groupNotFound cath "privacy" + testDelistedRoleChanges :: HasCallStack => FilePath -> IO () testDelistedRoleChanges tmp = withDirectoryServiceCfg tmp testCfgCreateGroupDirect $ \superUser dsLink -> @@ -980,14 +1025,28 @@ withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) withDirectoryService tmp = withDirectoryServiceCfg tmp testCfg withDirectoryServiceCfg :: HasCallStack => FilePath -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfg tmp cfg test = do +withDirectoryServiceCfg tmp cfg = withDirectoryServiceCfgOwnersGroup tmp cfg False + +withDirectoryServiceCfgOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfgOwnersGroup tmp cfg createOwnersGroup test = do dsLink <- withNewTestChatCfg tmp cfg serviceDbPrefix directoryProfile $ \ds -> withNewTestChatCfg tmp cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser + when createOwnersGroup $ do + superUser ##> "/g owners" + superUser <## "group #owners is created" + superUser <## "to add members use /a owners or /create link #owners" + superUser ##> "/a owners SimpleX-Directory admin" + superUser <## "invitation to join the group #owners sent to SimpleX-Directory" + ds <## "#owners: alice invites you to join the group as admin" + ds <## "use /j owners to accept" + ds ##> "/j owners" + ds <## "#owners: you joined the group" + superUser <## "#owners: SimpleX-Directory joined the group" ds ##> "/ad" getContactLink ds True - withDirectory tmp cfg dsLink test + withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () restoreDirectoryService tmp ctCount grCount test = do @@ -1004,11 +1063,16 @@ restoreDirectoryService tmp ctCount grCount test = do withDirectory tmp testCfg dsLink test withDirectory :: HasCallStack => FilePath -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory tmp cfg dsLink test = do - let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] +withDirectory tmp cfg dsLink = withDirectoryOwnersGroup tmp cfg dsLink False + +withDirectoryOwnersGroup :: HasCallStack => FilePath -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () +withDirectoryOwnersGroup tmp cfg dsLink createOwnersGroup test = do + let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing runDirectory cfg opts $ withTestChatCfg tmp cfg "super_user" $ \superUser -> do superUser <## "1 contacts connected (use /cs for the list)" + when createOwnersGroup $ + superUser <## "#owners: connected to server(s)" test superUser dsLink runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () From a59dea27b988fff3c45392fa7f57d9c08e89588a Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 17 Jan 2025 12:09:08 +0000 Subject: [PATCH 86/95] core: support names with spaces in bot parameters (#5542) --- src/Simplex/Chat/Bot/KnownContacts.hs | 6 +- src/Simplex/Chat/Library/Commands.hs | 158 +++++++++++++------------- 2 files changed, 83 insertions(+), 81 deletions(-) diff --git a/src/Simplex/Chat/Bot/KnownContacts.hs b/src/Simplex/Chat/Bot/KnownContacts.hs index 644b744437..0c902d8566 100644 --- a/src/Simplex/Chat/Bot/KnownContacts.hs +++ b/src/Simplex/Chat/Bot/KnownContacts.hs @@ -11,8 +11,8 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Options.Applicative +import Simplex.Chat.Library.Commands (displayNameP) import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Util (safeDecodeUtf8) data KnownContact = KnownContact { contactId :: Int64, @@ -36,7 +36,7 @@ knownContactsP = contactP `A.sepBy1` A.char ',' where contactP = do contactId <- A.decimal <* A.char ':' - localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") + localDisplayName <- displayNameP pure KnownContact {contactId, localDisplayName} parseKnownGroup :: ReadM KnownGroup @@ -45,5 +45,5 @@ parseKnownGroup = eitherReader $ parseAll knownGroupP . encodeUtf8 . T.pack knownGroupP :: A.Parser KnownGroup knownGroupP = do groupId <- A.decimal <* A.char ':' - localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") + localDisplayName <- displayNameP pure KnownGroup {groupId, localDisplayName} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8f6fdd8aff..aadec00f33 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3547,13 +3547,13 @@ chatCommandP = "/unmute " *> ((`SetShowMessages` MFAll) <$> chatNameP), "/unmute mentions " *> ((`SetShowMessages` MFMentions) <$> chatNameP), "/receipts " *> (SetSendReceipts <$> chatNameP <* " " <*> ((Just <$> onOffP) <|> ("default" $> Nothing))), - "/block #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), - "/unblock #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), + "/block #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), + "/unblock #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), "/_create user " *> (CreateActiveUser <$> jsonP), "/create user " *> (CreateActiveUser <$> newUserP), "/users" $> ListUsers, "/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)), - ("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)), + ("/user " <|> "/u ") *> (SetActiveUser <$> displayNameP <*> optional (A.space *> pwdP)), "/set receipts all " *> (SetAllContactReceipts <$> onOffP), "/_set receipts contacts " *> (APISetUserContactReceipts <$> A.decimal <* A.space <*> receiptSettings), "/set receipts contacts " *> (SetUserContactReceipts <$> receiptSettings), @@ -3568,7 +3568,7 @@ chatCommandP = "/mute user" $> MuteUser, "/unmute user" $> UnmuteUser, "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), - "/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)), + "/delete user " *> (DeleteUser <$> displayNameP <*> pure True <*> optional (A.space *> pwdP)), ("/user" <|> "/u") $> ShowActiveUser, "/_start " *> do mainApp <- "main=" *> onOffP @@ -3625,7 +3625,7 @@ chatCommandP = "/_reorder tags " *> (APIReorderChatTags <$> strP), "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_report #" *> (APIReportMessage <$> A.decimal <* A.space <*> A.decimal <*> (" reason=" *> strP) <*> (A.space *> textP <|> pure "")), - "/report #" *> (ReportMessage <$> displayName <*> optional (" @" *> displayName) <*> _strP <* A.space <*> msgTextP), + "/report #" *> (ReportMessage <$> displayNameP <*> optional (" @" *> displayNameP) <*> _strP <* A.space <*> msgTextP), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <*> _strP), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), @@ -3643,7 +3643,7 @@ chatCommandP = "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), - "/call " *> char_ '@' *> (SendCallInvitation <$> displayName <*> pure defaultCallType), + "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), "/_call offer @" *> (APISendCallOffer <$> A.decimal <* A.space <*> jsonP), "/_call answer @" *> (APISendCallAnswer <$> A.decimal <* A.space <*> jsonP), @@ -3704,37 +3704,37 @@ chatCommandP = "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), "/_info #" *> (APIGroupInfo <$> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), - ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayName), - ("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayName), + ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayNameP), + ("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayNameP), "/_queue info #" *> (APIGroupMemberQueueInfo <$> A.decimal <* A.space <*> A.decimal), "/_queue info @" *> (APIContactQueueInfo <$> A.decimal), - ("/queue info #" <|> "/qi #") *> (GroupMemberQueueInfo <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/queue info " <|> "/qi ") *> char_ '@' *> (ContactQueueInfo <$> displayName), + ("/queue info #" <|> "/qi #") *> (GroupMemberQueueInfo <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/queue info " <|> "/qi ") *> char_ '@' *> (ContactQueueInfo <$> displayNameP), "/_switch #" *> (APISwitchGroupMember <$> A.decimal <* A.space <*> A.decimal), "/_switch @" *> (APISwitchContact <$> A.decimal), "/_abort switch #" *> (APIAbortSwitchGroupMember <$> A.decimal <* A.space <*> A.decimal), "/_abort switch @" *> (APIAbortSwitchContact <$> A.decimal), "/_sync #" *> (APISyncGroupMemberRatchet <$> A.decimal <* A.space <*> A.decimal <*> (" force=on" $> True <|> pure False)), "/_sync @" *> (APISyncContactRatchet <$> A.decimal <*> (" force=on" $> True <|> pure False)), - "/switch #" *> (SwitchGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), - "/switch " *> char_ '@' *> (SwitchContact <$> displayName), - "/abort switch #" *> (AbortSwitchGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), - "/abort switch " *> char_ '@' *> (AbortSwitchContact <$> displayName), - "/sync #" *> (SyncGroupMemberRatchet <$> displayName <* A.space <* char_ '@' <*> displayName <*> (" force=on" $> True <|> pure False)), - "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayName <*> (" force=on" $> True <|> pure False)), + "/switch #" *> (SwitchGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/switch " *> char_ '@' *> (SwitchContact <$> displayNameP), + "/abort switch #" *> (AbortSwitchGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/abort switch " *> char_ '@' *> (AbortSwitchContact <$> displayNameP), + "/sync #" *> (SyncGroupMemberRatchet <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (" force=on" $> True <|> pure False)), + "/sync " *> char_ '@' *> (SyncContactRatchet <$> displayNameP <*> (" force=on" $> True <|> pure False)), "/_get code @" *> (APIGetContactCode <$> A.decimal), "/_get code #" *> (APIGetGroupMemberCode <$> A.decimal <* A.space <*> A.decimal), "/_verify code @" *> (APIVerifyContact <$> A.decimal <*> optional (A.space *> verifyCodeP)), "/_verify code #" *> (APIVerifyGroupMember <$> A.decimal <* A.space <*> A.decimal <*> optional (A.space *> verifyCodeP)), "/_enable @" *> (APIEnableContact <$> A.decimal), "/_enable #" *> (APIEnableGroupMember <$> A.decimal <* A.space <*> A.decimal), - "/code " *> char_ '@' *> (GetContactCode <$> displayName), - "/code #" *> (GetGroupMemberCode <$> displayName <* A.space <* char_ '@' <*> displayName), - "/verify " *> char_ '@' *> (VerifyContact <$> displayName <*> optional (A.space *> verifyCodeP)), - "/verify #" *> (VerifyGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> optional (A.space *> verifyCodeP)), - "/enable " *> char_ '@' *> (EnableContact <$> displayName), - "/enable #" *> (EnableGroupMember <$> displayName <* A.space <* char_ '@' <*> displayName), + "/code " *> char_ '@' *> (GetContactCode <$> displayNameP), + "/code #" *> (GetGroupMemberCode <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + "/verify " *> char_ '@' *> (VerifyContact <$> displayNameP <*> optional (A.space *> verifyCodeP)), + "/verify #" *> (VerifyGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> optional (A.space *> verifyCodeP)), + "/enable " *> char_ '@' *> (EnableContact <$> displayNameP), + "/enable #" *> (EnableGroupMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles, ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, ("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts, @@ -3747,40 +3747,40 @@ chatCommandP = ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), - ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRMember)), - ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName <*> (" mute" $> MFNone <|> pure MFAll)), - ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), - "/block for all #" *> (BlockForAll <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), - "/unblock for all #" *> (BlockForAll <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), - ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName), - ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayName), - ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName), - ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayName <*> chatDeleteMode), + ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), + ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)), + ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> memberRole), + "/block for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), + "/unblock for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), + ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP), + ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayNameP), + ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayNameP), + ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayNameP <*> chatDeleteMode), "/clear *" $> ClearNoteFolder, - "/clear #" *> (ClearGroup <$> displayName), - "/clear " *> char_ '@' *> (ClearContact <$> displayName), - ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayName), + "/clear #" *> (ClearGroup <$> displayNameP), + "/clear " *> char_ '@' *> (ClearContact <$> displayNameP), + ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayNameP), "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), - ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayName) <*> optional (A.space *> stringP)), + ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> stringP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), - ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile), - ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName), - "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> msgTextP)), - "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <* A.space <*> (Just <$> msgTextP)), - "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> pure Nothing), - "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayName), + ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), + ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), + "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> optional (A.space *> msgTextP)), + "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <* A.space <*> (Just <$> msgTextP)), + "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayNameP <*> pure Nothing), + "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayNameP), "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)), "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), "/_get link #" *> (APIGetGroupLink <$> A.decimal), - "/create link #" *> (CreateGroupLink <$> displayName <*> (memberRole <|> pure GRMember)), - "/set link role #" *> (GroupLinkMemberRole <$> displayName <*> memberRole), - "/delete link #" *> (DeleteGroupLink <$> displayName), - "/show link #" *> (ShowGroupLink <$> displayName), + "/create link #" *> (CreateGroupLink <$> displayNameP <*> (memberRole <|> pure GRMember)), + "/set link role #" *> (GroupLinkMemberRole <$> displayNameP <*> memberRole), + "/delete link #" *> (DeleteGroupLink <$> displayNameP), + "/show link #" *> (ShowGroupLink <$> displayNameP), "/_create member contact #" *> (APICreateMemberContact <$> A.decimal <* A.space <*> A.decimal), "/_invite member contact @" *> (APISendMemberContactInvitation <$> A.decimal <*> optional (A.space *> msgContentP)), - (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), - (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP), + (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <*> pure Nothing <*> quotedMsg <*> msgTextP), + (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <* char_ '@' <*> (Just <$> displayNameP) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), @@ -3790,18 +3790,18 @@ chatCommandP = "/_set conn user :" *> (APIChangeConnectionUser <$> A.decimal <* A.space <*> A.decimal), ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), - ForwardMessage <$> chatNameP <* " <- @" <*> displayName <* A.space <*> msgTextP, - ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayName <* A.space <* A.char '@' <*> (Just <$> displayName) <* A.space <*> msgTextP, - ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayName <*> pure Nothing <* A.space <*> msgTextP, + ForwardMessage <$> chatNameP <* " <- @" <*> displayNameP <* A.space <*> msgTextP, + ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <* A.space <* A.char '@' <*> (Just <$> displayNameP) <* A.space <*> msgTextP, + ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayNameP <*> pure Nothing <* A.space <*> msgTextP, ForwardLocalMessage <$> chatNameP <* " <- * " <*> msgTextP, SendMessage <$> chatNameP <* A.space <*> msgTextP, "/* " *> (SendMessage (ChatName CTLocal "") <$> msgTextP), - "@#" *> (SendMemberContactMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> msgTextP), + "@#" *> (SendMemberContactMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> msgTextP), "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")), (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd), ("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> textP), - ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> textP), + ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <* A.space <*> textP), ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> msgTextP), ReactToMessage <$> (("+" $> True) <|> ("-" $> False)) <*> reactionP <* A.space <*> chatNameP' <* A.space <*> textP, "/feed " *> (SendMessageBroadcast <$> msgTextP), @@ -3833,8 +3833,8 @@ chatCommandP = ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), "/auto_accept " *> (AddressAutoAccept <$> autoAcceptP), - ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayName), - ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName), + ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayNameP), + ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayNameP), ("/markdown" <|> "/m") $> ChatHelp HSMarkdown, ("/welcome" <|> "/w") $> Welcome, "/set profile image " *> (UpdateProfileImage . Just . ImageData <$> imageP), @@ -3842,22 +3842,22 @@ chatCommandP = "/show profile image" $> ShowProfileImage, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNames), ("/profile" <|> "/p") $> ShowProfile, - "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayName <*> _strP <*> optional memberRole), - "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayName <*> optional (A.space *> strP)), + "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayNameP <*> _strP <*> optional memberRole), + "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayNameP <*> optional (A.space *> strP)), "/set voice " *> (SetUserFeature (ACF SCFVoice) <$> strP), - "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayName <*> _strP <*> optional memberRole), - "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayName <*> (A.space *> strP)), - "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayName <*> (A.space *> strP)), - "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayName <*> optional (A.space *> strP)), + "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayNameP <*> _strP <*> optional memberRole), + "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayNameP <*> (A.space *> strP)), + "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayNameP <*> (A.space *> strP)), + "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayNameP <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), - "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayName <*> (A.space *> strP)), - "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayName <*> optional (A.space *> strP)), + "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayNameP <*> (A.space *> strP)), + "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayNameP <*> optional (A.space *> strP)), "/set delete " *> (SetUserFeature (ACF SCFFullDelete) <$> strP), - "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayName <*> _strP <*> optional memberRole), - "/set disappear #" *> (SetGroupTimedMessages <$> displayName <*> (A.space *> timedTTLOnOffP)), - "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), + "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayNameP <*> _strP <*> optional memberRole), + "/set disappear #" *> (SetGroupTimedMessages <$> displayNameP <*> (A.space *> timedTTLOnOffP)), + "/set disappear @" *> (SetContactTimedMessages <$> displayNameP <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), - "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayName <*> _strP <*> optional memberRole), + "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), "/list remote hosts" $> ListRemoteHosts, @@ -3919,14 +3919,7 @@ chatCommandP = ] where notifyP = " notify=" *> onOffP <|> pure True - displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace) - where - takeNameTill p = - A.peekChar' >>= \c -> - if refChar c then A.takeTill p else fail "invalid first character in display name" - quoted cs = A.choice [A.char c *> takeNameTill (== c) <* A.char c | c <- cs] - refChar c = c > ' ' && c /= '#' && c /= '@' - sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP + sendMsgQuote msgDir = SendMessageQuote <$> displayNameP <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space reactionP = MREmoji <$> (mrEmojiChar <$?> (toEmoji <$> A.anyChar)) toEmoji = \case @@ -3948,7 +3941,7 @@ chatCommandP = clearOverrides <- (" clear_overrides=" *> onOffP) <|> pure False pure UserMsgReceiptSettings {enable, clearOverrides} onOffP = ("on" $> True) <|> ("off" $> False) - profileNames = (,) <$> displayName <*> fullNameP + profileNames = (,) <$> displayNameP <*> fullNameP newUserP = do (cName, fullName) <- profileNames let profile = Just Profile {displayName = cName, fullName, image = Nothing, contactLink = Nothing, preferences = Nothing} @@ -3986,8 +3979,8 @@ chatCommandP = chatNameP = chatTypeP >>= \case CTLocal -> pure $ ChatName CTLocal "" - ct -> ChatName ct <$> displayName - chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayName + ct -> ChatName ct <$> displayNameP + chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP chatRefP = ChatRef <$> chatTypeP <*> A.decimal msgCountP = A.space *> A.decimal <|> pure 10 ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal) @@ -4052,6 +4045,15 @@ chatCommandP = text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char +displayNameP :: Parser Text +displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill isSpace) + where + takeNameTill p = + A.peekChar' >>= \c -> + if refChar c then A.takeTill p else fail "invalid first character in display name" + quoted c = A.char c *> takeNameTill (== c) <* A.char c + refChar c = c > ' ' && c /= '#' && c /= '@' + mkValidName :: String -> String mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where From d238a3c18f0441e704c3d7bcafa6b7fcfbb7a60e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 17 Jan 2025 17:14:33 +0400 Subject: [PATCH 87/95] core: update simplexmq (reopenStore), fix postgres compilation (#5543) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 3 +-- src/Simplex/Chat/Mobile.hs | 10 +++++----- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/cabal.project b/cabal.project index 5ae5c41937..b1d11146a5 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: dadf6ec5b67ee49b0f18ac4aecdce0d0be26786d + tag: fdde9863cdc87dc47609a3a5f51a4c2c4c038858 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index e3e39e3f47..549bdb4dbd 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."dadf6ec5b67ee49b0f18ac4aecdce0d0be26786d" = "0rgnqqkvhgd0a3vncbfx75sqlnd6spgyi2nj303pifdh9hza7k57"; + "https://github.com/simplex-chat/simplexmq.git"."fdde9863cdc87dc47609a3a5f51a4c2c4c038858" = "00jx2zy7b8c8mav01h0ycj6qm5298pxhd960x0p1r1ram4a0nhww"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index aadec00f33..548bcc3645 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -46,6 +46,7 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMayb import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, getCurrentTime, nominalDay) import Data.Type.Equality import qualified Data.UUID as UUID @@ -117,12 +118,10 @@ import UnliftIO.IO (hClose) import UnliftIO.STM #if defined(dbPostgres) import Data.Bifunctor (bimap, second) -import Data.Time (NominalDiffTime, addUTCTime) import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) #else import Data.Bifunctor (bimap, first, second) import qualified Data.ByteArray as BA -import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 8613c7f892..db0ceb8ca2 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -49,7 +49,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, reopenSQLiteStore) +import Simplex.Messaging.Agent.Store (closeStore, reopenStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -262,13 +262,13 @@ chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExcept chatCloseStore :: ChatController -> IO String chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do - closeDBStore chatStore - closeDBStore $ agentClientStore smpAgent + closeStore chatStore + closeStore $ agentClientStore smpAgent chatReopenStore :: ChatController -> IO String chatReopenStore ChatController {chatStore, smpAgent} = handleErr $ do - reopenSQLiteStore chatStore - reopenSQLiteStore (agentClientStore smpAgent) + reopenStore chatStore + reopenStore (agentClientStore smpAgent) handleErr :: IO () -> IO String handleErr a = (a $> "") `catch` (pure . show @SomeException) From 830838fc4c19ed2e8415e686d7547f52114db749 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 17 Jan 2025 13:38:10 +0000 Subject: [PATCH 88/95] cli: require single quotes around names with commas (fixes names in bot parameters) --- src/Simplex/Chat/Library/Commands.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 548bcc3645..bc89d3684f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4045,7 +4045,7 @@ chatCommandP = char_ = optional . A.char displayNameP :: Parser Text -displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill isSpace) +displayNameP = safeDecodeUtf8 <$> (quoted '\'' <|> takeNameTill (\c -> isSpace c || c == ',')) where takeNameTill p = A.peekChar' >>= \c -> From 0e940719c1f3e48d3989a1e2438bd239685142a3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 19 Jan 2025 00:16:45 +0000 Subject: [PATCH 89/95] directory: log superusers, admin users and owners group (#5547) --- .../src/Directory/Service.hs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 2db7857928..ed51371be3 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -18,7 +18,7 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Logger.Simple import Control.Monad -import Data.List (find) +import Data.List (find, intercalate) import Data.Maybe (fromMaybe, isJust, maybeToList) import Data.Set (Set) import qualified Data.Set as S @@ -75,11 +75,22 @@ newServiceState = do welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@DirectoryOpts {coreOptions, testing} <- getDirectoryOpts appDir "simplex_directory_service" + opts@DirectoryOpts {coreOptions, testing, superUsers, adminUsers, ownersGroup} <- getDirectoryOpts appDir "simplex_directory_service" unless testing $ do putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber printDbOpts coreOptions + putStrLn $ knownContacts "superuser" superUsers + putStrLn $ knownContacts "admin user" adminUsers + putStrLn $ case ownersGroup of + Nothing -> "No owner's group" + Just KnownGroup {groupId, localDisplayName = n} -> "Owners' group: " <> knownName groupId n pure opts + where + knownContacts userType = \case + [] -> "No " <> userType <> "s" + cts -> show (length cts) <> " " <> userType <> "(s): " <> intercalate ", " (map knownContact cts) + knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n + knownName i n = show i <> ":" <> T.unpack (viewName n) directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () directoryServiceCLI st opts = do From 20fa30eaccde0e35bb2f5557274ecca12bfe5404 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Jan 2025 17:41:48 +0400 Subject: [PATCH 90/95] core: Mobile.hs postgres interface (#5545) * core: Mobile.hs postgres interface * sqlite * fix * errors * postgres * rename * rename, refactor * merge files * rename * update simplexmq --------- Co-authored-by: Evgeny Poberezkin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 4 +- src/Simplex/Chat.hs | 19 ++------ src/Simplex/Chat/Call.hs | 9 +--- src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Messages.hs | 9 +--- src/Simplex/Chat/Messages/CIContent.hs | 9 +--- src/Simplex/Chat/Mobile.hs | 66 +++++++++++++++----------- src/Simplex/Chat/Operators.hs | 8 +--- src/Simplex/Chat/Options/DB.hs | 11 +++++ src/Simplex/Chat/Options/Postgres.hs | 59 +++++++++++++++++------ src/Simplex/Chat/Options/SQLite.hs | 44 +++++++++++++++++ src/Simplex/Chat/Protocol.hs | 9 +--- src/Simplex/Chat/Store.hs | 35 ++------------ src/Simplex/Chat/Types/Preferences.hs | 9 +--- src/Simplex/Chat/Types/Shared.hs | 9 +--- src/Simplex/Chat/Types/UITheme.hs | 9 +--- tests/ChatClient.hs | 18 +++---- tests/ChatTests/Direct.hs | 6 +-- tests/ChatTests/Groups.hs | 6 +-- tests/MobileTests.hs | 6 ++- tests/SchemaDump.hs | 8 ++-- 23 files changed, 177 insertions(+), 182 deletions(-) diff --git a/cabal.project b/cabal.project index b1d11146a5..67cd0197c4 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: fdde9863cdc87dc47609a3a5f51a4c2c4c038858 + tag: 488c7082f3b8cd1447e2e6f02bd913d2790f3c61 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 549bdb4dbd..dd3ff06505 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."fdde9863cdc87dc47609a3a5f51a4c2c4c038858" = "00jx2zy7b8c8mav01h0ycj6qm5298pxhd960x0p1r1ram4a0nhww"; + "https://github.com/simplex-chat/simplexmq.git"."488c7082f3b8cd1447e2e6f02bd913d2790f3c61" = "10x7byv49c5aj0c9ikvmnfsdi41czgffdwikizy339426b3mq4qx"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e74d785336..7ff9307947 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -48,8 +48,10 @@ library Simplex.Chat.Messages.Batch Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events + Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared + Simplex.Chat.Mobile.WebRTC Simplex.Chat.Operators Simplex.Chat.Operators.Conditions Simplex.Chat.Options @@ -96,8 +98,6 @@ library else exposed-modules: Simplex.Chat.Archive - Simplex.Chat.Mobile - Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options.SQLite Simplex.Chat.Store.SQLite.Migrations Simplex.Chat.Store.SQLite.Migrations.M20220101_initial diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ce15d29022..bf07e4ae51 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} @@ -51,9 +50,6 @@ import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), import qualified Simplex.Messaging.TMap as TM import qualified UnliftIO.Exception as E import UnliftIO.STM -#if defined(dbPostgres) -import Database.PostgreSQL.Simple (ConnectInfo (..), defaultConnectInfo) -#endif operatorSimpleXChat :: NewServerOperator operatorSimpleXChat = @@ -188,19 +184,10 @@ logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} createChatDatabase :: ChatDbOpts -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase dbOpts confirmMigrations = runExceptT $ do -#if defined(dbPostgres) - let ChatDbOpts {dbName, dbUser, dbSchemaPrefix} = dbOpts - connectInfo = defaultConnectInfo {connectUser = dbUser, connectDatabase = dbName} - chatStore <- ExceptT $ createChatStore connectInfo (chatSchema dbSchemaPrefix) confirmMigrations - agentStore <- ExceptT $ createAgentStore connectInfo (agentSchema dbSchemaPrefix) confirmMigrations +createChatDatabase chatDbOpts confirmMigrations = runExceptT $ do + chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) confirmMigrations + agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) confirmMigrations pure ChatDatabase {chatStore, agentStore} -#else - let ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} = dbOpts - chatStore <- ExceptT $ createChatStore (chatStoreFile dbFilePrefix) dbKey False confirmMigrations vacuumOnMigration - agentStore <- ExceptT $ createAgentStore (agentStoreFile dbFilePrefix) dbKey False confirmMigrations vacuumOnMigration - pure ChatDatabase {chatStore, agentStore} -#endif newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController newChatController diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 3fd52e8493..3b1f28dd27 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} @@ -22,19 +21,13 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (Contact, ContactId, User) import Simplex.Messaging.Agent.Store.DB (Binary (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data Call = Call { contactId :: ContactId, diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index bc89d3684f..d991157597 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -86,8 +86,8 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, m import Simplex.Messaging.Agent as Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) -import Simplex.Messaging.Agent.Store (execSQL) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Agent.Store.Migrations as Migrations import Simplex.Messaging.Client (NetworkConfig (..), SocksMode (SMAlways), textToHostMode) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index d665ab806b..55542b1d2f 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -43,6 +42,7 @@ import GHC.TypeLits (ErrorMessage (ShowType, type (:<>:)), TypeError) import qualified GHC.TypeLits as Type import Simplex.Chat.Markdown import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -54,13 +54,6 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 16bd749f30..60d5464b79 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -25,6 +24,7 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Type.Equality import Data.Word (Word32) import Simplex.Chat.Messages.CIContent.Events +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -34,13 +34,6 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOff, pattern import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Util (encodeJSON, safeDecodeUtf8, tshow, (<$?>)) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data MsgDirection = MDRcv | MDSnd deriving (Eq, Show) diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index db0ceb8ca2..b30c004b97 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -16,7 +17,6 @@ import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) import Data.ByteArray (ScrubbedBytes) -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 @@ -26,8 +26,6 @@ import Data.List (find) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Word (Word8) -import Database.SQLite.Simple (SQLError (..)) -import qualified Database.SQLite.Simple as DB import Foreign.C.String import Foreign.C.Types (CInt (..)) import Foreign.Ptr @@ -49,7 +47,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) -import Simplex.Messaging.Agent.Store (closeStore, reopenStore) +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, reopenDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String @@ -58,6 +56,10 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) +#if !defined(dbPostgres) +import Database.SQLite.Simple (SQLError (..)) +import qualified Database.SQLite.Simple as DB +#endif data DBMigrationResult = DBMOk @@ -112,9 +114,11 @@ foreign export ccall "chat_encrypt_file" cChatEncryptFile :: StablePtr ChatContr foreign export ccall "chat_decrypt_file" cChatDecryptFile :: CString -> CString -> CString -> CString -> IO CString -- | check / migrate database and initialize chat controller on success +-- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInit fp key conf = cChatMigrateInitKey fp key 0 conf 0 +-- For postgres first param is schema prefix, second param is database connection string. cChatMigrateInitKey :: CString -> CString -> CInt -> CString -> CInt -> Ptr (StablePtr ChatController) -> IO CJSONString cChatMigrateInitKey fp key keepKey conf background ctrl = do -- ensure we are set to UTF-8; iOS does not have locale, and will default to @@ -123,11 +127,10 @@ cChatMigrateInitKey fp key keepKey conf background ctrl = do setFileSystemEncoding utf8 setForeignEncoding utf8 - dbPath <- peekCString fp - dbKey <- BA.convert <$> B.packCString key + chatDbOpts <- mobileDbOpts fp key confirm <- peekCAString conf r <- - chatMigrateInitKey dbPath dbKey (keepKey /= 0) confirm (background /= 0) >>= \case + chatMigrateInitKey chatDbOpts (keepKey /= 0) confirm (background /= 0) >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e newCStringFromLazyBS $ J.encode r @@ -185,17 +188,12 @@ cChatValidName cName = newCString . mkValidName =<< peekCString cName cChatJsonLength :: CString -> IO CInt cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s -mobileChatOpts :: String -> ChatOpts -mobileChatOpts dbFilePrefix = +mobileChatOpts :: ChatDbOpts -> ChatOpts +mobileChatOpts dbOptions = ChatOpts { coreOptions = CoreChatOpts - { dbOptions = - ChatDbOpts - { dbFilePrefix, - dbKey = "", -- for API database is already opened, and the key in options is not used - vacuumOnMigration = True - }, + { dbOptions, smpServers = [], xftpServers = [], simpleNetCfg = defaultSimpleNetCfg, @@ -235,40 +233,50 @@ defaultMobileConfig = getActiveUser_ :: DBStore -> IO (Maybe User) getActiveUser_ st = find activeUser <$> withTransaction st getUsers +#if !defined(dbPostgres) +-- only used in tests chatMigrateInit :: String -> ScrubbedBytes -> String -> IO (Either DBMigrationResult ChatController) -chatMigrateInit dbFilePrefix dbKey confirm = chatMigrateInitKey dbFilePrefix dbKey False confirm False +chatMigrateInit dbFilePrefix dbKey confirm = do + let chatDBOpts = ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration = True} + chatMigrateInitKey chatDBOpts False confirm False +#endif -chatMigrateInitKey :: String -> ScrubbedBytes -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) -chatMigrateInitKey dbFilePrefix dbKey keepKey confirm backgroundMode = runExceptT $ do +chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) +chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm - chatStore <- migrate createChatStore (chatStoreFile dbFilePrefix) confirmMigrations - agentStore <- migrate createAgentStore (agentStoreFile dbFilePrefix) confirmMigrations + chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) confirmMigrations + agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) confirmMigrations liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where - opts = mobileChatOpts dbFilePrefix + opts = mobileChatOpts $ removeDbKey chatDbOpts initialize st db = do user_ <- getActiveUser_ st newChatController db user_ defaultMobileConfig opts backgroundMode - migrate createStore dbFile confirmMigrations = + migrate createStore dbOpts confirmMigrations = ExceptT $ - (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey keepKey confirmMigrations (vacuumOnMigration $ dbOptions $ coreOptions opts)) + (first (DBMErrorMigration errDbStr) <$> createStore dbOpts confirmMigrations) +#if !defined(dbPostgres) `catch` (pure . checkDBError) +#endif `catchAll` (pure . dbError) where + errDbStr = errorDbStr dbOpts +#if !defined(dbPostgres) checkDBError e = case sqlError e of - DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase dbFile + DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase errDbStr _ -> dbError e - dbError e = Left . DBMErrorSQL dbFile $ show e +#endif + dbError e = Left . DBMErrorSQL errDbStr $ show e chatCloseStore :: ChatController -> IO String chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do - closeStore chatStore - closeStore $ agentClientStore smpAgent + closeDBStore chatStore + closeDBStore $ agentClientStore smpAgent chatReopenStore :: ChatController -> IO String chatReopenStore ChatController {chatStore, smpAgent} = handleErr $ do - reopenStore chatStore - reopenStore (agentClientStore smpAgent) + reopenDBStore chatStore + reopenDBStore (agentClientStore smpAgent) handleErr :: IO () -> IO String handleErr a = (a $> "") `catch` (pure . show @SomeException) diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 6441d651da..6af0c4a17e 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -46,6 +46,7 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types (User) import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) @@ -54,13 +55,6 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTy import Simplex.Messaging.Protocol (AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif usageConditionsCommit :: Text usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" diff --git a/src/Simplex/Chat/Options/DB.hs b/src/Simplex/Chat/Options/DB.hs index 1796baa5db..7e20e93e88 100644 --- a/src/Simplex/Chat/Options/DB.hs +++ b/src/Simplex/Chat/Options/DB.hs @@ -1,14 +1,25 @@ {-# LANGUAGE CPP #-} module Simplex.Chat.Options.DB + #if defined(dbPostgres) ( module Simplex.Chat.Options.Postgres, + FromField (..), + ToField (..), ) where import Simplex.Chat.Options.Postgres +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) + #else ( module Simplex.Chat.Options.SQLite, + FromField (..), + ToField (..), ) where import Simplex.Chat.Options.SQLite +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) + #endif diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index 635223152c..b174ecd02e 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -1,37 +1,68 @@ {-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} module Simplex.Chat.Options.Postgres where +import qualified Data.ByteString.Char8 as B +import Foreign.C.String import Options.Applicative +import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) data ChatDbOpts = ChatDbOpts - { dbName :: String, - dbUser :: String, + { dbConnstr :: String, dbSchemaPrefix :: String } chatDbOptsP :: FilePath -> String -> Parser ChatDbOpts chatDbOptsP _appDir defaultDbName = do - dbName <- + dbConnstr <- strOption ( long "database" <> short 'd' - <> metavar "DB_NAME" - <> help "Database name" - <> value defaultDbName + <> metavar "DB_CONN" + <> help "Database connection string" + <> value ("postgresql://simplex@/" <> defaultDbName) <> showDefault ) - dbUser <- + dbSchemaPrefix <- strOption - ( long "database-user" - <> short 'u' - <> metavar "DB_USER" - <> help "Database user" - <> value "simplex" + ( long "schema-prefix" + <> metavar "DB_SCHEMA_PREFIX" + <> help "Database schema prefix" + <> value "simplex_v1" <> showDefault ) - pure ChatDbOpts {dbName, dbUser, dbSchemaPrefix = ""} + pure ChatDbOpts {dbConnstr, dbSchemaPrefix} dbString :: ChatDbOpts -> String -dbString ChatDbOpts {dbName} = dbName +dbString ChatDbOpts {dbConnstr} = dbConnstr + +toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts +toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix} dbSuffix _keepKey = + DBOpts + { connstr = B.pack dbConnstr, + schema = if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix + } + +chatSuffix :: String +chatSuffix = "_chat_schema" + +agentSuffix :: String +agentSuffix = "_agent_schema" + +mobileDbOpts :: CString -> CString -> IO ChatDbOpts +mobileDbOpts schemaPrefix connstr = do + dbSchemaPrefix <- peekCString schemaPrefix + dbConnstr <- peekCString connstr + pure $ + ChatDbOpts + { dbConnstr, + dbSchemaPrefix + } + +removeDbKey :: ChatDbOpts -> ChatDbOpts +removeDbKey = id + +errorDbStr :: DBOpts -> String +errorDbStr DBOpts {schema} = schema diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index dc81356784..11eaf7e58c 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -1,11 +1,16 @@ {-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Options.SQLite where import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA +import qualified Data.ByteString.Char8 as B +import Foreign.C.String import Options.Applicative +import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) import System.FilePath (combine) data ChatDbOpts = ChatDbOpts @@ -42,3 +47,42 @@ chatDbOptsP appDir defaultDbName = do dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + +toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, vacuumOnMigration} dbSuffix keepKey = do + DBOpts + { dbFilePath = dbFilePrefix <> dbSuffix, + dbKey, + keepKey, + vacuum = vacuumOnMigration + } + +chatSuffix :: String +chatSuffix = "_chat.db" + +agentSuffix :: String +agentSuffix = "_agent.db" + +mobileDbOpts :: CString -> CString -> IO ChatDbOpts +mobileDbOpts fp key = do + dbFilePrefix <- peekCString fp + dbKey <- BA.convert <$> B.packCString key + pure $ + ChatDbOpts + { dbFilePrefix, + dbKey, + vacuumOnMigration = True + } + +-- used to create new chat controller, +-- at that point database is already opened, and the key in options is not used +removeDbKey :: ChatDbOpts -> ChatDbOpts +removeDbKey ChatDbOpts {dbFilePrefix, vacuumOnMigration} = + ChatDbOpts + { dbFilePrefix, + dbKey = "", + vacuumOnMigration + } + +errorDbStr :: DBOpts -> String +errorDbStr DBOpts {dbFilePath} = dbFilePath diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index cf3e36820c..9cbc63b0e2 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} @@ -46,6 +45,7 @@ import Data.Type.Equality import Data.Typeable (Typeable) import Data.Word (Word32) import Simplex.Chat.Call +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -58,13 +58,6 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstTo import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (decodeJSON, eitherToMaybe, encodeJSON, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif -- Chat version history: -- 1 - support chat versions in connections (9/1/2023) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index dbb932740c..03b4d7a640 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -9,13 +9,6 @@ module Simplex.Chat.Store AutoAccept (..), createChatStore, migrations, -- used in tests -#if defined(dbPostgres) - chatSchema, - agentSchema, -#else - chatStoreFile, - agentStoreFile, -#endif withTransaction, ) where @@ -23,35 +16,13 @@ where import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction) +import Simplex.Messaging.Agent.Store.Interface (DBOpts, createDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, MigrationError) #if defined(dbPostgres) -import Database.PostgreSQL.Simple (ConnectInfo (..)) import Simplex.Chat.Store.Postgres.Migrations -import Simplex.Messaging.Agent.Store.Postgres (createDBStore) #else -import Data.ByteArray (ScrubbedBytes) import Simplex.Chat.Store.SQLite.Migrations -import Simplex.Messaging.Agent.Store.SQLite (createDBStore) #endif -#if defined(dbPostgres) -createChatStore :: ConnectInfo -> String -> MigrationConfirmation -> IO (Either MigrationError DBStore) -createChatStore connectInfo schema = createDBStore connectInfo schema migrations - -chatSchema :: String -> String -chatSchema "" = "chat_schema" -chatSchema prefix = prefix <> "_chat_schema" - -agentSchema :: String -> String -agentSchema "" = "agent_schema" -agentSchema prefix = prefix <> "_agent_schema" -#else -createChatStore :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> Bool -> IO (Either MigrationError DBStore) -createChatStore dbPath key keepKey = createDBStore dbPath key keepKey migrations - -chatStoreFile :: FilePath -> FilePath -chatStoreFile = (<> "_chat.db") - -agentStoreFile :: FilePath -> FilePath -agentStoreFile = (<> "_agent.db") -#endif +createChatStore :: DBOpts -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore dbCreateOpts = createDBStore dbCreateOpts migrations diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index bc5eadac3a..07e32e7d56 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -31,17 +30,11 @@ import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import GHC.Records.Compat +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Shared import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (blobFieldDecoder, defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) import Simplex.Messaging.Util (decodeJSON, encodeJSON, safeDecodeUtf8, (<$?>)) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data ChatFeature = CFTimedMessages diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index b70ae81974..d5c8f48776 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} @@ -7,16 +6,10 @@ module Simplex.Chat.Types.Shared where import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (blobFieldDecoder) import Simplex.Messaging.Util ((<$?>)) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data GroupMemberRole = GRObserver -- connects to all group members and receives all messages, can't send messages diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index 460076649e..f2512a3a5a 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} @@ -14,17 +13,11 @@ import qualified Data.Aeson.TH as JQ import Data.Char (toLower) import Data.Maybe (fromMaybe) import Data.Text (Text) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_) import Simplex.Messaging.Util (decodeJSON, encodeJSON) -#if defined(dbPostgres) -import Database.PostgreSQL.Simple.FromField (FromField (..)) -import Database.PostgreSQL.Simple.ToField (ToField (..)) -#else -import Database.SQLite.Simple.FromField (FromField (..)) -import Database.SQLite.Simple.ToField (ToField (..)) -#endif data UITheme = UITheme { themeId :: Text, diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 1c107d5d3f..91fe1cdb4a 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -44,7 +44,7 @@ import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval -import Simplex.Messaging.Agent.Store (closeStore) +import Simplex.Messaging.Agent.Store.Interface (closeDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) @@ -72,17 +72,14 @@ import System.FilePath (()) #endif #if defined(dbPostgres) -testDBName :: String -testDBName = "test_chat_db" - -testDBUser :: String -testDBUser = "test_chat_user" +testDBConnstr :: String +testDBConnstr = "postgresql://test_chat_user@/test_chat_db" testDBConnectInfo :: ConnectInfo testDBConnectInfo = defaultConnectInfo { - connectUser = testDBUser, - connectDatabase = testDBName + connectUser = "test_chat_user", + connectDatabase = "test_chat_db" } #endif @@ -114,8 +111,7 @@ testCoreOpts = { dbOptions = ChatDbOpts #if defined(dbPostgres) - { dbName = testDBName, - dbUser = testDBUser, + { dbConnstr = testDBConnstr, -- dbSchemaPrefix is not used in tests (except bot tests where it's redefined), -- instead different schema prefix is passed per client so that single test database is used dbSchemaPrefix = "" @@ -323,7 +319,7 @@ stopTestChat TestCC {chatController = cc@ChatController {smpAgent, chatStore}, c uninterruptibleCancel termAsync uninterruptibleCancel chatAsync liftIO $ disposeAgentClient smpAgent - closeStore chatStore + closeDBStore chatStore threadDelay 200000 withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 917fc36395..429ff95b19 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -28,6 +28,7 @@ import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat, authErrDisableCount, sameVerificationCode, verificationCode, pattern VersionChat) import Simplex.Messaging.Agent.Env.SQLite @@ -44,7 +45,6 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) -import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import System.FilePath (()) #endif @@ -2793,8 +2793,8 @@ setupDesynchronizedRatchet tmp alice = do (alice from) (chatStoreFile $ tmp to) - copyFile (agentStoreFile $ tmp from) (agentStoreFile $ tmp to) + copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) + copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) testSyncRatchet :: HasCallStack => FilePath -> IO () testSyncRatchet tmp = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index b739b40b7d..b9fee913d8 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -20,6 +20,7 @@ import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options +import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Types (VersionRangeChat) import Simplex.Chat.Types.Shared (GroupMemberRole (..)) @@ -33,7 +34,6 @@ import Test.Hspec hiding (it) import Database.PostgreSQL.Simple (Only (..)) #else import Database.SQLite.Simple (Only (..)) -import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import System.Directory (copyFile) import System.FilePath (()) #endif @@ -3606,8 +3606,8 @@ setupDesynchronizedRatchet tmp alice = do bob <# "#team alice> decryption error, possibly due to the device change (header, 3 messages)" where copyDb from to = do - copyFile (chatStoreFile $ tmp from) (chatStoreFile $ tmp to) - copyFile (agentStoreFile $ tmp from) (agentStoreFile $ tmp to) + copyFile (tmp (from <> chatSuffix)) (tmp (to <> chatSuffix)) + copyFile (tmp (from <> agentSuffix)) (tmp (to <> agentSuffix)) testGroupSyncRatchet :: HasCallStack => FilePath -> IO () testGroupSyncRatchet tmp = diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 730b0d8649..5c4ab29c60 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -32,9 +32,11 @@ import Simplex.Chat.Mobile import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC +import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) +import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) @@ -157,8 +159,8 @@ testChatApiNoUser tmp = do testChatApi :: FilePath -> IO () testChatApi tmp = do let dbPrefix = tmp "1" - f = chatStoreFile dbPrefix - Right st <- createChatStore f "myKey" False MCYesUp True + f = dbPrefix <> chatSuffix + Right st <- createChatStore (DBOpts f "myKey" False True) MCYesUp Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 1f7f6af8a3..307e715dfb 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,8 +10,8 @@ import Data.List (dropWhileEnd) import Data.Maybe (fromJust, isJust) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store +import Simplex.Messaging.Agent.Store.Interface import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) -import Simplex.Messaging.Agent.Store.SQLite (closeDBStore, createDBStore) import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations import Simplex.Messaging.Util (ifM, whenM) import System.Directory (doesFileExist, removeFile) @@ -53,7 +53,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore testDB "" False MCError True + void $ createChatStore (DBOpts testDB "" False True) MCError getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -61,14 +61,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore testDB "" False MCError True + void $ createChatStore (DBOpts testDB "" False True) MCError getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore testDB "" False noDownMigrations MCError True + Right st <- createDBStore (DBOpts testDB "" False True) noDownMigrations MCError mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB From 7e864f9178c044a78e0243e083d35a24899a2426 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 20 Jan 2025 18:06:00 +0000 Subject: [PATCH 91/95] core, ui: support chat item TTL per chat and group aliases (#5415) * core: support chat item TTL per chat * ios: UI mockup * core: chat time to live and group local alias support (#5533) * functions and type placeholders * simplify * queries to make tests pass * set chat queries * fetch queries * get local aliases for groups * local alias support for groups * simplify * fix tests * fix --------- Co-authored-by: Evgeny Poberezkin * migration * add test for expiration * expireChatItems * refactor queries, read objects inside the loop * add groupId to query * fix updateGroupAlias * ios group alias * ttl * changes * fixes and test * new types for ttl * chat and groups ttl in ios * accurate alert * label * progress indicator, disable interactions while api running * just call expire chat items * android, desktop: add local alias to groups (#5544) * android, desktop: add local alias to groups * different placeholder for chats vs contacts * improvements and fixes * only expire chat items, not all items, when chat ttl changes * refactor, fix conditions * refactor * refactor ChatTTLOption * text * fix * make ttl state * fix crash/remove warnings * fix for current? --------- Co-authored-by: Diogo --- apps/ios/Shared/Model/SimpleXAPI.swift | 24 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 326 +++++++++++------- apps/ios/Shared/Views/Chat/ChatView.swift | 3 +- .../Views/Chat/Group/GroupChatInfoView.swift | 226 +++++++----- apps/ios/SimpleXChat/APITypes.swift | 13 +- apps/ios/SimpleXChat/ChatTypes.swift | 98 +++++- .../chat/simplex/common/model/ChatModel.kt | 9 +- .../chat/simplex/common/model/SimpleXAPI.kt | 13 + .../simplex/common/views/chat/ChatInfoView.kt | 3 +- .../views/chat/group/GroupChatInfoView.kt | 25 +- .../commonMain/resources/MR/base/strings.xml | 1 + simplex-chat.cabal | 1 + src/Simplex/Chat/Controller.hs | 9 +- src/Simplex/Chat/Library/Commands.hs | 173 +++++++--- src/Simplex/Chat/Store/Connections.hs | 10 +- src/Simplex/Chat/Store/Direct.hs | 27 +- src/Simplex/Chat/Store/Groups.hs | 55 ++- src/Simplex/Chat/Store/Messages.hs | 16 +- .../Postgres/Migrations/M20241220_initial.hs | 3 + src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../SQLite/Migrations/M20250115_chat_ttl.hs | 22 ++ .../Store/SQLite/Migrations/chat_schema.sql | 5 +- src/Simplex/Chat/Store/Shared.hs | 16 +- src/Simplex/Chat/Types.hs | 3 + src/Simplex/Chat/View.hs | 15 +- tests/ChatTests/Direct.hs | 79 ++++- tests/ChatTests/Profiles.hs | 16 + 27 files changed, 869 insertions(+), 326 deletions(-) create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 48b78d8505..2380f79d59 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -340,7 +340,7 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear throw r } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { +func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async { do { let cInfo = chat.chatInfo let m = ChatModel.shared @@ -353,6 +353,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { await MainActor.run { im.reversedChatItems = chat.chatItems.reversed() m.updateChatInfo(chat.chatInfo) + if (replaceChat) { + m.replaceChat(chat.chatInfo.id, chat) + } } } catch let error { logger.error("loadChat error: \(responseError(error))") @@ -644,7 +647,13 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL { } private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL { - if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } + if case let .chatItemTTL(_, chatItemTTL) = r { + if let ttl = chatItemTTL { + return ChatItemTTL(ttl) + } else { + throw RuntimeError("chatItemTTLResponse: invalid ttl") + } + } throw r } @@ -653,6 +662,11 @@ func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws { try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds)) } +func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async throws { + let userId = try currentUserId("setChatItemTTL") + try await sendCommandOkResp(.apiSetChatTTL(userId: userId, type: chatType, id: id, seconds: chatItemTTL.value)) +} + func getNetworkConfig() async throws -> NetCfg? { let r = await chatSendCmd(.apiGetNetworkConfig) if case let .networkConfig(cfg) = r { return cfg } @@ -1044,6 +1058,12 @@ func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Co throw r } +func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? { + let r = await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias)) + if case let .groupAliasUpdated(_, toGroup) = r { return toGroup } + throw r +} + func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? { let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 1c3203920a..7a5003c94d 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -109,6 +109,7 @@ struct ChatInfoView: View { @State private var showConnectContactViaAddressDialog = false @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true + @State private var progressIndicator = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum ChatInfoViewAlert: Identifiable { @@ -137,50 +138,48 @@ struct ChatInfoView: View { var body: some View { NavigationView { - List { - contactInfoHeader() - .listRowBackground(Color.clear) - .contentShape(Rectangle()) - .onTapGesture { - aliasTextFieldFocused = false - } - - Group { + ZStack { + List { + contactInfoHeader() + .listRowBackground(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + aliasTextFieldFocused = false + } + localAliasTextEdit() - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.bottom, 18) - - GeometryReader { g in - HStack(alignment: .center, spacing: 8) { - let buttonWidth = g.size.width / 4 - searchButton(width: buttonWidth) - AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } - muteButton(width: buttonWidth) - } - } - .padding(.trailing) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8)) - - if let customUserProfile = customUserProfile { - Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { - HStack { - Text("Your random profile") - Spacer() - Text(customUserProfile.chatViewName) - .foregroundStyle(.indigo) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.bottom, 18) + + GeometryReader { g in + HStack(alignment: .center, spacing: 8) { + let buttonWidth = g.size.width / 4 + searchButton(width: buttonWidth) + AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } + VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) } + muteButton(width: buttonWidth) } } - } - - Section { - Group { + .padding(.trailing) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8)) + + if let customUserProfile = customUserProfile { + Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { + HStack { + Text("Your random profile") + Spacer() + Text(customUserProfile.chatViewName) + .foregroundStyle(.indigo) + } + } + } + + Section { if let code = connectionCode { verifyCodeButton(code) } contactPreferencesButton() sendReceiptsOption() @@ -191,97 +190,109 @@ struct ChatInfoView: View { // } else if developerTools { // synchronizeConnectionButtonForce() // } + + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } + // } else if developerTools { + // synchronizeConnectionButtonForce() + // } } .disabled(!contact.ready || !contact.active) - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } - // } else if developerTools { - // synchronizeConnectionButtonForce() - // } - } - .disabled(!contact.ready || !contact.active) - - if let conn = contact.activeConn { + Section { - infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") - } - } - - if let contactLink = contact.contactLink { - Section { - SimpleXLinkQRCode(uri: contactLink) - Button { - showShareSheet(items: [simplexChatLink(contactLink)]) - } label: { - Label("Share address", systemImage: "square.and.arrow.up") - } - } header: { - Text("Address") - .foregroundColor(theme.colors.secondary) + ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) } footer: { - Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.") - .foregroundColor(theme.colors.secondary) + Text("Delete chat messages from your device.") } - } - - if contact.ready && contact.active { - Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - networkStatusRow() - .onTapGesture { - alert = .networkStatusAlert + + if let conn = contact.activeConn { + Section { + infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard") + } + } + + if let contactLink = contact.contactLink { + Section { + SimpleXLinkQRCode(uri: contactLink) + Button { + showShareSheet(items: [simplexChatLink(contactLink)]) + } label: { + Label("Share address", systemImage: "square.and.arrow.up") } - if let connStats = connectionStats { - Button("Change receiving address") { - alert = .switchAddressAlert - } - .disabled( - connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } - || connStats.ratchetSyncSendProhibited - ) - if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { - Button("Abort changing address") { - alert = .abortSwitchAddressAlert + } header: { + Text("Address") + .foregroundColor(theme.colors.secondary) + } footer: { + Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.") + .foregroundColor(theme.colors.secondary) + } + } + + if contact.ready && contact.active { + Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { + networkStatusRow() + .onTapGesture { + alert = .networkStatusAlert + } + if let connStats = connectionStats { + Button("Change receiving address") { + alert = .switchAddressAlert } .disabled( - connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch } + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) + if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { + Button("Abort changing address") { + alert = .abortSwitchAddressAlert + } + .disabled( + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch } + || connStats.ratchetSyncSendProhibited + ) + } + smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) + smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) } - smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary) - smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary) } } - } - - Section { - clearChatButton() - deleteContactButton() - } - - if developerTools { - Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { - infoRow("Local name", chat.chatInfo.localDisplayName) - infoRow("Database ID", "\(chat.chatInfo.apiId)") - Button ("Debug delivery") { - Task { - do { - let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId)) - await MainActor.run { alert = .queueInfo(info: info) } - } catch let e { - logger.error("apiContactQueueInfo error: \(responseError(e))") - let a = getErrorAlert(e, "Error") - await MainActor.run { alert = .error(title: a.title, error: a.message) } + + Section { + clearChatButton() + deleteContactButton() + } + + if developerTools { + Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { + infoRow("Local name", chat.chatInfo.localDisplayName) + infoRow("Database ID", "\(chat.chatInfo.apiId)") + Button ("Debug delivery") { + Task { + do { + let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId)) + await MainActor.run { alert = .queueInfo(info: info) } + } catch let e { + logger.error("apiContactQueueInfo error: \(responseError(e))") + let a = getErrorAlert(e, "Error") + await MainActor.run { alert = .error(title: a.title, error: a.message) } + } } } } } } + .modifier(ThemedBackground(grouped: true)) + .navigationBarHidden(true) + .disabled(progressIndicator) + .opacity(progressIndicator ? 0.6 : 1) + + if progressIndicator { + ProgressView().scaleEffect(2) + } } - .modifier(ThemedBackground(grouped: true)) - .navigationBarHidden(true) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { @@ -290,7 +301,6 @@ struct ChatInfoView: View { } sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) - Task { do { let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId) @@ -498,7 +508,7 @@ struct ChatInfoView: View { chatSettings.sendRcpts = sendReceipts.bool() updateChatSettings(chat, chatSettings: chatSettings) } - + private func synchronizeConnectionButton() -> some View { Button { Task { @@ -643,6 +653,63 @@ struct ChatInfoView: View { } } +struct ChatTTLOption: View { + @ObservedObject var chat: Chat + @Binding var progressIndicator: Bool + @State private var currentChatItemTTL: ChatTTL = ChatTTL.userDefault(.seconds(0)) + @State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0)) + + var body: some View { + Picker("Delete messages after", selection: $chatItemTTL) { + ForEach(ChatItemTTL.values) { ttl in + Text(ttl.deleteAfterText).tag(ChatTTL.chat(ttl)) + } + let defaultTTL = ChatTTL.userDefault(ChatModel.shared.chatItemTTL) + Text(defaultTTL.text).tag(defaultTTL) + + if case .chat(let ttl) = chatItemTTL, case .seconds = ttl { + Text(ttl.deleteAfterText).tag(chatItemTTL) + } + } + .disabled(progressIndicator) + .frame(height: 36) + .onChange(of: chatItemTTL) { ttl in + if ttl == currentChatItemTTL { return } + setChatTTL( + ttl, + hasPreviousTTL: !currentChatItemTTL.neverExpires, + onCancel: { chatItemTTL = currentChatItemTTL } + ) { + progressIndicator = true + Task { + do { + try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl) + await loadChat(chat: chat, clearItems: true, replaceChat: true) + await MainActor.run { + progressIndicator = false + currentChatItemTTL = chatItemTTL + } + } + catch let error { + logger.error("setChatTTL error \(responseError(error))") + await loadChat(chat: chat, clearItems: true, replaceChat: true) + await MainActor.run { + chatItemTTL = currentChatItemTTL + progressIndicator = false + } + } + } + } + } + .onAppear { + let sm = ChatModel.shared + let ttl = chat.chatInfo.ttl(sm.chatItemTTL) + chatItemTTL = ttl + currentChatItemTTL = ttl + } + } +} + func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? { do { let stats = try apiSyncContactRatchet(contact.apiId, force) @@ -1054,6 +1121,33 @@ func deleteContactDialog( } } +func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) { + let title = if ttl.neverExpires { + NSLocalizedString("Disable automatic message deletion?", comment: "alert title") + } else if ttl.usingDefault || hasPreviousTTL { + NSLocalizedString("Change automatic message deletion?", comment: "alert title") + } else { + NSLocalizedString("Enable automatic message deletion?", comment: "alert title") + } + + let message = if ttl.neverExpires { + NSLocalizedString("Messages in this chat will never be deleted.", comment: "alert message") + } else { + NSLocalizedString("This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.", comment: "alert message") + } + + showAlert(title, message: message) { + [ + UIAlertAction( + title: ttl.neverExpires ? NSLocalizedString("Disable delete messages", comment: "alert button") : NSLocalizedString("Delete messages", comment: "alert button"), + style: .destructive, + handler: { _ in onConfirm() } + ), + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel, handler: { _ in onCancel() }) + ] + } +} + private func deleteContactOrConversationDialog( _ chat: Chat, _ contact: Contact, @@ -1254,7 +1348,7 @@ struct ChatInfoView_Previews: PreviewProvider { localAlias: "", featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences), currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences), - onSearch: {} + onSearch: {} ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index c0d7b501f5..baceb5b4ab 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -253,7 +253,8 @@ struct ChatView: View { chat.created = Date.now } ), - onSearch: { focusSearch() } + onSearch: { focusSearch() }, + localAlias: groupInfo.localAlias ) } } else if case .local = cInfo { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index c4df91bb8b..b0f896e493 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -18,6 +18,8 @@ struct GroupChatInfoView: View { @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo var onSearch: () -> Void + @State var localAlias: String + @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member @@ -27,6 +29,7 @@ struct GroupChatInfoView: View { @State private var connectionCode: String? @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true + @State private var progressIndicator = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var searchText: String = "" @FocusState private var searchFocussed @@ -67,101 +70,120 @@ struct GroupChatInfoView: View { .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } - List { - groupInfoHeader() - .listRowBackground(Color.clear) - .padding(.bottom, 18) - - infoActionButtons() - .padding(.horizontal) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - - Section { - if groupInfo.isOwner && groupInfo.businessChat == nil { - editGroupButton() - } - if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { - addOrEditWelcomeMessage() - } - GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() - } - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } - } header: { - Text("") - } footer: { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Only group owners can change group preferences." - : "Only chat owners can change preferences." - ) - Text(label) - .foregroundColor(theme.colors.secondary) - } - - Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { - if groupInfo.canAddMembers { - if groupInfo.businessChat == nil { - groupLinkButton() + ZStack { + List { + groupInfoHeader() + .listRowBackground(Color.clear) + + localAliasTextEdit() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.bottom, 18) + + infoActionButtons() + .padding(.horizontal) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + + Section { + if groupInfo.isOwner && groupInfo.businessChat == nil { + editGroupButton() } - if (chat.chatInfo.incognito) { - Label("Invite members", systemImage: "plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { alert = .cantInviteIncognitoAlert } + if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { + addOrEditWelcomeMessage() + } + GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() } else { - addMembersButton() + sendReceiptsOptionDisabled() } + + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } + } header: { + Text("") + } footer: { + let label: LocalizedStringKey = ( + groupInfo.businessChat == nil + ? "Only group owners can change group preferences." + : "Only chat owners can change preferences." + ) + Text(label) + .foregroundColor(theme.colors.secondary) } - if members.count > 8 { - searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) - .padding(.leading, 8) + + Section { + ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) + } footer: { + Text("Delete chat messages from your device.") } - let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } - MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) - ForEach(filteredMembers) { member in - ZStack { - NavigationLink { - memberInfoView(member) - } label: { - EmptyView() + + Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { + if groupInfo.canAddMembers { + if groupInfo.businessChat == nil { + groupLinkButton() } - .opacity(0) - MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) + if (chat.chatInfo.incognito) { + Label("Invite members", systemImage: "plus") + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .onTapGesture { alert = .cantInviteIncognitoAlert } + } else { + addMembersButton() + } + } + if members.count > 8 { + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) + } + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } + MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) + ForEach(filteredMembers) { member in + ZStack { + NavigationLink { + memberInfoView(member) + } label: { + EmptyView() + } + .opacity(0) + MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) + } + } + } + + Section { + clearChatButton() + if groupInfo.canDelete { + deleteGroupButton() + } + if groupInfo.membership.memberCurrent { + leaveGroupButton() + } + } + + if developerTools { + Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { + infoRow("Local name", chat.chatInfo.localDisplayName) + infoRow("Database ID", "\(chat.chatInfo.apiId)") } } } - - Section { - clearChatButton() - if groupInfo.canDelete { - deleteGroupButton() - } - if groupInfo.membership.memberCurrent { - leaveGroupButton() - } - } - - if developerTools { - Section(header: Text("For console").foregroundColor(theme.colors.secondary)) { - infoRow("Local name", chat.chatInfo.localDisplayName) - infoRow("Database ID", "\(chat.chatInfo.apiId)") - } + .modifier(ThemedBackground(grouped: true)) + .navigationBarHidden(true) + .disabled(progressIndicator) + .opacity(progressIndicator ? 0.6 : 1) + + if progressIndicator { + ProgressView().scaleEffect(2) } } - .modifier(ThemedBackground(grouped: true)) - .navigationBarHidden(true) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alertItem in @@ -200,7 +222,7 @@ struct GroupChatInfoView: View { ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill)) .padding(.top, 12) .padding() - Text(cInfo.displayName) + Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName) .font(.largeTitle) .multilineTextAlignment(.center) .lineLimit(4) @@ -215,6 +237,37 @@ struct GroupChatInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } + private func localAliasTextEdit() -> some View { + TextField("Set chat name…", text: $localAlias) + .disableAutocorrection(true) + .focused($aliasTextFieldFocused) + .submitLabel(.done) + .onChange(of: aliasTextFieldFocused) { focused in + if !focused { + setGroupAlias() + } + } + .onSubmit { + setGroupAlias() + } + .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondary) + } + + private func setGroupAlias() { + Task { + do { + if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) { + await MainActor.run { + chatModel.updateGroup(gInfo) + } + } + } catch { + logger.error("setGroupAlias error: \(responseError(error))") + } + } + } + func infoActionButtons() -> some View { GeometryReader { g in let buttonWidth = g.size.width / 4 @@ -739,7 +792,8 @@ struct GroupChatInfoView_Previews: PreviewProvider { GroupChatInfoView( chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: Binding.constant(GroupInfo.sampleData), - onSearch: {} + onSearch: {}, + localAlias: "" ) } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 753a28f7e9..4ae9bda0f2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -89,8 +89,9 @@ public enum ChatCommand { case apiGetUsageConditions case apiSetConditionsNotified(conditionsId: Int64) case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64]) - case apiSetChatItemTTL(userId: Int64, seconds: Int64?) + case apiSetChatItemTTL(userId: Int64, seconds: Int64) case apiGetChatItemTTL(userId: Int64) + case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?) case apiSetNetworkConfig(networkConfig: NetCfg) case apiGetNetworkConfig case apiSetNetworkInfo(networkInfo: UserNetworkInfo) @@ -124,6 +125,7 @@ public enum ChatCommand { case apiUpdateProfile(userId: Int64, profile: Profile) case apiSetContactPrefs(contactId: Int64, preferences: Preferences) case apiSetContactAlias(contactId: Int64, localAlias: String) + case apiSetGroupAlias(groupId: Int64, localAlias: String) case apiSetConnectionAlias(connId: Int64, localAlias: String) case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?) @@ -265,6 +267,7 @@ public enum ChatCommand { case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" + case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case .apiGetNetworkConfig: return "/network" case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" @@ -308,6 +311,7 @@ public enum ChatCommand { case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" + case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")" case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")" @@ -434,6 +438,7 @@ public enum ChatCommand { case .apiAcceptConditions: return "apiAcceptConditions" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" + case .apiSetChatTTL: return "apiSetChatTTL" case .apiSetNetworkConfig: return "apiSetNetworkConfig" case .apiGetNetworkConfig: return "apiGetNetworkConfig" case .apiSetNetworkInfo: return "apiSetNetworkInfo" @@ -466,6 +471,7 @@ public enum ChatCommand { case .apiUpdateProfile: return "apiUpdateProfile" case .apiSetContactPrefs: return "apiSetContactPrefs" case .apiSetContactAlias: return "apiSetContactAlias" + case .apiSetGroupAlias: return "apiSetGroupAlias" case .apiSetConnectionAlias: return "apiSetConnectionAlias" case .apiSetUserUIThemes: return "apiSetUserUIThemes" case .apiSetChatUIThemes: return "apiSetChatUIThemes" @@ -523,7 +529,7 @@ public enum ChatCommand { if let seconds = seconds { return String(seconds) } else { - return "none" + return "default" } } @@ -629,6 +635,7 @@ public enum ChatResponse: Decodable, Error { case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) case userPrivacy(user: User, updatedUser: User) case contactAliasUpdated(user: UserRef, toContact: Contact) + case groupAliasUpdated(user: UserRef, toGroup: GroupInfo) case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection) case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) case userContactLink(user: User, contactLink: UserContactLink) @@ -809,6 +816,7 @@ public enum ChatResponse: Decodable, Error { case .userProfileUpdated: return "userProfileUpdated" case .userPrivacy: return "userPrivacy" case .contactAliasUpdated: return "contactAliasUpdated" + case .groupAliasUpdated: return "groupAliasUpdated" case .connectionAliasUpdated: return "connectionAliasUpdated" case .contactPrefsUpdated: return "contactPrefsUpdated" case .userContactLink: return "userContactLink" @@ -987,6 +995,7 @@ public enum ChatResponse: Decodable, Error { case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser)) case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 666083ffbd..ae49ee3f3f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1500,6 +1500,24 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .invalidJSON: return .now } } + + public func ttl(_ globalTTL: ChatItemTTL) -> ChatTTL { + switch self { + case let .direct(contact): + return if let ciTTL = contact.chatItemTTL { + ChatTTL.chat(ChatItemTTL(ciTTL)) + } else { + ChatTTL.userDefault(globalTTL) + } + case let .group(groupInfo): + return if let ciTTL = groupInfo.chatItemTTL { + ChatTTL.chat(ChatItemTTL(ciTTL)) + } else { + ChatTTL.userDefault(globalTTL) + } + default: return ChatTTL.userDefault(globalTTL) + } + } public struct SampleData: Hashable { public var direct: ChatInfo @@ -1572,6 +1590,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { var contactGroupMemberId: Int64? var contactGrpInvSent: Bool public var chatTags: [Int64] + public var chatItemTTL: Int64? public var uiThemes: ThemeModeOverrides? public var chatDeleted: Bool @@ -1930,11 +1949,12 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var apiId: Int64 { get { groupId } } public var ready: Bool { get { true } } public var sendMsgEnabled: Bool { get { membership.memberActive } } - public var displayName: String { get { groupProfile.displayName } } + public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } - public var localAlias: String { "" } public var chatTags: [Int64] + public var chatItemTTL: Int64? + public var localAlias: String public var isOwner: Bool { return membership.memberRole == .owner && membership.memberCurrent @@ -1958,7 +1978,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, - chatTags: [] + chatTags: [], + localAlias: "" ) } @@ -4334,45 +4355,53 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable { case day case week case month + case year case seconds(_ seconds: Int64) case none - public static var values: [ChatItemTTL] { [.none, .month, .week, .day] } + public static var values: [ChatItemTTL] { [.none, .year, .month, .week, .day] } public var id: Self { self } - public init(_ seconds: Int64?) { + public init(_ seconds: Int64) { switch seconds { + case 0: self = .none case 86400: self = .day case 7 * 86400: self = .week case 30 * 86400: self = .month - case let .some(n): self = .seconds(n) - case .none: self = .none + case 365 * 86400: self = .year + default: self = .seconds(seconds) } } - public var deleteAfterText: LocalizedStringKey { + public var deleteAfterText: String { switch self { - case .day: return "1 day" - case .week: return "1 week" - case .month: return "1 month" - case let .seconds(seconds): return "\(seconds) second(s)" - case .none: return "never" + case .day: return NSLocalizedString("1 day", comment: "delete after time") + case .week: return NSLocalizedString("1 week", comment: "delete after time") + case .month: return NSLocalizedString("1 month", comment: "delete after time") + case .year: return NSLocalizedString("1 year", comment: "delete after time") + case let .seconds(seconds): return String.localizedStringWithFormat(NSLocalizedString("%d seconds(s)", comment: "delete after time"), seconds) + case .none: return NSLocalizedString("never", comment: "delete after time") } } - public var seconds: Int64? { + public var seconds: Int64 { switch self { case .day: return 86400 case .week: return 7 * 86400 case .month: return 30 * 86400 + case .year: return 365 * 86400 case let .seconds(seconds): return seconds - case .none: return nil + case .none: return 0 } } private var comparisonValue: Int64 { - self.seconds ?? Int64.max + if self.seconds == 0 { + return Int64.max + } else { + return self.seconds + } } public static func < (lhs: Self, rhs: Self) -> Bool { @@ -4380,6 +4409,43 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable { } } +public enum ChatTTL: Identifiable, Hashable { + case userDefault(ChatItemTTL) + case chat(ChatItemTTL) + + public var id: Self { self } + + public var text: String { + switch self { + case let .chat(ttl): return ttl.deleteAfterText + case let .userDefault(ttl): return String.localizedStringWithFormat( + NSLocalizedString("default (%@)", comment: "delete after time"), + ttl.deleteAfterText) + } + } + + public var neverExpires: Bool { + switch self { + case let .chat(ttl): return ttl.seconds == 0 + case let .userDefault(ttl): return ttl.seconds == 0 + } + } + + public var value: Int64? { + switch self { + case let .chat(ttl): return ttl.seconds + case .userDefault: return nil + } + } + + public var usingDefault: Bool { + switch self { + case .userDefault: return true + case .chat: return false + } + } +} + public struct ChatTag: Decodable, Hashable { public var chatTagId: Int64 public var chatTagText: String 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 e2fd922b34..4eb0b350cb 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 @@ -1725,7 +1725,8 @@ data class GroupInfo ( override val updatedAt: Instant, val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, - val chatTags: List + val chatTags: List, + override val localAlias: String, ): SomeChat, NamedChat { override val chatType get() = ChatType.Group override val id get() = "#$groupId" @@ -1743,10 +1744,9 @@ data class GroupInfo ( ChatFeature.Calls -> false } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } - override val displayName get() = groupProfile.displayName + override val displayName get() = localAlias.ifEmpty { groupProfile.displayName } override val fullName get() = groupProfile.fullName override val image get() = groupProfile.image - override val localAlias get() = "" val isOwner: Boolean get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent @@ -1773,7 +1773,8 @@ data class GroupInfo ( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), uiThemes = null, - chatTags = emptyList() + chatTags = emptyList(), + localAlias = "" ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b42d99f2fc..f891d206a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1562,6 +1562,13 @@ object ChatController { return null } + suspend fun apiSetGroupAlias(rh: Long?, groupId: Long, localAlias: String): GroupInfo? { + val r = sendCmd(rh, CC.ApiSetGroupAlias(groupId, localAlias)) + if (r is CR.GroupAliasUpdated) return r.toGroup + Log.e(TAG, "apiSetGroupAlias bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? { val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias)) if (r is CR.ConnectionAliasUpdated) return r.toConnection @@ -3411,6 +3418,7 @@ sealed class CC { class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC() class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC() + class ApiSetGroupAlias(val groupId: Long, val localAlias: String): CC() class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC() class ApiSetUserUIThemes(val userId: Long, val themes: ThemeModeOverrides?): CC() class ApiSetChatUIThemes(val chatId: String, val themes: ThemeModeOverrides?): CC() @@ -3592,6 +3600,7 @@ sealed class CC { is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}" + is ApiSetGroupAlias -> "/_set alias #$groupId ${localAlias.trim()}" is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}" is ApiSetUserUIThemes -> "/_set theme user $userId ${if (themes != null) json.encodeToString(themes) else ""}" is ApiSetChatUIThemes -> "/_set theme $chatId ${if (themes != null) json.encodeToString(themes) else ""}" @@ -3751,6 +3760,7 @@ sealed class CC { is ApiUpdateProfile -> "apiUpdateProfile" is ApiSetContactPrefs -> "apiSetContactPrefs" is ApiSetContactAlias -> "apiSetContactAlias" + is ApiSetGroupAlias -> "apiSetGroupAlias" is ApiSetConnectionAlias -> "apiSetConnectionAlias" is ApiSetUserUIThemes -> "apiSetUserUIThemes" is ApiSetChatUIThemes -> "apiSetChatUIThemes" @@ -5645,6 +5655,7 @@ sealed class CR { @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR() @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: UserRef, val toContact: Contact): CR() + @Serializable @SerialName("groupAliasUpdated") class GroupAliasUpdated(val user: UserRef, val toGroup: GroupInfo): CR() @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: UserRef, val fromContact: Contact, val toContact: Contact): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR() @@ -5832,6 +5843,7 @@ sealed class CR { is UserProfileUpdated -> "userProfileUpdated" is UserPrivacy -> "userPrivacy" is ContactAliasUpdated -> "contactAliasUpdated" + is GroupAliasUpdated -> "groupAliasUpdated" is ConnectionAliasUpdated -> "connectionAliasUpdated" is ContactPrefsUpdated -> "contactPrefsUpdated" is UserContactLink -> "userContactLink" @@ -6009,6 +6021,7 @@ sealed class CR { is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) is UserPrivacy -> withUser(user, json.encodeToString(updatedUser)) is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact)) + is GroupAliasUpdated -> withUser(user, json.encodeToString(toGroup)) is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection)) is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}") is UserContactLink -> withUser(user, contactLink.responseDetails) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index afff6a9561..2b3cf773cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -732,6 +732,7 @@ fun LocalAliasEditor( center: Boolean = true, leadingIcon: Boolean = false, focus: Boolean = false, + isContact: Boolean = true, updateValue: (String) -> Unit ) { val state = remember(chatId) { @@ -748,7 +749,7 @@ fun LocalAliasEditor( state, { Text( - generalGetString(MR.strings.text_field_set_contact_placeholder), + generalGetString(if (isContact) MR.strings.text_field_set_contact_placeholder else MR.strings.text_field_set_chat_placeholder), textAlign = if (center) TextAlign.Center else TextAlign.Start, color = MaterialTheme.colors.secondary ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index d82352c5eb..9b2986ef83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -70,6 +70,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole }, developerTools, + onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) }, groupLink, scrollToItemId, addMembers = { @@ -286,6 +287,7 @@ fun ModalData.GroupChatInfoLayout( setSendReceipts: (SendReceipts) -> Unit, members: List, developerTools: Boolean, + onLocalAliasChanged: (String) -> Unit, groupLink: String?, scrollToItemId: MutableState, addMembers: () -> Unit, @@ -327,8 +329,11 @@ fun ModalData.GroupChatInfoLayout( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - GroupChatInfoHeader(chat.chatInfo) + GroupChatInfoHeader(chat.chatInfo, groupInfo) } + + LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged) + SectionSpacer() Box( @@ -459,7 +464,7 @@ fun ModalData.GroupChatInfoLayout( } @Composable -private fun GroupChatInfoHeader(cInfo: ChatInfo) { +private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { Column( Modifier.padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -467,18 +472,18 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) val clipboard = LocalClipboardManager.current val copyNameToClipboard = { - clipboard.setText(AnnotatedString(cInfo.displayName)) + clipboard.setText(AnnotatedString(groupInfo.groupProfile.displayName)) showToast(generalGetString(MR.strings.copied)) } Text( - cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + groupInfo.groupProfile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, textAlign = TextAlign.Center, maxLines = 4, overflow = TextOverflow.Ellipsis, modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard) ) - if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != groupInfo.groupProfile.displayName) { Text( cInfo.fullName, style = MaterialTheme.typography.h2, color = MaterialTheme.colors.onBackground, @@ -742,6 +747,15 @@ private fun SearchRowView( } } +private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { + val chatRh = chat.remoteHostId + chatModel.controller.apiSetGroupAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { + withChats { + updateGroup(chatRh, it) + } + } +} + @Preview @Composable fun PreviewGroupChatInfoLayout() { @@ -758,6 +772,7 @@ fun PreviewGroupChatInfoLayout() { setSendReceipts = {}, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, + onLocalAliasChanged = {}, groupLink = null, scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, 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 398648b666..954c22abee 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -562,6 +562,7 @@ Contact deleted! You can still view conversation with %1$s in the list of chats. Set contact name… + Set chat name… Connected Disconnected Error diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 7ff9307947..6b0b8bdd82 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -219,6 +219,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags Simplex.Chat.Store.SQLite.Migrations.M20241230_reports Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes + Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 834599a70f..bf91e5ed23 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -342,6 +342,7 @@ data ChatCommand | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias + | APISetGroupAlias GroupId LocalAlias | APISetConnectionAlias Int64 LocalAlias | APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides) | APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides) @@ -379,10 +380,13 @@ data ChatCommand | APIGetUsageConditions | APISetConditionsNotified Int64 | APIAcceptConditions Int64 (NonEmpty Int64) - | APISetChatItemTTL UserId (Maybe Int64) - | SetChatItemTTL (Maybe Int64) + | APISetChatItemTTL UserId Int64 + | SetChatItemTTL Int64 | APIGetChatItemTTL UserId | GetChatItemTTL + | APISetChatTTL UserId ChatRef (Maybe Int64) + | SetChatTTL ChatName (Maybe Int64) + | GetChatTTL ChatName | APISetNetworkConfig NetworkConfig | APIGetNetworkConfig | SetNetworkConfig SimpleNetCfg @@ -720,6 +724,7 @@ data ChatResponse | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary} | CRUserProfileImage {user :: User, profile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} + | CRGroupAliasUpdated {user :: User, toGroup :: GroupInfo} | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactConnecting {user :: User, contact :: Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d991157597..b8bf879caa 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -176,7 +176,7 @@ startChatController mainApp enableSndFiles = do startXFTP xftpStartWorkers void $ forkIO $ startFilesToReceive users startCleanupManager - void $ forkIO $ startExpireCIs users + void $ forkIO $ mapM_ startExpireCIs users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -191,12 +191,15 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT cleanupManager) atomically $ writeTVar cleanupAsync a _ -> pure () - startExpireCIs users = - forM_ users $ \user -> do - ttl <- fromRight Nothing <$> runExceptT (withStore' (`getChatItemTTL` user)) - forM_ ttl $ \_ -> do - startExpireCIThread user - setExpireCIFlag user True + startExpireCIs user = whenM shouldExpireChats $ do + startExpireCIThread user + setExpireCIFlag user True + where + shouldExpireChats = + fmap (fromRight False) $ runExceptT $ withStore' $ \db -> do + ttl <- getChatItemTTL db user + ttlCount <- getChatTTLCount db user + pure $ ttl > 0 || ttlCount > 0 subscribeUsers :: Bool -> [User] -> CM' () subscribeUsers onlyNeeded users = do @@ -1256,6 +1259,11 @@ processChatCommand' vr = \case ct <- getContact db vr user contactId liftIO $ updateContactAlias db userId ct localAlias pure $ CRContactAliasUpdated user ct' + APISetGroupAlias gId localAlias -> withUser $ \user@User {userId} -> do + gInfo' <- withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user gId + liftIO $ updateGroupAlias db userId gInfo localAlias + pure $ CRGroupAliasUpdated user gInfo' APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do conn' <- withFastStore $ \db -> do conn <- getPendingContactConnection db userId connId @@ -1401,27 +1409,55 @@ processChatCommand' vr = \case currentTs <- liftIO getCurrentTime acceptConditions db condId opIds currentTs CRServerOperatorConditions <$> getServerOperators db - APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> + APISetChatTTL userId (ChatRef cType chatId) newTTL_ -> + withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatTTL" $ do + (oldTTL_, globalTTL, ttlCount) <- withStore' $ \db -> + (,,) <$> getSetChatTTL db <*> getChatItemTTL db user <*> getChatTTLCount db user + let newTTL = fromMaybe globalTTL newTTL_ + oldTTL = fromMaybe globalTTL oldTTL_ + when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do + lift $ setExpireCIFlag user False + expireChat user globalTTL `catchChatError` (toView . CRChatError (Just user)) + lift $ setChatItemsExpiration user globalTTL ttlCount + ok user + where + getSetChatTTL db = case cType of + CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_ + CTGroup -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_ + _ -> pure Nothing + expireChat user globalTTL = do + currentTs <- liftIO getCurrentTime + case cType of + CTDirect -> expireContactChatItems user vr globalTTL chatId + CTGroup -> + let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs + in expireGroupChatItems user vr globalTTL createdAtCutoff chatId + _ -> throwChatError $ CECommandError "not supported" + SetChatTTL chatName newTTL -> withUser' $ \user@User {userId} -> do + chatRef <- getChatRef user chatName + processChatCommand $ APISetChatTTL userId chatRef newTTL + GetChatTTL chatName -> withUser' $ \user -> do + ChatRef cType chatId <- getChatRef user chatName + ttl <- case cType of + CTDirect -> withFastStore' (`getDirectChatTTL` chatId) + CTGroup -> withFastStore' (`getGroupChatTTL` chatId) + _ -> throwChatError $ CECommandError "not supported" + pure $ CRChatItemTTL user ttl + APISetChatItemTTL userId newTTL -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do - case newTTL_ of - Nothing -> do - withFastStore' $ \db -> setChatItemTTL db user newTTL_ - lift $ setExpireCIFlag user False - Just newTTL -> do - oldTTL <- withFastStore' (`getChatItemTTL` user) - when (maybe True (newTTL <) oldTTL) $ do - lift $ setExpireCIFlag user False - expireChatItems user newTTL True - withFastStore' $ \db -> setChatItemTTL db user newTTL_ - lift $ startExpireCIThread user - lift . whenM chatStarted $ setExpireCIFlag user True + (oldTTL, ttlCount) <- withFastStore' $ \db -> + (,) <$> getChatItemTTL db user <* setChatItemTTL db user newTTL <*> getChatTTLCount db user + when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do + lift $ setExpireCIFlag user False + expireChatItems user newTTL True + lift $ setChatItemsExpiration user newTTL ttlCount ok user SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ APIGetChatItemTTL userId -> withUserId' userId $ \user -> do ttl <- withFastStore' (`getChatItemTTL` user) - pure $ CRChatItemTTL user ttl + pure $ CRChatItemTTL user (Just ttl) GetChatItemTTL -> withUser' $ \User {userId} -> do processChatCommand $ APIGetChatItemTTL userId APISetNetworkConfig cfg -> withUser' $ \_ -> lift (withAgent' (`setNetworkConfig` cfg)) >> ok_ @@ -3246,9 +3282,16 @@ startExpireCIThread user@User {userId} = do atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry lift waitChatStartedAndActivated ttl <- withStore' (`getChatItemTTL` user) - forM_ ttl $ \t -> expireChatItems user t False + expireChatItems user ttl False liftIO $ threadDelay' interval +setChatItemsExpiration :: User -> Int64 -> Int -> CM' () +setChatItemsExpiration user newTTL ttlCount + | newTTL > 0 || ttlCount > 0 = do + startExpireCIThread user + whenM chatStarted $ setExpireCIFlag user True + | otherwise = setExpireCIFlag user False + setExpireCIFlag :: User -> Bool -> CM' () setExpireCIFlag User {userId} b = do expireFlags <- asks expireCIFlags @@ -3496,20 +3539,19 @@ cleanupManager = do withStore' (`deleteOldProbes` cutoffTs) expireChatItems :: User -> Int64 -> Bool -> CM () -expireChatItems user@User {userId} ttl sync = do +expireChatItems user@User {userId} globalTTL sync = do currentTs <- liftIO getCurrentTime vr <- chatVersionRange - let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs - -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts - createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs + -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts + let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs lift waitChatStartedAndActivated - contacts <- withStore' $ \db -> getUserContacts db vr user - loop contacts $ processContact expirationDate + contactIds <- withStore' $ \db -> getUserContactsToExpire db user globalTTL + loop contactIds $ expireContactChatItems user vr globalTTL lift waitChatStartedAndActivated - groups <- withStore' $ \db -> getUserGroupDetails db vr user Nothing Nothing - loop groups $ processGroup vr expirationDate createdAtCutoff + groupIds <- withStore' $ \db -> getUserGroupsToExpire db user globalTTL + loop groupIds $ expireGroupChatItems user vr globalTTL createdAtCutoff where - loop :: [a] -> (a -> CM ()) -> CM () + loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () loop (a : as) process = continue $ do process a `catchChatError` (toView . CRChatError (Just user)) @@ -3522,22 +3564,40 @@ expireChatItems user@User {userId} ttl sync = do expireFlags <- asks expireCIFlags expire <- atomically $ TM.lookup userId expireFlags when (expire == Just True) $ threadDelay 100000 >> a - processContact :: UTCTime -> Contact -> CM () - processContact expirationDate ct = do - lift waitChatStartedAndActivated - filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo - withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate - processGroup :: VersionRangeChat -> UTCTime -> UTCTime -> GroupInfo -> CM () - processGroup vr expirationDate createdAtCutoff gInfo = do - lift waitChatStartedAndActivated - filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - cancelFilesInProgress user filesInfo - deleteFilesLocally filesInfo - withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff - membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo - forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m + +expireContactChatItems :: User -> VersionRangeChat -> Int64 -> ContactId -> CM () +expireContactChatItems user vr globalTTL ctId = + -- reading contacts and groups inside the loop, + -- to allow ttl changing while processing and to reduce memory usage + tryChatError (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process + where + process ct@Contact {chatItemTTL} = + withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do + lift waitChatStartedAndActivated + filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo + withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate + +expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () +expireGroupChatItems user vr globalTTL createdAtCutoff groupId = + tryChatError (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process + where + process gInfo@GroupInfo {chatItemTTL} = + withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do + lift waitChatStartedAndActivated + filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff + cancelFilesInProgress user filesInfo + deleteFilesLocally filesInfo + withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff + membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo + forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m + +withExpirationDate :: Int64 -> Maybe Int64 -> (UTCTime -> CM ()) -> CM () +withExpirationDate globalTTL chatItemTTL action = do + currentTs <- liftIO getCurrentTime + let ttl = fromMaybe globalTTL chatItemTTL + when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs chatCommandP :: Parser ChatCommand chatCommandP = @@ -3653,6 +3713,7 @@ chatCommandP = "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), + "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), "/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)), @@ -3688,10 +3749,13 @@ chatCommandP = "/_conditions" $> APIGetUsageConditions, "/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal), "/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP), - "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal), - "/ttl " *> (SetChatItemTTL <$> ciTTL), + "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> A.decimal), + "/_ttl " *> (APISetChatTTL <$> A.decimal <* A.space <*> chatRefP <* A.space <*> ciTTLDecimal), "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), + "/ttl " *> (SetChatItemTTL <$> ciTTL), "/ttl" $> GetChatItemTTL, + "/ttl " *> (SetChatTTL <$> chatNameP <* A.space <*> (("default" $> Nothing) <|> (Just <$> ciTTL))), + "/ttl " *> (GetChatTTL <$> chatNameP), "/_network info " *> (APISetNetworkInfo <$> jsonP), "/_network " *> (APISetNetworkConfig <$> jsonP), ("/network " <|> "/net ") *> (SetNetworkConfig <$> netCfgP), @@ -3982,12 +4046,13 @@ chatCommandP = chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP chatRefP = ChatRef <$> chatTypeP <*> A.decimal msgCountP = A.space *> A.decimal <|> pure 10 - ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal) + ciTTLDecimal = ("default" $> Nothing) <|> (Just <$> A.decimal) ciTTL = - ("day" $> Just 86400) - <|> ("week" $> Just (7 * 86400)) - <|> ("month" $> Just (30 * 86400)) - <|> ("none" $> Nothing) + ("day" $> 86400) + <|> ("week" $> (7 * 86400)) + <|> ("month" $> (30 * 86400)) + <|> ("year" $> (365 * 86400)) + <|> ("none" $> 0) timedTTLP = ("30s" $> 30) <|> ("5min" $> 300) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index d8c154f1e0..589b8e39f2 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -110,19 +110,19 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, - p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data + p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData)) = + toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do gm <- @@ -133,9 +133,9 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index cd7a87b443..44ee662c75 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -82,6 +82,9 @@ module Simplex.Chat.Store.Direct setContactChatDeleted, getDirectChatTags, updateDirectChatTags, + setDirectChatTTL, + getDirectChatTTL, + getUserContactsToExpire ) where @@ -198,7 +201,7 @@ getContactByConnReqHash db vr user@User {userId} cReqHash = do SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -263,6 +266,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing @@ -659,7 +663,7 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -838,6 +842,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing @@ -873,7 +878,7 @@ getContact_ db vr user@User {userId} contactId deleted = do SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -1078,3 +1083,19 @@ addDirectChatTags :: DB.Connection -> Contact -> IO Contact addDirectChatTags db ct = do chatTags <- getDirectChatTags db $ contactId' ct pure (ct :: Contact) {chatTags} + +setDirectChatTTL :: DB.Connection -> ContactId -> Maybe Int64 -> IO () +setDirectChatTTL db ctId ttl = do + updatedAt <- getCurrentTime + DB.execute db "UPDATE contacts SET chat_item_ttl = ?, updated_at = ? WHERE contact_id = ?" (ttl, updatedAt, ctId) + +getDirectChatTTL :: DB.Connection -> ContactId -> IO (Maybe Int64) +getDirectChatTTL db ctId = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM contacts WHERE contact_id = ? LIMIT 1" (Only ctId) + +getUserContactsToExpire :: DB.Connection -> User -> Int64 -> IO [ContactId] +getUserContactsToExpire db User {userId} globalTTL = + map fromOnly <$> DB.query db ("SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId) + where + cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 2e0fca19ca..589e220690 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -126,6 +126,10 @@ module Simplex.Chat.Store.Groups setGroupUIThemes, updateGroupChatTags, getGroupChatTags, + setGroupChatTTL, + getGroupChatTTL, + getUserGroupsToExpire, + updateGroupAlias, ) where @@ -160,13 +164,9 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM -#if defined(dbPostgres) -import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) -import Database.PostgreSQL.Simple.SqlQQ (sql) -#else + import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) -#endif type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) @@ -268,9 +268,9 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -337,6 +337,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc { groupId, localDisplayName = ldn, groupProfile, + localAlias = "", businessChat = Nothing, fullGroupPreferences, membership, @@ -347,6 +348,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, customData = Nothing } @@ -406,6 +408,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ { groupId, localDisplayName, groupProfile, + localAlias = "", businessChat = Nothing, fullGroupPreferences, membership, @@ -416,6 +419,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, chatTags = [], + chatItemTTL = Nothing, uiThemes = Nothing, customData = Nothing }, @@ -646,9 +650,9 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do db [sql| SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g @@ -1388,9 +1392,9 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -2074,7 +2078,7 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do @@ -2111,7 +2115,7 @@ createMemberContactInvited contactId <- createContactUpdateMember currentTs userPreferences ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing} + mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} m' = m {memberContactId = Just contactId} pure (mCt', m') where @@ -2350,3 +2354,28 @@ untagGroupChat db groupId tId = WHERE group_id = ? AND chat_tag_id = ? |] (groupId, tId) + +setGroupChatTTL :: DB.Connection -> GroupId -> Maybe Int64 -> IO () +setGroupChatTTL db gId ttl = do + updatedAt <- getCurrentTime + DB.execute + db + "UPDATE groups SET chat_item_ttl = ?, updated_at = ? WHERE group_id = ?" + (ttl, updatedAt, gId) + +getGroupChatTTL :: DB.Connection -> GroupId -> IO (Maybe Int64) +getGroupChatTTL db gId = + fmap join . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM groups WHERE group_id = ? LIMIT 1" (Only gId) + +getUserGroupsToExpire :: DB.Connection -> User -> Int64 -> IO [GroupId] +getUserGroupsToExpire db User {userId} globalTTL = + map fromOnly <$> DB.query db ("SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId) + where + cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" + +updateGroupAlias :: DB.Connection -> UserId -> GroupInfo -> LocalAlias -> IO GroupInfo +updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do + updatedAt <- getCurrentTime + DB.execute db "UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (localAlias, updatedAt, userId, groupId) + pure (g :: GroupInfo) {localAlias = localAlias} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index f10659bcf8..a828a30925 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -107,6 +107,7 @@ module Simplex.Chat.Store.Messages getTimedItems, getChatItemTTL, setChatItemTTL, + getChatTTLCount, getContactExpiredFileInfo, deleteContactExpiredCIs, getGroupExpiredFileInfo, @@ -2885,11 +2886,12 @@ getTimedItems db User {userId} startTimedThreadCutoff = (itemId, Nothing, Just groupId, deleteAt) -> Just ((ChatRef CTGroup groupId, itemId), deleteAt) _ -> Nothing -getChatItemTTL :: DB.Connection -> User -> IO (Maybe Int64) +getChatItemTTL :: DB.Connection -> User -> IO Int64 getChatItemTTL db User {userId} = - fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1" (Only userId) + fmap (fromMaybe 0 . join) . maybeFirstRow fromOnly $ + DB.query db "SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1" (Only userId) -setChatItemTTL :: DB.Connection -> User -> Maybe Int64 -> IO () +setChatItemTTL :: DB.Connection -> User -> Int64 -> IO () setChatItemTTL db User {userId} chatItemTTL = do currentTs <- getCurrentTime r :: (Maybe Int64) <- maybeFirstRow fromOnly $ DB.query db "SELECT 1 FROM settings WHERE user_id = ? LIMIT 1" (Only userId) @@ -2905,6 +2907,14 @@ setChatItemTTL db User {userId} chatItemTTL = do "INSERT INTO settings (user_id, chat_item_ttl, created_at, updated_at) VALUES (?,?,?,?)" (userId, chatItemTTL, currentTs, currentTs) +getChatTTLCount :: DB.Connection -> User -> IO Int +getChatTTLCount db User {userId} = do + contactCount <- getCount "SELECT COUNT(1) FROM contacts WHERE user_id = ? AND chat_item_ttl > 0" + groupCount <- getCount "SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0" + pure $ contactCount + groupCount + where + getCount q = fromOnly . head <$> DB.query db q (Only userId) + getContactExpiredFileInfo :: DB.Connection -> User -> Contact -> UTCTime -> IO [CIFileInfo] getContactExpiredFileInfo db User {userId} Contact {contactId} expirationDate = map toFileInfo diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs index ad9bbd65a4..e8fd77aa0d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20241220_initial.hs @@ -82,6 +82,7 @@ CREATE TABLE contacts( custom_data BYTEA, ui_themes TEXT, chat_deleted SMALLINT NOT NULL DEFAULT 0, + chat_item_ttl BIGINT, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -140,6 +141,8 @@ CREATE TABLE groups( business_chat TEXT NULL, business_xcontact_id BYTEA NULL, customer_member_id BYTEA NULL, + chat_item_ttl BIGINT, + local_alias TEXT DEFAULT '', FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 0126fc600f..f8bdc0d788 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -123,6 +123,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes +import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -245,7 +246,8 @@ schemaMigrations = ("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions), ("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags), ("20241230_reports", m20241230_reports, Just down_m20241230_reports), - ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes) + ("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes), + ("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs new file mode 100644 index 0000000000..3e52890f86 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250115_chat_ttl.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250115_chat_ttl :: Query +m20250115_chat_ttl = + [sql| +ALTER TABLE contacts ADD COLUMN chat_item_ttl INTEGER; +ALTER TABLE groups ADD COLUMN chat_item_ttl INTEGER; +ALTER TABLE groups ADD COLUMN local_alias TEXT DEFAULT ''; +|] + +down_m20250115_chat_ttl :: Query +down_m20250115_chat_ttl = + [sql| +ALTER TABLE contacts DROP COLUMN chat_item_ttl; +ALTER TABLE groups DROP COLUMN chat_item_ttl; +ALTER TABLE groups DROP COLUMN local_alias; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 0601c9bbc0..923928ad5c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -78,6 +78,7 @@ CREATE TABLE contacts( custom_data BLOB, ui_themes TEXT, chat_deleted INTEGER NOT NULL DEFAULT 0, + chat_item_ttl INTEGER, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -131,7 +132,9 @@ CREATE TABLE groups( business_member_id BLOB NULL, business_chat TEXT NULL, business_xcontact_id BLOB NULL, - customer_member_id BLOB NULL, -- received + customer_member_id BLOB NULL, + chat_item_ttl INTEGER, + local_alias TEXT DEFAULT '', -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 5b56b67704..fab4c344bf 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -414,18 +414,18 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] (userId, profileId, userId, profileId, userId, profileId) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData) +type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) type ContactRow = Only ContactId :. ContactRow' toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData)) :. connRow) = +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toMaybeConnection vr connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile getProfileById db userId profileId = @@ -575,18 +575,18 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, uiThemes, customData} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = @@ -607,9 +607,9 @@ groupInfoQuery = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 8e9fbf55f4..11587694cb 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -188,6 +188,7 @@ data Contact = Contact contactGroupMemberId :: Maybe GroupMemberId, contactGrpInvSent :: Bool, chatTags :: [ChatTagId], + chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, chatDeleted :: Bool, customData :: Maybe CustomData @@ -381,6 +382,7 @@ data GroupInfo = GroupInfo { groupId :: GroupId, localDisplayName :: GroupName, groupProfile :: GroupProfile, + localAlias :: Text, businessChat :: Maybe BusinessChatInfo, fullGroupPreferences :: FullGroupPreferences, membership :: GroupMember, @@ -391,6 +393,7 @@ data GroupInfo = GroupInfo chatTs :: Maybe UTCTime, userMemberProfileSentAt :: Maybe UTCTime, chatTags :: [ChatTagId], + chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData } diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b73f720930..84cb561396 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -237,6 +237,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserProfileImage u p -> ttyUser u $ viewUserProfileImage p CRContactPrefsUpdated {user = u, fromContact, toContact} -> ttyUser u $ viewUserContactPrefsUpdated u fromContact toContact CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c + CRGroupAliasUpdated u g -> ttyUser u $ viewGroupAliasUpdated g CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CRGroupMemberUpdated {} -> [] @@ -1182,7 +1183,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs groupSS (g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g - s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g where viewMemberStatus = \case GSMemRemoved -> delete "you are removed" @@ -1197,6 +1198,9 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g) delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" + alias GroupInfo {localAlias} + | localAlias == "" = "" + | otherwise = " (alias: " <> plain localAlias <> ")" groupInvitation' :: GroupInfo -> StyledString groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = @@ -1359,11 +1363,13 @@ viewUsageConditions current accepted_ = viewChatItemTTL :: Maybe Int64 -> [StyledString] viewChatItemTTL = \case - Nothing -> ["old messages are not being deleted"] + Nothing -> ["old messages are set to delete according to default user config"] Just ttl + | ttl == 0 -> ["old messages are not being deleted"] | ttl == 86400 -> deletedAfter "one day" | ttl == 7 * 86400 -> deletedAfter "one week" | ttl == 30 * 86400 -> deletedAfter "one month" + | ttl == 365 * 86400 -> deletedAfter "one year" | otherwise -> deletedAfter $ sShow ttl <> " second(s)" where deletedAfter ttlStr = ["old messages are set to be deleted after: " <> ttlStr] @@ -1626,6 +1632,11 @@ viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}} | localAlias == "" = ["contact " <> ttyContact' ct <> " alias removed"] | otherwise = ["contact " <> ttyContact' ct <> " alias updated: " <> plain localAlias] +viewGroupAliasUpdated :: GroupInfo -> [StyledString] +viewGroupAliasUpdated g@GroupInfo {localAlias} + | localAlias == "" = ["group " <> ttyGroup' g <> " alias removed"] + | otherwise = ["group " <> ttyGroup' g <> " alias updated: " <> plain localAlias] + viewConnectionAliasUpdated :: PendingContactConnection -> [StyledString] viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias} | localAlias == "" = ["connection " <> sShow pccConnId <> " alias removed"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 429ff95b19..bc857132eb 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -134,6 +134,7 @@ chatDirectTests = do it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages it "user profile privacy: hide profiles and notifications" testUserPrivacy + it "set direct chat expiration TTL" testSetDirectChatTTL describe "settings" $ do it "set chat item expiration TTL" testSetChatItemTTL it "save/get app settings" testAppSettings @@ -2116,7 +2117,7 @@ testUsersRestartCIExpiration tmp = do showActiveUser alice "alisa" alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) - threadDelay 3000000 + threadDelay 4000000 alice #$> ("/_get chat @6 count=100", chat, []) where @@ -2561,6 +2562,82 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") +testSetDirectChatTTL :: HasCallStack => FilePath -> IO () +testSetDirectChatTTL = + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + connectUsers alice cath + alice #> "@bob 1" + bob <# "alice> 1" + bob #> "@alice 2" + alice <# "bob> 2" + -- above items should be deleted after we set ttl + alice #> "@cath 10" + cath <# "alice> 10" + cath #> "@alice 11" + alice <# "cath> 11" + alice #$> ("/ttl @cath none", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are not being deleted") + + threadDelay 3000000 + alice #> "@bob 3" + bob <# "alice> 3" + bob #> "@alice 4" + alice <# "bob> 4" + alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "1"), Nothing), ((0, "2"), Nothing), ((1, "3"), Nothing), ((0, "4"), Nothing)]) + alice #$> ("/_ttl 1 2", id, "ok") + -- when expiration is turned on, first cycle is synchronous + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4")]) + + -- chat @3 doesn't expire since it was set to not expire + alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "10"), (0, "11")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4")]) + + -- remove global ttl + alice #$> ("/ttl none", id, "ok") + alice #> "@bob 5" + bob <# "alice> 5" + bob #> "@alice 6" + alice <# "bob> 6" + alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "10"), (0, "11")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + + -- set ttl for chat @3, only chat @3 is affected since global ttl is disabled + alice #$> ("/_ttl 1 @3 1", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: 1 second(s)") + threadDelay 3000000 + alice #$> ("/_get chat @3 count=100", chat, []) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4"), (0, "5"), (1, "6")]) + + -- set ttl to never expire again + alice #$> ("/ttl @cath none", id, "ok") + alice #> "@cath 12" + cath <# "alice> 12" + cath #> "@alice 13" + alice <# "cath> 13" + threadDelay 3000000 + alice #$> ("/_get chat @3 count=100", chat, [(1, "12"), (0, "13")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")]) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4"), (0, "5"), (1, "6")]) + + -- set ttl back to default + alice #$> ("/ttl @cath default", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to delete according to default user config") + alice #$> ("/_ttl 1 2", id, "ok") + alice #$> ("/_get chat @3 count=100", chat, []) + alice #$> ("/_get chat @2 count=100", chat, []) + + alice #$> ("/ttl @cath day", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one day") + alice #$> ("/ttl @cath week", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one week") + alice #$> ("/ttl @cath month", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one month") + alice #$> ("/ttl @cath year", id, "ok") + alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one year") + testAppSettings :: HasCallStack => FilePath -> IO () testAppSettings tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 699565af23..36fe576dcb 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -77,6 +77,8 @@ chatProfileTests = do describe "contact aliases" $ do it "set contact alias" testSetAlias it "set connection alias" testSetConnectionAlias + describe "group aliases" $ do + it "set group alias" testSetGroupAlias describe "pending connection users" $ do it "change user for pending connection" testChangePCCUser it "change from incognito profile connects as new user" testChangePCCUserFromIncognito @@ -1978,6 +1980,20 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ alice ##> "/contacts" alice <## "bob (Bob) (alias: friend)" +testSetGroupAlias :: HasCallStack => FilePath -> IO () +testSetGroupAlias = testChat2 aliceProfile bobProfile $ + \alice bob -> do + createGroup2 "team" alice bob + threadDelay 1500000 + alice ##> "/_set alias #1 friends" + alice <## "group #team alias updated: friends" + alice ##> "/groups" + alice <## "#team (2 members) (alias: friends)" + alice ##> "/_set alias #1" + alice <## "group #team alias removed" + alice ##> "/groups" + alice <## "#team (2 members)" + testSetContactPrefs :: HasCallStack => FilePath -> IO () testSetContactPrefs = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do From b491a7e73582c8666b4ec3545bde72ff2ac2668d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 20 Jan 2025 18:43:25 +0000 Subject: [PATCH 92/95] core: simplexmq 6.3.0.2 --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- tests/ChatClient.hs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index 67cd0197c4..9a7346c4d0 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 488c7082f3b8cd1447e2e6f02bd913d2790f3c61 + tag: 23189753751dc52046865ce2d992335495020e91 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index dd3ff06505..95b8215de8 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."488c7082f3b8cd1447e2e6f02bd913d2790f3c61" = "10x7byv49c5aj0c9ikvmnfsdi41czgffdwikizy339426b3mq4qx"; + "https://github.com/simplex-chat/simplexmq.git"."23189753751dc52046865ce2d992335495020e91" = "0f1c0bfjqwycsb2nkphhbdiv77zx6q47jdigk7bjal1c4rfla8gy"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 91fe1cdb4a..32af1d5c95 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -171,7 +171,7 @@ testAgentCfgSlow = testAgentCfg { smpClientVRange = mkVersionRange (Version 1) srvHostnamesSMPClientVersion, -- v2 smpAgentVRange = mkVersionRange duplexHandshakeSMPAgentVersion pqdrSMPAgentVersion, -- v5 - smpCfg = (smpCfg testAgentCfg) {serverVRange = mkVersionRange batchCmdsSMPVersion sendingProxySMPVersion} -- v8 + smpCfg = (smpCfg testAgentCfg) {serverVRange = mkVersionRange minClientSMPRelayVersion sendingProxySMPVersion} -- v8 } testCfg :: ChatConfig @@ -210,7 +210,7 @@ testAgentCfgV1 = { smpClientVRange = v1Range, smpAgentVRange = versionToRange duplexHandshakeSMPAgentVersion, e2eEncryptVRange = versionToRange CR.kdfX3DHE2EEncryptVersion, - smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange batchCmdsSMPVersion} + smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange minClientSMPRelayVersion} } testCfgVPrev :: ChatConfig From 9cf2b5a1e417b6e0e4eb3f0c998bb3baa0a45145 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 20 Jan 2025 19:23:39 +0000 Subject: [PATCH 93/95] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e6f301a52c..641c1e9fd1 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -519,9 +519,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -677,9 +677,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -760,8 +760,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-2v70Hf7C0rHK37FBpai7lH.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */, ); path = Libraries; sourceTree = ""; From 5bd8dc1f7187b7926b195bf023d7179df4097b60 Mon Sep 17 00:00:00 2001 From: Diogo Date: Tue, 21 Jan 2025 10:58:27 +0000 Subject: [PATCH 94/95] desktop, android: use timestamp as file name for videos (#5539) * desktop, android: hide file name on video uploads * indirection * never makeup extensions * param instead of fn * format * replaced comment --------- Co-authored-by: Evgeny Poberezkin --- .../simplex/common/views/chat/ComposeView.kt | 2 +- .../simplex/common/views/helpers/Utils.kt | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) 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 7ca5c873bd..c413e06599 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 @@ -610,7 +610,7 @@ fun ComposeView( if (remoteHost == null) saveAnimImage(it.uri) else CryptoFile.desktopPlain(it.uri) is UploadContent.Video -> - if (remoteHost == null) saveFileFromUri(it.uri) + if (remoteHost == null) saveFileFromUri(it.uri, hiddenFileNamePrefix = "video") else CryptoFile.desktopPlain(it.uri) } if (file != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 5c4f9b631e..db1a0be9da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -247,13 +247,26 @@ fun saveAnimImage(uri: URI): CryptoFile? { expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? -fun saveFileFromUri(uri: URI, withAlertOnException: Boolean = true): CryptoFile? { +fun saveFileFromUri( + uri: URI, + withAlertOnException: Boolean = true, + hiddenFileNamePrefix: String? = null +): CryptoFile? { return try { val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() val inputStream = uri.inputStream() val fileToSave = getFileName(uri) return if (inputStream != null && fileToSave != null) { - val destFileName = uniqueCombine(fileToSave, File(getAppFilePath(""))) + val destFileName = if (hiddenFileNamePrefix == null) { + uniqueCombine(fileToSave, File(getAppFilePath(""))) + } else { + val ext = when { + // remove everything but extension + fileToSave.contains(".") -> fileToSave.substringAfterLast(".") + else -> null + } + generateNewFileName(hiddenFileNamePrefix, ext, File(getAppFilePath(""))) + } val destFile = File(getAppFilePath(destFileName)) if (encrypted) { createTmpFileAndDelete { tmpFile -> @@ -353,11 +366,12 @@ fun createTmpFileAndDelete(dir: File = tmpDir, onCreated: (File) -> T): T { } } -fun generateNewFileName(prefix: String, ext: String, dir: File): String { +fun generateNewFileName(prefix: String, ext: String?, dir: File): String { val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) sdf.timeZone = TimeZone.getTimeZone("GMT") val timestamp = sdf.format(Date()) - return uniqueCombine("${prefix}_$timestamp.$ext", dir) + val extension = if (ext != null) ".$ext" else "" + return uniqueCombine("${prefix}_$timestamp$extension", dir) } fun uniqueCombine(fileName: String, dir: File): String { From 8e609ac507f894644b771ec75cd71aa1e29d9ada Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:35:43 +0400 Subject: [PATCH 95/95] core: don't subscribe to deleted/left groups, read less data for groups on subscription (#5552) --- src/Simplex/Chat/Controller.hs | 11 +++--- src/Simplex/Chat/Library/Commands.hs | 50 +++++++++++++++------------ src/Simplex/Chat/Store/Groups.hs | 51 ++++++++++++++++++++++++++++ src/Simplex/Chat/Types.hs | 24 +++++++++++++ src/Simplex/Chat/View.hs | 22 ++++++++---- tests/Bots/DirectoryTests.hs | 8 ++--- tests/MobileTests.hs | 1 - 7 files changed, 127 insertions(+), 40 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bf91e5ed23..ac4b50ed9f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -741,7 +741,7 @@ data ChatResponse | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} + | CRGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} @@ -757,8 +757,7 @@ data ChatResponse | CRUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} | CRUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} - | CRGroupEmpty {user :: User, groupInfo :: GroupInfo} - | CRGroupRemoved {user :: User, groupInfo :: GroupInfo} + | CRGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} @@ -773,9 +772,9 @@ data ChatResponse | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} - | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} + | CRMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} - | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} + | CRGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} @@ -1051,7 +1050,7 @@ data ContactSubStatus = ContactSubStatus deriving (Show) data MemberSubStatus = MemberSubStatus - { member :: GroupMember, + { member :: ShortGroupMember, memberError :: Maybe ChatError } deriving (Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b8bf879caa..bf568f7946 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3347,17 +3347,17 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce - -- TODO possibly, we could either disable these events or replace with less noisy for API - contactLinkSubsToView rs ucs - groupSubsToView rs gs ms ce - sndFileSubsToView rs sfts - rcvFileSubsToView rs rfts - pendingConnSubsToView rs pcs + unlessM (asks $ coreApi . config) $ do + contactLinkSubsToView rs ucs + groupSubsToView rs gs ms ce + sndFileSubsToView rs sfts + rcvFileSubsToView rs rfts + pendingConnSubsToView rs pcs where addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (toPCC c) pcs in (cts, ucs, ms, sfts, rfts, pcs') - RcvGroupMsgConnection c _g m -> let ms' = addConn c m ms in (cts, ucs, ms', sfts, rfts, pcs) + RcvGroupMsgConnection c _g m -> let ms' = addConn c (toShortMember m c) ms in (cts, ucs, ms', sfts, rfts, pcs) SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) @@ -3377,6 +3377,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do createdAt, updatedAt = createdAt } + toShortMember GroupMember {groupMemberId, groupId, localDisplayName} Connection {agentConnId} = + ShortGroupMember + { groupMemberId, + groupId, + memberName = localDisplayName, + connId = agentConnId + } getContactConns :: CM ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ (`getUserContacts` vr) @@ -3387,11 +3394,13 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do (cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr) let connIds = map aConnId cs pure (connIds, M.fromList $ zip connIds ucs) - getGroupMemberConns :: CM ([Group], [ConnId], Map ConnId GroupMember) + getGroupMemberConns :: CM ([ShortGroup], [ConnId], Map ConnId ShortGroupMember) getGroupMemberConns = do - gs <- withStore_ (`getUserGroups` vr) - let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs + gs <- withStore_ getUserGroupsToSubscribe + let mPairs = concatMap (\(ShortGroup _ ms) -> map (\m -> (shortMemConnId m, m)) ms) gs pure (gs, map fst mPairs, M.fromList mPairs) + where + shortMemConnId ShortGroupMember{connId = AgentConnId acId} = acId getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do sfts <- withStore_ getLiveSndFileTransfers @@ -3435,30 +3444,27 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do -- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> CM () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs - groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> CM () + groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM () groupSubsToView rs gs ms ce = do mapM_ groupSub $ - sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs + sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms - groupSub :: Group -> CM () - groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do + groupSub :: ShortGroup -> CM () + groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors toView groupEvent where - mErrors :: [(GroupMember, ChatError)] + mErrors :: [(ShortGroupMember, ChatError)] mErrors = - sortOn (\(GroupMember {localDisplayName = n}, _) -> n) + sortOn (\(ShortGroupMember {memberName = n}, _) -> n) . filterErrors - $ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs + $ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs groupEvent :: ChatResponse groupEvent - | memberStatus membership == GSMemInvited = CRGroupInvitation user g - | all (\GroupMember {activeConn} -> isNothing activeConn) members = - if memberActive membership - then CRGroupEmpty user g - else CRGroupRemoved user g + | membershipStatus == GSMemInvited = CRGroupInvitation user g + | null members = CRGroupEmpty user g | otherwise = CRGroupSubscribed user g sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> CM () sndFileSubsToView rs sfts = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 589e220690..1d1a715b78 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -57,6 +57,7 @@ module Simplex.Chat.Store.Groups deleteGroupItemsAndMembers, deleteGroup, getUserGroups, + getUserGroupsToSubscribe, getUserGroupDetails, getUserGroupsWithSummary, getGroupSummary, @@ -588,6 +589,51 @@ getGroup db vr user groupId = do members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members +getGroupToSubscribe :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ShortGroup +getGroupToSubscribe db User {userId, userContactId} groupId = do + shortInfo <- getGroupInfoToSubscribe + members <- liftIO getGroupMembersToSubscribe + pure $ ShortGroup shortInfo members + where + getGroupInfoToSubscribe :: ExceptT StoreError IO ShortGroupInfo + getGroupInfoToSubscribe = ExceptT $ do + firstRow toInfo (SEGroupNotFound groupId) $ + DB.query + db + [sql| + SELECT g.local_display_name, mu.member_status + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + |] + (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toInfo :: (GroupName, GroupMemberStatus) -> ShortGroupInfo + toInfo (groupName, membershipStatus) = + ShortGroupInfo groupId groupName membershipStatus + getGroupMembersToSubscribe :: IO [ShortGroupMember] + getGroupMembersToSubscribe = do + map toShortMember + <$> DB.query + db + [sql| + SELECT m.group_member_id, m.local_display_name, c.agent_conn_id + FROM group_members m + JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id + ) + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) + AND m.member_status NOT IN (?,?,?) + |] + (userId, userId, groupId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + where + toShortMember :: (GroupMemberId, ContactName, AgentConnId) -> ShortGroupMember + toShortMember (groupMemberId, localDisplayName, agentConnId) = + ShortGroupMember groupMemberId groupId localDisplayName agentConnId + deleteGroupConnectionsAndFiles :: DB.Connection -> User -> GroupInfo -> [GroupMember] -> IO () deleteGroupConnectionsAndFiles db User {userId} GroupInfo {groupId} members = do forM_ members $ \m -> DB.execute db "DELETE FROM connections WHERE user_id = ? AND group_member_id = ?" (userId, groupMemberId' m) @@ -642,6 +688,11 @@ getUserGroups db vr user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroup db vr user) groupIds +getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] +getUserGroupsToSubscribe db user@User {userId} = do + groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) + rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds + getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do g_ <- diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 11587694cb..d137e54d23 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -373,6 +373,26 @@ optionalFullName displayName fullName | T.null fullName || displayName == fullName = "" | otherwise = " (" <> fullName <> ")" +data ShortGroup = ShortGroup + { shortInfo :: ShortGroupInfo, + members :: [ShortGroupMember] + } + +data ShortGroupInfo = ShortGroupInfo + { groupId :: GroupId, + groupName :: GroupName, + membershipStatus :: GroupMemberStatus + } + deriving (Eq, Show) + +data ShortGroupMember = ShortGroupMember + { groupMemberId :: GroupMemberId, + groupId :: GroupId, + memberName :: ContactName, + connId :: AgentConnId + } + deriving (Show) + data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Eq, Show) @@ -1812,3 +1832,7 @@ $(JQ.deriveJSON defaultJSON ''ContactRef) $(JQ.deriveJSON defaultJSON ''NoteFolder) $(JQ.deriveJSON defaultJSON ''ChatTag) + +$(JQ.deriveJSON defaultJSON ''ShortGroupInfo) + +$(JQ.deriveJSON defaultJSON ''ShortGroupMember) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 84cb561396..1d578dea24 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -292,7 +292,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] - CRGroupInvitation u g -> ttyUser u [groupInvitation' g] + CRGroupInvitation u g -> ttyUser u [groupInvitationSub g] CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m @@ -307,8 +307,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] - CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] + CRGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"] CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g @@ -323,9 +322,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' - CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] + CRMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g + CRGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g CRPendingSubSummary u _ -> ttyUser u [] CRSndFileSubError u SndFileTransfer {fileId, fileName} e -> ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] @@ -571,8 +570,8 @@ viewUsersList us = <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -viewGroupSubscribed :: GroupInfo -> [StyledString] -viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] +viewGroupSubscribed :: GroupName -> [StyledString] +viewGroupSubscribed g = [ttyGroup g <> ": connected to server(s)"] showSMPServer :: SMPServer -> String showSMPServer ProtocolServer {host} = B.unpack $ strEncode host @@ -1216,6 +1215,15 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " +groupInvitationSub :: ShortGroupInfo -> StyledString +groupInvitationSub ShortGroupInfo {groupName = ldn} = + highlight ("#" <> viewName ldn) + <> " - you are invited (" + <> highlight ("/j " <> viewName ldn) + <> " to join, " + <> highlight ("/d #" <> viewName ldn) + <> " to delete invitation)" + viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] viewContactsMerged c1 c2 ct' = [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 6cb11f3f93..7a0ae79ccc 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -923,13 +923,13 @@ testRestoreDirectory tmp = do withTestChat tmp "cath" $ \cath -> do bob <## "2 contacts connected (use /cs for the list)" bob - <### [ "#privacy (Privacy): connected to server(s)", - "#security (Security): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#security: connected to server(s)" ] cath <## "2 contacts connected (use /cs for the list)" cath - <### [ "#privacy (Privacy): connected to server(s)", - "#anonymity (Anonymity): connected to server(s)" + <### [ "#privacy: connected to server(s)", + "#anonymity: connected to server(s)" ] listGroups superUser bob cath groupFoundN 3 bob "privacy" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 5c4ab29c60..e99b61dd5c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -169,7 +169,6 @@ testChatApi tmp = do chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted chatRecvMsg cc `shouldReturn` networkStatuses - chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown