diff --git a/README.md b/README.md index 7e2e6b5fb5..6cc3b5bb77 100644 --- a/README.md +++ b/README.md @@ -79,18 +79,14 @@ You can use SimpleX with your own servers and still communicate with people usin ## News and updates -Selected updates: +Recent updates: -[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced netwrok settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md) +[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md) + +[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md) [Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md) -[May 11, 2022. v2.0 released - sending images and files in mobile apps](./blog/20220511-simplex-chat-v2-images-files.md) - -[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md) - -[Jan 12, 2022. SimpleX v1 released: the only messaging and application platform without user identities](./20220112-simplex-chat-v1-released.md) - [All updates](./blog) ## Make a private connection @@ -177,10 +173,14 @@ If you are considering developing with SimpleX platform please get in touch for - ✅ Chat database export and import - ✅ Chat groups in mobile apps. - ✅ Connecting to messaging servers via Tor. -- 🏗 Dual server addresses to access messaging servers as v3 hidden services (in progress). -- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress). -- Chat database encryption. +- ✅ Dual server addresses to access messaging servers as v3 hidden services. +- ✅ Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (ready for announcement). +- ✅ Incognito mode to share a new random name with each contact. +- 🏗 Chat database encryption. +- 🏗 Links to join groups and improve groups stability. - Disappearing messages, with mutual agreement. +- Voice messages +- Video messages - Web widgets for custom interactivity in the chats. - SMP protocol improvements: - SMP queue redundancy and rotation. @@ -191,6 +191,8 @@ If you are considering developing with SimpleX platform please get in touch for - the server doesn't have information about your contacts and groups. - Channels server for large groups and broadcast channels. - Media server to optimize sending large files to groups. +- Desktop client. +- Using the same profile on multiple devices. ## Help us pay for 3rd party security audit @@ -209,6 +211,7 @@ It is possible to donate via: - [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us. - [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies. - Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt +- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG Thank you, diff --git a/apps/android/.gitignore b/apps/android/.gitignore index 4d1f29a2d1..e4dd4a5169 100644 --- a/apps/android/.gitignore +++ b/apps/android/.gitignore @@ -9,6 +9,7 @@ /.idea/assetWizardSettings.xml /.idea/deploymentTargetDropDown.xml /.idea/misc.xml +/.idea/uiDesigner.xml .DS_Store /build /captures diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 5fcf716d4a..eba863a3bf 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 48 - versionName "3.1" + versionCode 52 + versionName "3.2.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { @@ -26,9 +26,19 @@ android { cppFlags '' } } + manifestPlaceholders.app_name = "@string/app_name" + manifestPlaceholders.provider_authorities = "chat.simplex.app.provider" + manifestPlaceholders.extract_native_libs = compression_level != "0" } buildTypes { + debug { + applicationIdSuffix "$application_id_suffix" + debuggable new Boolean("$enable_debuggable") + manifestPlaceholders.app_name = "$app_name" + // Provider can't be the same for different apps on the same device + manifestPlaceholders.provider_authorities = "chat.simplex.app${application_id_suffix}.provider" + } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' @@ -64,6 +74,7 @@ android { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' } + jniLibs.useLegacyPackaging = compression_level != "0" } } @@ -83,6 +94,7 @@ dependencies { implementation "androidx.navigation:navigation-compose:2.4.1" implementation "com.google.accompanist:accompanist-insets:0.23.0" implementation 'androidx.webkit:webkit:1.4.0' + implementation "com.godaddy.android.colorpicker:compose-color-picker:0.4.2" def work_version = "2.7.1" implementation "androidx.work:work-runtime-ktx:$work_version" @@ -113,3 +125,70 @@ dependencies { androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" } + +def buildType = "unknown" +// Don't do anything if no compression is needed +if (compression_level != "0") { + tasks.whenTaskAdded { task -> + if (task.name == 'packageDebug') { + task.doLast { + buildType = "debug" + } + task.finalizedBy compressApk + } else if (task.name == 'packageRelease') { + task.doLast { + buildType = "release" + } + task.finalizedBy compressApk + } + } +} + +tasks.register("compressApk") { + doLast { + def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome() + def sdkDir = android.getSdkDirectory().getAbsolutePath() + def keyAlias = "" + def keyPassword = "" + def storeFile = "" + def storePassword = "" + if (project.properties['android.injected.signing.key.alias'] != null) { + keyAlias = project.properties['android.injected.signing.key.alias'] + keyPassword = project.properties['android.injected.signing.key.password'] + storeFile = project.properties['android.injected.signing.store.file'] + storePassword = project.properties['android.injected.signing.store.password'] + } else if (android.signingConfigs.hasProperty(buildType)) { + def gradleConfig = android.signingConfigs[buildType] + keyAlias = gradleConfig.keyAlias + keyPassword = gradleConfig.keyPassword + storeFile = gradleConfig.storeFile + storePassword = gradleConfig.storePassword + } else { + // There is no signing config for current build type, can't sign the apk + println("No signing configs for this build type: $buildType") + return + } + + def outputDir = tasks["package${buildType.capitalize()}"].outputs.files.last() + + exec { + workingDir '../../../scripts/android' + setEnvironment(['JAVA_HOME': "$javaHome"]) + commandLine './compress-and-sign-apk.sh', \ + "$compression_level", \ + "$outputDir", \ + "$sdkDir", \ + "$storeFile", \ + "$storePassword", \ + "$keyAlias", \ + "$keyPassword" + } + + if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') { + new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk")) + } + + // View all gradle properties set + // project.properties.each { k, v -> println "$k -> $v" } + } +} diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 5f2de04a24..3f04b64fcb 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,8 @@ android:name="SimplexApp" android:allowBackup="true" android:icon="@mipmap/icon" - android:label="@string/app_name" + android:label="${app_name}" + android:extractNativeLibs="${extract_native_libs}" android:supportsRtl="true" android:theme="@style/Theme.SimpleX"> @@ -34,7 +35,7 @@ android:name=".MainActivity" android:launchMode="singleTask" android:exported="true" - android:label="@string/app_name" + android:label="${app_name}" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.SimpleX"> @@ -66,9 +67,7 @@ @@ -80,9 +79,7 @@ @@ -98,7 +95,7 @@ (null) + val enteredBackground = mutableStateOf(null) + // Remember result and show it after orientation change + private val laFailed = mutableStateOf(false) + + fun clearAuthState() { + userAuthorized.value = null + enteredBackground.value = null + } + } private val vm by viewModels() private val chatController by lazy { (application as SimplexApp).chatController } - private val userAuthorized = mutableStateOf(null) - private val enteredBackground = mutableStateOf(null) - private val laFailed = mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - ProcessLifecycleOwner.get().lifecycle.addObserver(this) // testJson() val m = vm.chatModel - processNotificationIntent(intent, m) + // When call ended and orientation changes, it re-process old intent, it's unneeded. + // Only needed to be processed on first creation of activity + if (savedInstanceState == null) { + processNotificationIntent(intent, m) + } setContent { SimpleXTheme { Surface( @@ -70,7 +85,7 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { } } } - schedulePeriodicServiceRestartWorker() + SimplexApp.context.schedulePeriodicServiceRestartWorker() } override fun onNewIntent(intent: Intent?) { @@ -78,20 +93,25 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { processIntent(intent, vm.chatModel) } - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - withApi { - when (event) { - Lifecycle.Event.ON_STOP -> { - enteredBackground.value = elapsedRealtime() - } - Lifecycle.Event.ON_START -> { - val enteredBackgroundVal = enteredBackground.value - if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) { - runAuthenticate() - } - } - else -> {} - } + override fun onStart() { + super.onStart() + val enteredBackgroundVal = enteredBackground.value + if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30 * 1e+3) { + runAuthenticate() + } + } + + override fun onStop() { + super.onStop() + enteredBackground.value = elapsedRealtime() + } + + override fun onBackPressed() { + super.onBackPressed() + if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) { + // When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch + clearAuthState() + laFailed.value = true } } @@ -131,24 +151,6 @@ class MainActivity: FragmentActivity(), LifecycleEventObserver { } } - private fun schedulePeriodicServiceRestartWorker() { - val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get() - val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) { - Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy") - ExistingPeriodicWorkPolicy.KEEP - } else { - Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy") - chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION) - ExistingPeriodicWorkPolicy.REPLACE - } - val work = PeriodicWorkRequestBuilder(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES) - .addTag(SimplexService.TAG) - .addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) - .build() - Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes") - WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work) - } - private fun setPerformLA(on: Boolean) { vm.chatModel.controller.appPrefs.laNoticeShown.set(true) if (on) { @@ -241,7 +243,7 @@ fun MainPage( showLANotice: () -> Unit ) { // this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication - var chatsAccessAuthorized by remember { mutableStateOf(false) } + var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) } LaunchedEffect(userAuthorized.value) { if (chatModel.controller.appPrefs.performLA.get()) { delay(500L) diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 213eacc8b5..39a4806add 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -4,14 +4,17 @@ import android.app.Application import android.net.LocalServerSocket import android.util.Log import androidx.lifecycle.* +import androidx.work.* import chat.simplex.app.model.* import chat.simplex.app.views.helpers.getFilesDirectory import chat.simplex.app.views.helpers.withApi import chat.simplex.app.views.onboarding.OnboardingStage +import kotlinx.coroutines.* import java.io.BufferedReader import java.io.InputStreamReader import java.util.* import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit import kotlin.concurrent.thread const val TAG = "SIMPLEX" @@ -57,8 +60,6 @@ class SimplexApp: Application(), LifecycleEventObserver { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo } else { chatController.startChat(user) - SimplexService.start(applicationContext) - chatController.showBackgroundServiceNoticeIfNeeded() } } } @@ -80,11 +81,39 @@ class SimplexApp: Application(), LifecycleEventObserver { } } + fun allowToStartServiceAfterAppExit() = with(chatModel.controller) { + appPrefs.runServiceInBackground.get() && isIgnoringBatteryOptimizations(chatModel.controller.appContext) + } + + /* + * It takes 1-10 milliseconds to process this function. Better to do it in a background thread + * */ + fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch { + if (!allowToStartServiceAfterAppExit()) { + return@launch + } + val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get() + val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) { + Log.d(TAG, "ServiceStartWorker version matches: choosing KEEP as existing work policy") + ExistingPeriodicWorkPolicy.KEEP + } else { + Log.d(TAG, "ServiceStartWorker version DOES NOT MATCH: choosing REPLACE as existing work policy") + chatController.appPrefs.autoRestartWorkerVersion.set(SimplexService.SERVICE_START_WORKER_VERSION) + ExistingPeriodicWorkPolicy.REPLACE + } + val work = PeriodicWorkRequestBuilder(SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES) + .addTag(SimplexService.TAG) + .addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) + .build() + Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes") + WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work) + } + companion object { lateinit var context: SimplexApp private set init { - val socketName = "local.socket.address.listen.native.cmd2" + val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2" val s = Semaphore(0) thread(name="stdout/stderr pipe") { Log.d(TAG, "starting server") diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt index a9426a062e..7a4dd8ea71 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt @@ -2,6 +2,7 @@ package chat.simplex.app import android.app.* import android.content.* +import android.content.pm.PackageManager import android.os.* import android.provider.Settings import android.util.Log @@ -54,7 +55,10 @@ class SimplexService: Service() { override fun onDestroy() { Log.d(TAG, "Simplex service destroyed") stopService() - sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) // Restart if necessary! + + // If private notifications are enabled and battery optimization is disabled, restart the service + if (SimplexApp.context.allowToStartServiceAfterAppExit()) + sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) super.onDestroy() } @@ -147,6 +151,14 @@ class SimplexService: Service() { // re-schedules the task when "Clear recent apps" is pressed override fun onTaskRemoved(rootIntent: Intent) { + // Just to make sure that after restart of the app the user will need to re-authenticate + MainActivity.clearAuthState() + + // If private notifications aren't enabled or battery optimization isn't disabled, we shouldn't restart the service + if (!SimplexApp.context.allowToStartServiceAfterAppExit()) { + return + } + val restartServiceIntent = Intent(applicationContext, SimplexService::class.java).also { it.setPackage(packageName) }; @@ -162,6 +174,17 @@ class SimplexService: Service() { Log.d(TAG, "StartReceiver: onReceive called") scheduleStart(context) } + companion object { + fun toggleReceiver(enable: Boolean) { + Log.d(TAG, "StartReceiver: toggleReceiver enabled: $enable") + val component = ComponentName(BuildConfig.APPLICATION_ID, StartReceiver::class.java.name) + SimplexApp.context.packageManager.setComponentEnabledSetting( + component, + if (enable) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + } } // restart on destruction diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index fad7fc0d46..8593d903bd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -47,6 +47,7 @@ class ChatModel(val controller: ChatController) { val runServiceInBackground = mutableStateOf(true) val performLA = mutableStateOf(false) val showAdvertiseLAUnavailableAlert = mutableStateOf(false) + var incognito = mutableStateOf(false) // current WebRTC call val callManager = CallManager(this) @@ -57,7 +58,7 @@ class ChatModel(val controller: ChatController) { val showCallView = mutableStateOf(false) val switchingCall = mutableStateOf(false) - fun updateUserProfile(profile: Profile) { + fun updateUserProfile(profile: LocalProfile) { val user = currentUser.value if (user != null) { currentUser.value = user.copy(profile = profile) @@ -66,6 +67,7 @@ class ChatModel(val controller: ChatController) { fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id } + fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id } fun addChat(chat: Chat) = chats.add(index = 0, chat) @@ -211,27 +213,39 @@ class ChatModel(val controller: ChatController) { } } - fun markChatItemsRead(cInfo: ChatInfo) { + fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { + val markedRead = markItemsReadInCurrentChat(cInfo, range) // update preview val chatIdx = getChatIndex(cInfo.id) if (chatIdx >= 0) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { - chats[chatIdx] = chat.copy(chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = lastId + 1)) + chats[chatIdx] = chat.copy( + chatStats = chat.chatStats.copy( + unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0, + // Can't use minUnreadItemId currently since chat items can have unread items between read items + //minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1 + ) + ) } } - // update current chat + } + + private fun markItemsReadInCurrentChat(cInfo: ChatInfo, range: CC.ItemRange? = null): Int { + var markedRead = 0 if (chatId.value == cInfo.id) { var i = 0 while (i < chatItems.count()) { val item = chatItems[i] - if (item.meta.itemStatus is CIStatus.RcvNew) { + if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { chatItems[i] = item.withStatus(CIStatus.RcvRead()) + markedRead++ } i += 1 } } + return markedRead } // func popChat(_ id: String) { @@ -248,6 +262,22 @@ class ChatModel(val controller: ChatController) { fun removeChat(id: String) { chats.removeAll { it.id == id } } + + fun upsertGroupMember(groupInfo: GroupInfo, member: GroupMember): Boolean { + // update current chat + return if (chatId.value == groupInfo.id) { + val memberIndex = groupMembers.indexOfFirst { it.id == member.id } + if (memberIndex >= 0) { + groupMembers[memberIndex] = member + false + } else { + groupMembers.add(member) + true + } + } else { + false + } + } } enum class ChatType(val type: String) { @@ -262,19 +292,20 @@ data class User( val userId: Long, val userContactId: Long, val localDisplayName: String, - val profile: Profile, + val profile: LocalProfile, val activeUser: Boolean ): NamedChat { override val displayName: String get() = profile.displayName override val fullName: String get() = profile.fullName override val image: String? get() = profile.image + override val localAlias: String = "" companion object { val sampleData = User( userId = 1, userContactId = 1, localDisplayName = "alice", - profile = Profile.sampleData, + profile = LocalProfile.sampleData, activeUser = true ) } @@ -286,8 +317,9 @@ interface NamedChat { val displayName: String val fullName: String val image: String? + val localAlias: String val chatViewName: String - get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") + get() = localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } } interface SomeChat { @@ -297,6 +329,7 @@ interface SomeChat { val apiId: Long val ready: Boolean val sendMsgEnabled: Boolean + val ntfsEnabled: Boolean val createdAt: Instant val updatedAt: Instant } @@ -347,6 +380,8 @@ data class Chat ( @Serializable sealed class ChatInfo: SomeChat, NamedChat { + abstract val incognito: Boolean + @Serializable @SerialName("direct") class Direct(val contact: Contact): ChatInfo() { override val chatType get() = ChatType.Direct @@ -355,11 +390,14 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contact.apiId override val ready get() = contact.ready override val sendMsgEnabled get() = contact.sendMsgEnabled + override val ntfsEnabled get() = contact.chatSettings.enableNtfs + override val incognito get() = contact.contactConnIncognito override val createdAt get() = contact.createdAt override val updatedAt get() = contact.updatedAt override val displayName get() = contact.displayName override val fullName get() = contact.fullName override val image get() = contact.image + override val localAlias: String get() = contact.localAlias companion object { val sampleData = Direct(Contact.sampleData) @@ -374,11 +412,14 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = groupInfo.apiId override val ready get() = groupInfo.ready override val sendMsgEnabled get() = groupInfo.sendMsgEnabled + override val ntfsEnabled get() = groupInfo.chatSettings.enableNtfs + override val incognito get() = groupInfo.membership.memberIncognito override val createdAt get() = groupInfo.createdAt override val updatedAt get() = groupInfo.updatedAt override val displayName get() = groupInfo.displayName override val fullName get() = groupInfo.fullName override val image get() = groupInfo.image + override val localAlias get() = groupInfo.localAlias companion object { val sampleData = Group(GroupInfo.sampleData) @@ -393,11 +434,14 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactRequest.apiId override val ready get() = contactRequest.ready override val sendMsgEnabled get() = contactRequest.sendMsgEnabled + override val ntfsEnabled get() = false + override val incognito get() = false override val createdAt get() = contactRequest.createdAt override val updatedAt get() = contactRequest.updatedAt override val displayName get() = contactRequest.displayName override val fullName get() = contactRequest.fullName override val image get() = contactRequest.image + override val localAlias get() = contactRequest.localAlias companion object { val sampleData = ContactRequest(UserContactRequest.sampleData) @@ -412,11 +456,14 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready override val sendMsgEnabled get() = contactConnection.sendMsgEnabled + override val ntfsEnabled get() = false + override val incognito get() = contactConnection.incognito override val createdAt get() = contactConnection.createdAt override val updatedAt get() = contactConnection.updatedAt override val displayName get() = contactConnection.displayName override val fullName get() = contactConnection.fullName override val image get() = contactConnection.image + override val localAlias get() = contactConnection.localAlias companion object { fun getSampleData(status: ConnStatus = ConnStatus.New, viaContactUri: Boolean = false): ContactConnection = @@ -426,12 +473,13 @@ sealed class ChatInfo: SomeChat, NamedChat { } @Serializable -class Contact( +data class Contact( val contactId: Long, override val localDisplayName: String, - val profile: Profile, + val profile: LocalProfile, val activeConn: Connection, val viaGroup: Long? = null, + val chatSettings: ChatSettings, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -440,19 +488,25 @@ class Contact( override val apiId get() = contactId override val ready get() = activeConn.connStatus == ConnStatus.Ready override val sendMsgEnabled get() = true - override val displayName get() = profile.displayName + override val ntfsEnabled get() = chatSettings.enableNtfs + override val displayName get() = localAlias.ifEmpty { profile.displayName } override val fullName get() = profile.fullName override val image get() = profile.image + override val localAlias get() = profile.localAlias val isIndirectContact: Boolean get() = activeConn.connLevel > 0 || viaGroup != null + val contactConnIncognito = + activeConn.customUserProfileId != null + companion object { val sampleData = Contact( contactId = 1, localDisplayName = "alice", - profile = Profile.sampleData, + profile = LocalProfile.sampleData, activeConn = Connection.sampleData, + chatSettings = ChatSettings(true), createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -474,10 +528,10 @@ class ContactSubStatus( ) @Serializable -class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int) { +class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: Int, val customUserProfileId: Long? = null) { val id: ChatId get() = ":$connId" companion object { - val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0) + val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, customUserProfileId = null) } } @@ -485,13 +539,16 @@ class Connection(val connId: Long, val connStatus: ConnStatus, val connLevel: In class Profile( override val displayName: String, override val fullName: String, - override val image: String? = null + override val image: String? = null, + override val localAlias : String = "" ): NamedChat { val profileViewName: String get() { return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } + fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, image, localAlias) + companion object { val sampleData = Profile( displayName = "alice", @@ -500,6 +557,28 @@ class Profile( } } +@Serializable +class LocalProfile( + val profileId: Long, + override val displayName: String, + override val fullName: String, + override val image: String? = null, + override val localAlias: String, +): NamedChat { + val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" } + + fun toProfile(): Profile = Profile(displayName, fullName, image, localAlias) + + companion object { + val sampleData = LocalProfile( + profileId = 1L, + displayName = "alice", + fullName = "Alice", + localAlias = "" + ) + } +} + @Serializable class Group ( val groupInfo: GroupInfo, @@ -507,11 +586,13 @@ class Group ( ) @Serializable -class GroupInfo ( +data class GroupInfo ( val groupId: Long, override val localDisplayName: String, val groupProfile: GroupProfile, val membership: GroupMember, + val hostConnCustomUserProfileId: Long? = null, + val chatSettings: ChatSettings, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -520,9 +601,11 @@ class GroupInfo ( override val apiId get() = groupId override val ready get() = true override val sendMsgEnabled get() = membership.memberActive + override val ntfsEnabled get() = chatSettings.enableNtfs override val displayName get() = groupProfile.displayName override val fullName get() = groupProfile.fullName override val image get() = groupProfile.image + override val localAlias get() = "" val canEdit: Boolean get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent @@ -539,6 +622,8 @@ class GroupInfo ( localDisplayName = "team", groupProfile = GroupProfile.sampleData, membership = GroupMember.sampleData, + hostConnCustomUserProfileId = null, + chatSettings = ChatSettings(true), createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -549,7 +634,8 @@ class GroupInfo ( class GroupProfile ( override val displayName: String, override val fullName: String, - override val image: String? = null + override val image: String? = null, + override val localAlias: String = "", ): NamedChat { companion object { val sampleData = GroupProfile( @@ -569,17 +655,18 @@ class GroupMember ( var memberStatus: GroupMemberStatus, var invitedBy: InvitedBy, val localDisplayName: String, - val memberProfile: Profile, + val memberProfile: LocalProfile, val memberContactId: Long? = null, + val memberContactProfileId: Long, var activeConn: Connection? = null ) { val id: String get() = "#$groupId @$groupMemberId" - val displayName: String get() = memberProfile.displayName + val displayName: String get() = memberProfile.localAlias.ifEmpty { memberProfile.displayName } val fullName: String get() = memberProfile.fullName val image: String? get() = memberProfile.image val chatViewName: String - get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") + get() = memberProfile.localAlias.ifEmpty { displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } val memberActive: Boolean get() = when (this.memberStatus) { GroupMemberStatus.MemRemoved -> false @@ -615,6 +702,8 @@ class GroupMember ( && userRole >= GroupMemberRole.Admin && userRole >= memberRole && membership.memberCurrent } + val memberIncognito = memberProfile.profileId != memberContactProfileId + companion object { val sampleData = GroupMember( groupMemberId = 1, @@ -625,8 +714,9 @@ class GroupMember ( memberStatus = GroupMemberStatus.MemComplete, invitedBy = InvitedBy.IBUser(), localDisplayName = "alice", - memberProfile = Profile.sampleData, + memberProfile = LocalProfile.sampleData, memberContactId = 1, + memberContactProfileId = 1L, activeConn = Connection.sampleData ) } @@ -740,9 +830,11 @@ class UserContactRequest ( override val apiId get() = contactRequestId override val ready get() = true override val sendMsgEnabled get() = false + override val ntfsEnabled get() = false override val displayName get() = profile.displayName override val fullName get() = profile.fullName override val image get() = profile.image + override val localAlias get() = "" companion object { val sampleData = UserContactRequest( @@ -761,6 +853,7 @@ class PendingContactConnection( val pccAgentConnId: String, val pccConnStatus: ConnStatus, val viaContactUri: Boolean, + val customUserProfileId: Long? = null, override val createdAt: Instant, override val updatedAt: Instant ): SomeChat, NamedChat { @@ -769,6 +862,7 @@ class PendingContactConnection( override val apiId get() = pccConnId override val ready get() = false override val sendMsgEnabled get() = false + override val ntfsEnabled get() = false override val localDisplayName get() = String.format(generalGetString(R.string.connection_local_display_name), pccConnId) override val displayName: String get() { val initiated = pccConnStatus.initiated @@ -784,14 +878,21 @@ class PendingContactConnection( } override val fullName get() = "" override val image get() = null + override val localAlias get() = "" + val initiated get() = (pccConnStatus.initiated ?: false) && !viaContactUri + val incognito = customUserProfileId != null + val description: String get() { val initiated = pccConnStatus.initiated return if (initiated == null) "" else generalGetString( - if (initiated && !viaContactUri) R.string.description_you_shared_one_time_link - else if (viaContactUri ) R.string.description_via_contact_address_link - else R.string.description_via_one_time_link + if (initiated && !viaContactUri) + if (incognito) R.string.description_you_shared_one_time_link_incognito else R.string.description_you_shared_one_time_link + else if (viaContactUri ) + if (incognito) R.string.description_via_contact_address_link_incognito else R.string.description_via_contact_address_link + else + if (incognito) R.string.description_via_one_time_link_incognito else R.string.description_via_one_time_link ) } @@ -802,6 +903,7 @@ class PendingContactConnection( pccAgentConnId = "abcd", pccConnStatus = status, viaContactUri = viaContactUri, + customUserProfileId = null, createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) @@ -856,7 +958,7 @@ data class ChatItem ( val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew val memberDisplayName: String? get() = - if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName + if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName else null val isDeletedContent: Boolean get() = @@ -1149,9 +1251,11 @@ class CIGroupInvitation ( val groupMemberId: Long, val localDisplayName: String, val groupProfile: GroupProfile, - val status: CIGroupInvitationStatus + val status: CIGroupInvitationStatus, ) { - val text: String get() = String.format(generalGetString(R.string.group_invitation_item_description), groupProfile.displayName) + val text: String get() = String.format( + generalGetString(R.string.group_invitation_item_description), + groupProfile.displayName) companion object { fun getSample( diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index c64b1c8d24..c854183a70 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -64,6 +64,8 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference } fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) { + if (!cInfo.ntfsEnabled) return + notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index e3ee2d55f1..30da6db484 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -15,12 +15,14 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bolt import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import chat.simplex.app.* import chat.simplex.app.R +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.OnboardingStage @@ -86,6 +88,8 @@ class AppPreferences(val context: Context) { val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) + val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name) + val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false) val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout) val networkTCPTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_TIMEOUT, NetCfg.defaults.tcpTimeout, NetCfg.proxyDefaults.tcpTimeout) val networkSMPPingInterval = mkLongPreference(SHARED_PREFS_NETWORK_SMP_PING_INTERVAL, NetCfg.defaults.smpPingInterval) @@ -93,6 +97,10 @@ class AppPreferences(val context: Context) { val networkTCPKeepIdle = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_IDLE, KeepAliveOpts.defaults.keepIdle) val networkTCPKeepIntvl = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_INTVL, KeepAliveOpts.defaults.keepIntvl) val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt) + val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false) + + val currentTheme = mkStrPreference(SHARED_PREFS_CURRENT_THEME, DefaultTheme.SYSTEM.name) + val primaryColor = mkIntPreference(SHARED_PREFS_PRIMARY_COLOR, LightColorPalette.primary.toArgb()) private fun mkIntPreference(prefName: String, default: Int) = Preference( @@ -153,6 +161,8 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" + private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" + private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout" private const val SHARED_PREFS_NETWORK_TCP_TIMEOUT = "NetworkTCPTimeout" private const val SHARED_PREFS_NETWORK_SMP_PING_INTERVAL = "NetworkSMPPingInterval" @@ -160,6 +170,9 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_NETWORK_TCP_KEEP_IDLE = "NetworkTCPKeepIdle" private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl" private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt" + private const val SHARED_PREFS_INCOGNITO = "Incognito" + private const val SHARED_PREFS_CURRENT_THEME = "CurrentTheme" + private const val SHARED_PREFS_PRIMARY_COLOR = "PrimaryColor" } } @@ -172,6 +185,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager init { chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get() chatModel.performLA.value = appPrefs.performLA.get() + chatModel.incognito.value = appPrefs.incognito.get() } suspend fun startChat(user: User) { @@ -182,6 +196,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager val justStarted = apiStartChat() if (justStarted) { apiSetFilesFolder(getAppFilesDirectory(appContext)) + apiSetIncognito(chatModel.incognito.value) chatModel.userAddress.value = apiGetUserAddress() chatModel.userSMPServers.value = getUserSMPServers() val chats = apiGetChats() @@ -290,6 +305,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager throw Error("failed to set files folder: ${r.responseType} ${r.details}") } + suspend fun apiSetIncognito(incognito: Boolean) { + val r = sendCmd(CC.SetIncognito(incognito)) + if (r is CR.CmdOk) return + throw Exception("failed to set incognito: ${r.responseType} ${r.details}") + } + suspend fun apiExportArchive(config: ArchiveConfig) { val r = sendCmd(CC.ApiExportArchive(config)) if (r is CR.CmdOk) return @@ -314,8 +335,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager throw Error("failed getting the list of chats: ${r.responseType} ${r.details}") } - suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(100)): Chat? { - val r = sendCmd(CC.ApiGetChat(type, id, pagination)) + suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { + val r = sendCmd(CC.ApiGetChat(type, id, pagination, search)) if (r is CR.ApiChat ) return r.chat Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") return null @@ -387,9 +408,20 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } } - suspend fun apiContactInfo(contactId: Long): ConnectionStats? { + suspend fun apiSetSettings(type: ChatType,id: Long, settings: ChatSettings): Boolean { + val r = sendCmd(CC.APISetChatSettings(type, id, settings)) + return when (r) { + is CR.CmdOk -> true + else -> { + Log.e(TAG, "apiSetSettings bad response: ${r.responseType} ${r.details}") + false + } + } + } + + suspend fun apiContactInfo(contactId: Long): Pair? { val r = sendCmd(CC.APIContactInfo(contactId)) - if (r is CR.ContactInfo) return r.connectionStats + if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile Log.e(TAG, "apiContactInfo bad response: ${r.responseType} ${r.details}") return null } @@ -500,6 +532,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } + suspend fun apiSetContactAlias(contactId: Long, localAlias: String): Contact? { + val r = sendCmd(CC.ApiSetContactAlias(contactId, localAlias)) + if (r is CR.ContactAliasUpdated) return r.toContact + Log.e(TAG, "apiSetContactAlias bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiCreateUserAddress(): String? { val r = sendCmd(CC.CreateMyAddress()) if (r is CR.UserContactLinkCreated) return r.connReqContact @@ -599,10 +638,11 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } - suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole) { + suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole)) - if (r is CR.SentGroupInvitation) return + if (r is CR.SentGroupInvitation) return r.member Log.e(TAG, "apiAddMember bad response: ${r.responseType} ${r.details}") + return null } suspend fun apiJoinGroup(groupId: Long) { @@ -756,12 +796,22 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager chatModel.addChat(Chat(chatInfo = ChatInfo.Group(r.groupInfo), chatItems = listOf())) // TODO NtfManager.shared.notifyGroupInvitation } + is CR.JoinedGroupMemberConnecting -> + chatModel.upsertGroupMember(r.groupInfo, r.member) + is CR.DeletedMemberUser -> // TODO update user member + chatModel.updateGroup(r.groupInfo) + is CR.DeletedMember -> + chatModel.upsertGroupMember(r.groupInfo, r.deletedMember) + is CR.LeftMember -> + chatModel.upsertGroupMember(r.groupInfo, r.member) + is CR.GroupDeleted -> // TODO update user member + chatModel.updateGroup(r.groupInfo) is CR.UserJoinedGroup -> chatModel.updateGroup(r.groupInfo) - is CR.GroupDeleted -> - chatModel.updateGroup(r.groupInfo) - is CR.DeletedMemberUser -> - chatModel.updateGroup(r.groupInfo) + is CR.JoinedGroupMember -> + chatModel.upsertGroupMember(r.groupInfo, r.member) + is CR.ConnectedToGroupMember -> + chatModel.upsertGroupMember(r.groupInfo, r.member) is CR.GroupUpdated -> chatModel.updateGroup(r.toGroup) is CR.RcvFileStart -> @@ -879,7 +929,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager fun showBackgroundServiceNoticeIfNeeded() { Log.d(TAG, "showBackgroundServiceNoticeIfNeeded") if (!appPrefs.backgroundServiceNoticeShown.get()) { - // the branch for the new users who has never seen service notice + // the branch for the new users who have never seen service notice if (isIgnoringBatteryOptimizations(appContext)) { showBGServiceNotice() } else { @@ -892,15 +942,19 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager // the branch for users who have app installed, and have seen the service notice, // but the battery optimization for the app is on (Android 12) AND the service is running if (appPrefs.backgroundServiceBatteryNoticeShown.get()) { - // users have been presented with battery notice before - they did not allow ignoring optimizitions -> disable service + // users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service showDisablingServiceNotice() appPrefs.runServiceInBackground.set(false) chatModel.runServiceInBackground.value = false + SimplexService.StartReceiver.toggleReceiver(false) } else { // show battery optimization notice showBGServiceNoticeIgnoreOptimization() appPrefs.backgroundServiceBatteryNoticeShown.set(true) } + } else { + // service is allowed and battery optimization is disabled + SimplexApp.context.schedulePeriodicServiceRestartWorker() } } @@ -926,7 +980,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } }, confirmButton = { - Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } + TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } } ) } @@ -957,7 +1011,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } }, confirmButton = { - Button(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) } + TextButton(onClick = ignoreOptimization) { Text(stringResource(R.string.ok)) } } ) } @@ -983,7 +1037,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } }, confirmButton = { - Button(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } + TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(R.string.ok)) } } ) } @@ -1053,6 +1107,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager fun getNetCfg(): NetCfg { val useSocksProxy = appPrefs.networkUseSocksProxy.get() val socksProxy = if (useSocksProxy) ":9050" else null + val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!) + val requiredHostMode = appPrefs.networkRequiredHostMode.get() val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get() val tcpTimeout = appPrefs.networkTCPTimeout.get() val smpPingInterval = appPrefs.networkSMPPingInterval.get() @@ -1067,6 +1123,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } return NetCfg( socksProxy = socksProxy, + hostMode = hostMode, + requiredHostMode = requiredHostMode, tcpConnectTimeout = tcpConnectTimeout, tcpTimeout = tcpTimeout, tcpKeepAlive = tcpKeepAlive, @@ -1076,6 +1134,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager fun setNetCfg(cfg: NetCfg) { appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy) + appPrefs.networkHostMode.set(cfg.hostMode.name) + appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode) appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout) appPrefs.networkTCPTimeout.set(cfg.tcpTimeout) appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval) @@ -1100,11 +1160,12 @@ sealed class CC { class StartChat: CC() class ApiStopChat: CC() class SetFilesFolder(val filesFolder: String): CC() + class SetIncognito(val incognito: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() class ApiGetChats: CC() - class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination): CC() + class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() @@ -1120,6 +1181,7 @@ sealed class CC { class SetUserSMPServers(val smpServers: List): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() + class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() class APIContactInfo(val contactId: Long): CC() class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class AddContact: CC() @@ -1129,6 +1191,7 @@ sealed class CC { class ListContacts: CC() class ApiUpdateProfile(val profile: Profile): CC() class ApiParseMarkdown(val text: String): CC() + class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC() class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() @@ -1151,11 +1214,12 @@ sealed class CC { is StartChat -> "/_start" is ApiStopChat -> "/_stop" is SetFilesFolder -> "/_files_folder $filesFolder" + is SetIncognito -> "/incognito ${if (incognito) "on" else "off"}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" is ApiGetChats -> "/_get chats pcc=on" - is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" @@ -1170,6 +1234,7 @@ sealed class CC { is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" + is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is AddContact -> "/connect" @@ -1179,6 +1244,7 @@ sealed class CC { is ListContacts -> "/contacts" is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" is ApiParseMarkdown -> "/_parse $text" + is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}" is CreateMyAddress -> "/address" is DeleteMyAddress -> "/delete_address" is ShowMyAddress -> "/show_address" @@ -1202,6 +1268,7 @@ sealed class CC { is StartChat -> "startChat" is ApiStopChat -> "apiStopChat" is SetFilesFolder -> "setFilesFolder" + is SetIncognito -> "setIncognito" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" @@ -1221,6 +1288,7 @@ sealed class CC { is SetUserSMPServers -> "setUserSMPServers" is APISetNetworkConfig -> "/apiSetNetworkConfig" is APIGetNetworkConfig -> "/apiGetNetworkConfig" + is APISetChatSettings -> "/apiSetChatSettings" is APIContactInfo -> "apiContactInfo" is APIGroupMemberInfo -> "apiGroupMemberInfo" is AddContact -> "addContact" @@ -1230,6 +1298,7 @@ sealed class CC { is ListContacts -> "listContacts" is ApiUpdateProfile -> "updateProfile" is ApiParseMarkdown -> "apiParseMarkdown" + is ApiSetContactAlias -> "apiSetContactAlias" is CreateMyAddress -> "createMyAddress" is DeleteMyAddress -> "deleteMyAddress" is ShowMyAddress -> "showMyAddress" @@ -1265,6 +1334,12 @@ sealed class ChatPagination { is After -> "after=${this.chatItemId} count=${this.count}" is Before -> "before=${this.chatItemId} count=${this.count}" } + + companion object { + const val INITIAL_COUNT = 100 + const val PRELOAD_COUNT = 100 + const val UNTIL_PRELOAD_COUNT = 50 + } } @Serializable @@ -1276,6 +1351,8 @@ class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = @Serializable data class NetCfg( val socksProxy: String? = null, + val hostMode: HostMode = HostMode.OnionViaSocks, + val requiredHostMode: Boolean = false, val tcpConnectTimeout: Long, // microseconds val tcpTimeout: Long, // microseconds val tcpKeepAlive: KeepAliveOpts?, @@ -1303,6 +1380,33 @@ data class NetCfg( smpPingInterval = 600_000_000 ) } + + val onionHosts: OnionHosts get() = when { + hostMode == HostMode.Public && requiredHostMode -> OnionHosts.NEVER + hostMode == HostMode.OnionViaSocks && !requiredHostMode -> OnionHosts.PREFER + hostMode == HostMode.OnionViaSocks && requiredHostMode -> OnionHosts.REQUIRED + else -> OnionHosts.PREFER + } + + fun withOnionHosts(mode: OnionHosts): NetCfg = when (mode) { + OnionHosts.NEVER -> + this.copy(hostMode = HostMode.Public, requiredHostMode = true) + OnionHosts.PREFER -> + this.copy(hostMode = HostMode.OnionViaSocks, requiredHostMode = false) + OnionHosts.REQUIRED -> + this.copy(hostMode = HostMode.OnionViaSocks, requiredHostMode = true) + } +} + +enum class OnionHosts { + NEVER, PREFER, REQUIRED +} + +@Serializable +enum class HostMode { + @SerialName("onionViaSocks") OnionViaSocks, + @SerialName("onion") Onion, + @SerialName("public") Public; } @Serializable @@ -1317,9 +1421,15 @@ data class KeepAliveOpts( } } +@Serializable +data class ChatSettings( + val enableNtfs: Boolean +) + val json = Json { prettyPrint = true ignoreUnknownKeys = true + encodeDefaults = true } @Serializable @@ -1355,7 +1465,7 @@ sealed class CR { @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() - @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats): CR() + @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() @Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation: CR() @@ -1365,6 +1475,7 @@ sealed class CR { @Serializable @SerialName("chatCleared") class ChatCleared(val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR() + @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR() @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() @@ -1390,7 +1501,7 @@ sealed class CR { @Serializable @SerialName("contactsList") class ContactsList(val contacts: List): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact): CR() + @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val groupInfo: GroupInfo): CR() @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val groupInfo: GroupInfo): CR() @@ -1403,11 +1514,11 @@ sealed class CR { @Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("contactsMerged") class ContactsMerged(val intoContact: Contact, val mergedContact: Contact): CR() - @Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR() + @Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() + @Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() // unused @Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR() // receiving file events @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR() @@ -1451,6 +1562,7 @@ sealed class CR { is ChatCleared -> "chatCleared" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" + is ContactAliasUpdated -> "contactAliasUpdated" is ParsedMarkdown -> "apiParsedMarkdown" is UserContactLink -> "userContactLink" is UserContactLinkCreated -> "userContactLinkCreated" @@ -1535,6 +1647,7 @@ sealed class CR { is ChatCleared -> json.encodeToString(chatInfo) is UserProfileNoChange -> noDetails() is UserProfileUpdated -> json.encodeToString(toProfile) + is ContactAliasUpdated -> json.encodeToString(toContact) is ParsedMarkdown -> json.encodeToString(formattedText) is UserContactLink -> connReqContact is UserContactLinkCreated -> connReqContact @@ -1559,7 +1672,7 @@ sealed class CR { is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}" is ContactsList -> json.encodeToString(contacts) is GroupCreated -> json.encodeToString(groupInfo) - is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact" + is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member" is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is UserDeletedMember -> "groupInfo: $groupInfo\nmember: $member" is LeftMemberUser -> json.encodeToString(groupInfo) diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 8f02fee808..5b3e0b0ea6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -7,6 +7,7 @@ val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) val Gray = Color(0x22222222) +val Indigo = Color(0xff330099) val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files val SimplexGreen = Color(77, 218, 103, 255) val SecretColor = Color(0x40808080) @@ -15,8 +16,8 @@ val DarkGray = Color(43, 44, 46, 255) val HighOrLowlight = Color(139, 135, 134, 255) val MessagePreviewDark = Color(179, 175, 174, 255) val MessagePreviewLight = Color(49, 45, 44, 255) -val ToolbarLight = Color(220, 220, 220, 20) -val ToolbarDark = Color(80, 80, 80, 20) +val ToolbarLight = Color(220, 220, 220, 12) +val ToolbarDark = Color(80, 80, 80, 12) val SettingsBackgroundLight = Color(220, 216, 215, 90) val SettingsSecondaryLight = Color(200, 196, 195, 90) val GroupDark = Color(80, 80, 80, 60) diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt index a7ee4c9fb7..a475beed7c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt @@ -2,10 +2,15 @@ package chat.simplex.app.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.flow.MutableStateFlow -private val DarkColorPalette = darkColors( +enum class DefaultTheme { + SYSTEM, DARK, LIGHT +} + +val DarkColorPalette = darkColors( primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files primaryVariant = SimplexGreen, secondary = DarkGray, @@ -18,7 +23,7 @@ private val DarkColorPalette = darkColors( onSurface = Color(0xFFFFFBFA), // onError: Color = Color.Black, ) -private val LightColorPalette = lightColors( +val LightColorPalette = lightColors( primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files primaryVariant = SimplexGreen, secondary = LightGray, @@ -30,16 +35,28 @@ private val LightColorPalette = lightColors( // onSurface = Color.Black, ) -@Composable -fun SimpleXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) { - DarkColorPalette - } else { - LightColorPalette - } +val CurrentColors: MutableStateFlow> = MutableStateFlow(ThemeManager.currentColors(true)) +@Composable +fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight + +@Composable +fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) { + LaunchedEffect(darkTheme) { + // For preview + if (darkTheme != null) + CurrentColors.value = ThemeManager.currentColors(darkTheme) + } + val systemDark = isSystemInDarkTheme() + LaunchedEffect(systemDark) { + if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) { + // Change active colors from light to dark and back based on system theme + ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark) + } + } + val theme by CurrentColors.collectAsState() MaterialTheme( - colors = colors, + colors = theme.first, typography = Typography, shapes = Shapes, content = content diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt new file mode 100644 index 0000000000..a1d5ee4526 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt @@ -0,0 +1,64 @@ +package chat.simplex.app.ui.theme + +import androidx.compose.material.Colors +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import chat.simplex.app.R +import chat.simplex.app.SimplexApp +import chat.simplex.app.model.AppPreferences +import chat.simplex.app.views.helpers.generalGetString + +object ThemeManager { + private val appPrefs: AppPreferences by lazy { + AppPreferences(SimplexApp.context) + } + + fun currentColors(darkForSystemTheme: Boolean): Pair { + val theme = appPrefs.currentTheme.get()!! + val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette + val res = when (theme) { + DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM) + DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK) + DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT) + else -> Pair(systemThemeColors, DefaultTheme.SYSTEM) + } + return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get()))) + } + + // colors, default theme enum, localized name of theme + fun allThemes(darkForSystemTheme: Boolean): List> { + val allThemes = ArrayList>() + allThemes.add( + Triple( + if (darkForSystemTheme) DarkColorPalette else LightColorPalette, + DefaultTheme.SYSTEM, + generalGetString(R.string.theme_system) + ) + ) + allThemes.add( + Triple( + LightColorPalette, + DefaultTheme.LIGHT, + generalGetString(R.string.theme_light) + ) + ) + allThemes.add( + Triple( + DarkColorPalette, + DefaultTheme.DARK, + generalGetString(R.string.theme_dark) + ) + ) + return allThemes + } + + fun applyTheme(name: String, darkForSystemTheme: Boolean) { + appPrefs.currentTheme.set(name) + CurrentColors.value = currentColors(darkForSystemTheme) + } + + fun saveAndApplyPrimaryColor(color: Color) { + appPrefs.primaryColor.set(color.toArgb()) + CurrentColors.value = currentColors(!CurrentColors.value.first.isLight) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 46709dd1c8..c0ca2ae479 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -82,13 +82,9 @@ fun TerminalLayout( @Composable fun TerminalLog(terminalItems: List) { val listState = rememberLazyListState() - val keyboardState by getKeyboardState() - val ciListState = rememberSaveable(stateSaver = CIListStateSaver) { - mutableStateOf(CIListState(false, terminalItems.count(), keyboardState)) - } - val scope = rememberCoroutineScope() - LazyColumn(state = listState) { - items(terminalItems) { item -> + val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } } + LazyColumn(state = listState, reverseLayout = true) { + items(reversedTerminalItems) { item -> Text("${item.date.toString().subSequence(11, 19)} ${item.label}", style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary), maxLines = 1, @@ -104,13 +100,6 @@ fun TerminalLog(terminalItems: List) { } ) } - val len = terminalItems.count() - if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) { - scope.launch { - ciListState.value = CIListState(true, len, keyboardState) - listState.animateScrollToItem(len - 1) - } - } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt index b6f529600b..2b0f39a346 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -1,7 +1,10 @@ package chat.simplex.app.views.call import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity import android.content.Context +import android.content.pm.ActivityInfo import android.media.AudioManager import android.util.Log import android.view.ViewGroup @@ -41,6 +44,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +@SuppressLint("SourceLockedOrientationActivity") @Composable fun ActiveCallView(chatModel: ChatModel) { BackHandler(onBack = { @@ -122,6 +126,17 @@ fun ActiveCallView(chatModel: ChatModel) { val call = chatModel.activeCall.value if (call != null) ActiveCallOverlay(call, chatModel) } + + val context = LocalContext.current + DisposableEffect(Unit) { + val activity = context as? Activity ?: return@DisposableEffect onDispose {} + // Lock orientation to portrait in order to have good experience with calls + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + onDispose { + // Unlock orientation + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } } @Composable @@ -337,6 +352,8 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa val wv = webView.value if (wv != null) processCommand(wv, WCallCommand.End) lifecycleOwner.lifecycle.removeObserver(observer) + webView.value?.destroy() + webView.value = null } } LaunchedEffect(callCommand.value, webView.value) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt index 631649211c..455fc915f3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt @@ -45,16 +45,19 @@ fun IncomingCallAlertLayout( ignoreCall: () -> Unit, acceptCall: () -> Unit ) { - val color = if (isSystemInDarkTheme()) IncomingCallDark else IncomingCallLight - Column(Modifier.background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) { + val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight + Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) { IncomingCallInfo(invitation) Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White) - Spacer(Modifier.fillMaxWidth().weight(1f)) - CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall) - CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall) - CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) { + ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White) + } + Row(verticalAlignment = Alignment.CenterVertically) { + CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall) + CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall) + CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index 7580abdce2..6ed71d2955 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -1,47 +1,105 @@ package chat.simplex.app.views.chat import InfoRow +import InfoRowEllipsis import SectionDivider import SectionItemView import SectionSpacer import SectionView +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +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.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R +import chat.simplex.app.SimplexApp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* @Composable -fun ChatInfoView(chatModel: ChatModel, connStats: ConnectionStats?, close: () -> Unit) { +fun ChatInfoView( + chatModel: ChatModel, + contact: Contact, + connStats: ConnectionStats?, + customUserProfile: Profile?, + localAlias: String, + close: () -> Unit, + onChatUpdated: (Chat) -> Unit, +) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null) { ChatInfoLayout( chat, + contact, connStats, + customUserProfile, + localAlias, developerTools, + onLocalAliasChanged = { + setContactAlias(chat.chatInfo.apiId, it, chatModel, onChatUpdated) + }, deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, - clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) } + clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, + changeNtfsState = { enabled -> + changeNtfsState(enabled, chat, chatModel) + }, ) } } +fun changeNtfsState(enabled: Boolean, chat: Chat, chatModel: ChatModel) { + val newChatInfo = when(chat.chatInfo) { + is ChatInfo.Direct -> with (chat.chatInfo) { + ChatInfo.Direct(contact.copy(chatSettings = contact.chatSettings.copy(enableNtfs = enabled))) + } + is ChatInfo.Group -> with(chat.chatInfo) { + ChatInfo.Group(groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(enableNtfs = enabled))) + } + else -> null + } + withApi { + val res = when (newChatInfo) { + is ChatInfo.Direct -> with(newChatInfo) { + chatModel.controller.apiSetSettings(chatType, apiId, contact.chatSettings) + } + is ChatInfo.Group -> with(newChatInfo) { + chatModel.controller.apiSetSettings(chatType, apiId, groupInfo.chatSettings) + } + else -> false + } + if (res && newChatInfo != null) { + chatModel.updateChatInfo(newChatInfo) + if (!enabled) { + chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) + } + } + } +} + fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertMsg( title = generalGetString(R.string.delete_contact_question), @@ -82,10 +140,15 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit @Composable fun ChatInfoLayout( chat: Chat, + contact: Contact, connStats: ConnectionStats?, + customUserProfile: Profile?, + localAlias: String, developerTools: Boolean, + onLocalAliasChanged: (String) -> Unit, deleteContact: () -> Unit, - clearChat: () -> Unit + clearChat: () -> Unit, + changeNtfsState: (Boolean) -> Unit, ) { Column( Modifier @@ -97,8 +160,18 @@ fun ChatInfoLayout( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - ChatInfoHeader(chat.chatInfo) + ChatInfoHeader(chat.chatInfo, contact) } + + LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged) + + if (customUserProfile != null) { + SectionSpacer() + SectionView(generalGetString(R.string.incognito).uppercase()) { + InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName) + } + } + SectionSpacer() if (connStats != null) { @@ -120,6 +193,17 @@ fun ChatInfoLayout( SectionSpacer() } + var ntfsEnabled by remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + SectionView(title = stringResource(R.string.settings_section_title_settings)) { + SectionItemView { + NtfsSwitch(ntfsEnabled) { + ntfsEnabled = !ntfsEnabled + changeNtfsState(ntfsEnabled) + } + } + } + SectionSpacer() + SectionView { SectionItemView { ClearChatButton(clearChat) @@ -143,19 +227,20 @@ fun ChatInfoLayout( } @Composable -fun ChatInfoHeader(cInfo: ChatInfo) { +fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { Column( Modifier.padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight) + ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) Text( - cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 8.dp) ) - if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) { Text( cInfo.fullName, style = MaterialTheme.typography.h2, color = MaterialTheme.colors.onBackground, @@ -166,6 +251,41 @@ fun ChatInfoHeader(cInfo: ChatInfo) { } } +@Composable +private fun LocalAliasEditor(initialValue: String, updateValue: (String) -> Unit) { + var value by rememberSaveable { mutableStateOf(initialValue) } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + DefaultBasicTextField( + Modifier.padding(horizontal = 10.dp).widthIn(min = 100.dp), + value, + { + Text( + generalGetString(R.string.text_field_set_contact_placeholder), + textAlign = TextAlign.Center, + color = HighOrLowlight + ) + }, + color = HighOrLowlight, + textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty()) TextAlign.Start else TextAlign.Center), + keyboardActions = KeyboardActions(onDone = { updateValue(value) }) + ) { + value = it + } + } + LaunchedEffect(Unit) { + snapshotFlow { value } + .onEach { delay(500) } // wait a little after every new character, don't emit until user stops typing + .conflate() // get the latest value + .filter { it == value } // don't process old ones + .collect { + updateValue(value) + } + } + DisposableEffect(Unit) { + onDispose { updateValue(value) } // just in case snapshotFlow will be canceled when user presses Back too fast + } +} + @Composable fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { Row( @@ -223,7 +343,43 @@ fun ServerImage(networkStatus: Chat.NetworkStatus) { @Composable fun SimplexServers(text: String, servers: List) { val info = servers.joinToString(separator = ", ") { it.substringAfter("@") } - InfoRow(text, info) + val clipboardManager: ClipboardManager = LocalClipboardManager.current + InfoRowEllipsis(text, info) { + clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ","))) + Toast.makeText(SimplexApp.context, generalGetString(R.string.copied), Toast.LENGTH_SHORT).show() + } +} + +@Composable +fun NtfsSwitch( + ntfsEnabled: Boolean, + toggleNtfs: (Boolean) -> Unit +) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Outlined.Notifications, + stringResource(R.string.notifications), + tint = HighOrLowlight + ) + Text(stringResource(R.string.notifications)) + } + Switch( + checked = ntfsEnabled, + onCheckedChange = toggleNtfs, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + ) + } } @Composable @@ -262,6 +418,13 @@ fun DeleteContactButton(deleteContact: () -> Unit) { } } +private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: ChatModel, onChatUpdated: (Chat) -> Unit) = withApi { + chatModel.controller.apiSetContactAlias(contactApiId, localAlias)?.let { + chatModel.updateContact(it) + onChatUpdated(chatModel.getChat(chatModel.chatId.value ?: return@withApi) ?: return@withApi) + } +} + @Preview @Composable fun PreviewChatInfoLayout() { @@ -272,8 +435,13 @@ fun PreviewChatInfoLayout() { chatItems = arrayListOf(), serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) ), + Contact.sampleData, + localAlias = "", + changeNtfsState = {}, developerTools = false, connStats = null, + onLocalAliasChanged = {}, + customUserProfile = null, deleteContact = {}, clearChat = {} ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index ffb3f282bd..c7c8c06d03 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -1,16 +1,17 @@ package chat.simplex.app.views.chat import android.content.res.Configuration -import android.util.Log import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.mapSaver @@ -19,64 +20,77 @@ 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.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.* import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* import chat.simplex.app.R -import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* -import chat.simplex.app.views.chat.group.AddGroupMembersView -import chat.simplex.app.views.chat.group.GroupChatInfoView +import chat.simplex.app.views.chat.group.* import chat.simplex.app.views.chat.item.ChatItemView -import chat.simplex.app.views.chatlist.openChat -import chat.simplex.app.views.chatlist.populateGroupMembers +import chat.simplex.app.views.chat.item.ItemAction +import chat.simplex.app.views.chatlist.* import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.AppBarHeight import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock @Composable fun ChatView(chatModel: ChatModel) { - val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } + var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) } + val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() - val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) } - val attachmentOption = remember { mutableStateOf(null) } + val composeState = rememberSaveable(saver = ComposeState.saver()) { + mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) + } + val attachmentOption = rememberSaveable { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() - if (chat == null || user == null) { - chatModel.chatId.value = null - } else { - BackHandler { chatModel.chatId.value = null } - // TODO a more advanced version would mark as read only if in view - LaunchedEffect(chat.chatItems) { - Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect") - delay(750L) - if (chat.chatItems.isNotEmpty()) { - chatModel.markChatItemsRead(chat.chatInfo) - chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) - withApi { - chatModel.controller.apiChatRead( - chat.chatInfo.chatType, - chat.chatInfo.apiId, - CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id) - ) + LaunchedEffect(Unit) { + // snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value. + // With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view + snapshotFlow { chatModel.chatId.value } + .distinctUntilChanged() + .collect { + activeChat = if (chatModel.chatId.value == null) { + null + } else { + // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly + // Also for situation when chatId changes after clicking in notification, etc + chatModel.getChat(chatModel.chatId.value!!) } } + } + + if (activeChat == null || user == null) { + chatModel.chatId.value = null + } else { + val chat = activeChat!! + BackHandler { chatModel.chatId.value = null } + + // We need to have real unreadCount value for displaying it inside top right button + // Having activeChat reloaded on every change in it is inefficient (UI lags) + val unreadCount = remember { + derivedStateOf { + chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0 + } } + ChatLayout( user, chat, + unreadCount, composeState, composeView = { if (chat.chatInfo.sendMsgEnabled) { @@ -90,27 +104,31 @@ fun ChatView(chatModel: ChatModel) { scope, attachmentBottomSheetState, chatModel.chatItems, + searchText, useLinkPreviews = useLinkPreviews, + chatModelIncognito = chatModel.incognito.value, back = { chatModel.chatId.value = null }, info = { withApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { - val connStats = chatModel.controller.apiContactInfo(cInfo.apiId) + val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId) ModalManager.shared.showCustomModal { close -> ModalView( close = close, modifier = Modifier, - background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { - ChatInfoView(chatModel, connStats, close) + ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close) { + activeChat = it + } } } } else if (cInfo is ChatInfo.Group) { - populateGroupMembers(cInfo.groupInfo, chatModel) + setGroupMembers(cInfo.groupInfo, chatModel) ModalManager.shared.showCustomModal { close -> ModalView( close = close, modifier = Modifier, - background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { GroupChatInfoView(chatModel, close) } @@ -118,11 +136,27 @@ fun ChatView(chatModel: ChatModel) { } } }, - openDirectChat = { contactId -> - val c = chatModel.chats.firstOrNull { - it.chatInfo is ChatInfo.Direct && it.chatInfo.contact.contactId == contactId + showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> + withApi { + val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + ModalManager.shared.showCustomModal { close -> + ModalView( + close = close, modifier = Modifier, + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + ) { + GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close) + } + } + } + }, + loadPrevMessages = { cInfo -> + val c = chatModel.getChat(cInfo.id) + val firstId = chatModel.chatItems.firstOrNull()?.id + if (c != null && firstId != null) { + withApi { + apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) + } } - if (c != null) withApi { openChat(c.chatInfo, chatModel) } }, deleteMessage = { itemId, mode -> withApi { @@ -160,16 +194,35 @@ fun ChatView(chatModel: ChatModel) { }, addMembers = { groupInfo -> withApi { - populateGroupMembers(groupInfo, chatModel) + setGroupMembers(groupInfo, chatModel) ModalManager.shared.showCustomModal { close -> ModalView( close = close, modifier = Modifier, - background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { AddGroupMembersView(groupInfo, chatModel, close) } } } + }, + markRead = { range, unreadCountAfter -> + chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) + chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) + withApi { + chatModel.controller.apiChatRead( + chat.chatInfo.chatType, + chat.chatInfo.apiId, + range + ) + } + }, + onSearchValueChanged = { value -> + if (searchText.value == value) return@ChatLayout + val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout + withApi { + apiFindMessages(c.chatInfo, chatModel, value) + searchText.value = value + } } ) } @@ -179,22 +232,28 @@ fun ChatView(chatModel: ChatModel) { fun ChatLayout( user: User, chat: Chat, + unreadCount: State, composeState: MutableState, composeView: (@Composable () -> Unit), attachmentOption: MutableState, scope: CoroutineScope, attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, + searchValue: State, useLinkPreviews: Boolean, + chatModelIncognito: Boolean, back: () -> Unit, info: () -> Unit, - openDirectChat: (Long) -> Unit, + showMemberInfo: (GroupInfo, GroupMember) -> Unit, + loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, acceptCall: (Contact) -> Unit, - addMembers: (GroupInfo) -> Unit + addMembers: (GroupInfo) -> Unit, + markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, + onSearchValueChanged: (String) -> Unit, ) { Surface( Modifier @@ -214,13 +273,23 @@ fun ChatLayout( sheetState = attachmentBottomSheetState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { + val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) } + val setFloatingButton = { button: @Composable () -> Unit -> + floatingButton.value = button + } + Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, onSearchValueChanged) }, bottomBar = composeView, - modifier = Modifier.navigationBarsWithImePadding() + modifier = Modifier.navigationBarsWithImePadding(), + floatingActionButton = { floatingButton.value() }, ) { contentPadding -> - Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, composeState, chatItems, useLinkPreviews, openDirectChat, deleteMessage, receiveFile, joinGroup, acceptCall) + BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) { + ChatItemsList( + user, chat, unreadCount, composeState, chatItems, searchValue, + useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, + receiveFile, joinGroup, acceptCall, markRead, setFloatingButton + ) } } } @@ -234,54 +303,79 @@ fun ChatInfoToolbar( back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, - addMembers: (GroupInfo) -> Unit + addMembers: (GroupInfo) -> Unit, + onSearchValueChanged: (String) -> Unit, ) { - @Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) { - IconButton(onClick, modifier = modifier) { - Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary) + var showMenu by rememberSaveable { mutableStateOf(false) } + var showSearch by rememberSaveable { mutableStateOf(false) } + val onBackClicked = { + if (!showSearch) { + back() + } else { + onSearchValueChanged("") + showSearch = false } } - Column { - Box( - Modifier - .fillMaxWidth() - .height(52.dp) - .background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight) - .padding(horizontal = 4.dp), - contentAlignment = Alignment.CenterStart, - ) { - val cInfo = chat.chatInfo - toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back) - if (cInfo is ChatInfo.Direct) { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { - Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) { - toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) { - startCall(CallMediaType.Audio) - } - } - toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) { - startCall(CallMediaType.Video) - } - } - } else if (cInfo is ChatInfo.Group) { - if (cInfo.groupInfo.canAddMembers) { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { - toolbarButton(Icons.Outlined.PersonAdd, R.string.icon_descr_add_members) { - addMembers(cInfo.groupInfo) - } - } - } - } - Box( - Modifier - .padding(horizontal = 80.dp).fillMaxWidth() - .clickable(onClick = info), - contentAlignment = Alignment.Center - ) { - ChatInfoToolbarTitle(cInfo) + BackHandler(onBack = onBackClicked) + val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() + val menuItems = arrayListOf<@Composable () -> Unit>() + menuItems.add { + ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = { + showMenu = false + showSearch = true + }) + } + + if (chat.chatInfo is ChatInfo.Direct) { + barButtons.add { + IconButton({ + showMenu = false + startCall(CallMediaType.Audio) + }) { + Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary) } } - Divider() + menuItems.add { + ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = { + showMenu = false + startCall(CallMediaType.Video) + }) + } + } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) { + barButtons.add { + IconButton({ + showMenu = false + addMembers(chat.chatInfo.groupInfo) + }) { + Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } + } + } + barButtons.add { + IconButton({ showMenu = true }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary) + } + } + + DefaultTopAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + title = { ChatInfoToolbarTitle(chat.chatInfo) }, + onTitleClick = info, + showSearch = showSearch, + onSearchValueChanged = onSearchValueChanged, + buttons = barButtons + ) + + Divider(Modifier.padding(top = AppBarHeight)) + + Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + Modifier.widthIn(min = 220.dp) + ) { + menuItems.forEach { it() } + } } } @@ -291,6 +385,9 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { + if (cInfo.incognito) { + IncognitoImage(size = 36.dp, Indigo) + } ChatInfoImage(cInfo, size = imageSize, iconColor) Column( Modifier.padding(start = 8.dp), @@ -300,7 +397,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo cInfo.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) - if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) { Text( cInfo.fullName, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -323,81 +420,274 @@ val CIListStateSaver = run { } @Composable -fun ChatItemsList( +fun BoxWithConstraintsScope.ChatItemsList( user: User, chat: Chat, + unreadCount: State, composeState: MutableState, chatItems: List, + searchValue: State, useLinkPreviews: Boolean, - openDirectChat: (Long) -> Unit, + chatModelIncognito: Boolean, + showMemberInfo: (GroupInfo, GroupMember) -> Unit, + loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, joinGroup: (Long) -> Unit, - acceptCall: (Contact) -> Unit + acceptCall: (Contact) -> Unit, + markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, + setFloatingButton: (@Composable () -> Unit) -> Unit, ) { - val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew }) - val keyboardState by getKeyboardState() - val ciListState = rememberSaveable(stateSaver = CIListStateSaver) { - mutableStateOf(CIListState(false, chatItems.count(), keyboardState)) - } + val listState = rememberLazyListState() val scope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current val cxt = LocalContext.current - LazyColumn(state = listState) { - itemsIndexed(chatItems) { i, cItem -> - if (i == 0) { - Spacer(Modifier.size(8.dp)) - } - if (chat.chatInfo is ChatInfo.Group) { - if (cItem.chatDir is CIDirection.GroupRcv) { - val prevItem = if (i > 0) chatItems[i - 1] else null - val member = cItem.chatDir.groupMember - val showMember = showMemberImage(member, prevItem) - Row(Modifier.padding(start = 8.dp, end = 66.dp)) { - if (showMember) { - val contactId = member.memberContactId - if (contactId == null) { - MemberImage(member) + + // Helps to scroll to bottom after moving from Group to Direct chat + // and prevents scrolling to bottom on orientation change + var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType, shouldAutoScroll) { + if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) { + scope.launch { listState.scrollToItem(0) } + } + // Don't autoscroll next time until it will be needed + shouldAutoScroll = false + } + var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) } + // Scroll to bottom when search value changes from something to nothing and back + LaunchedEffect(searchValue.value.isEmpty()) { + // They are equal when orientation was changed, don't need to scroll. + // LaunchedEffect unaware of this event since it uses remember, not rememberSaveable + if (prevSearchEmptiness == searchValue.value.isEmpty()) return@LaunchedEffect + prevSearchEmptiness = searchValue.value.isEmpty() + + if (listState.firstVisibleItemIndex != 0) { + scope.launch { listState.scrollToItem(0) } + } + } + + PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c -> + loadPrevMessages(c.chatInfo) + } + + Spacer(Modifier.size(8.dp)) + + val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } } + LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { + itemsIndexed(reversedChatItems) { i, cItem -> + CompositionLocalProvider( + // Makes horizontal and vertical scrolling to coexist nicely. + // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view + LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() + ) { + val dismissState = rememberDismissState(initialValue = DismissValue.Default) { false } + val directions = setOf(DismissDirection.EndToStart) + val swipeableModifier = SwipeToDismissModifier( + state = dismissState, + directions = directions, + swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, + ) + val swipedToEnd = (dismissState.overflow.value > 0f && directions.contains(DismissDirection.StartToEnd)) + val swipedToStart = (dismissState.overflow.value < 0f && directions.contains(DismissDirection.EndToStart)) + if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) { + LaunchedEffect(Unit) { + scope.launch { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) } else { - Box( - Modifier - .clip(CircleShape) - .clickable { openDirectChat(contactId) } - ) { - MemberImage(member) - } + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) } - Spacer(Modifier.size(4.dp)) - } else { - Spacer(Modifier.size(42.dp)) } - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) - } - } else { - Box(Modifier.padding(start = 86.dp, end = 12.dp)) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) } } - } else { // direct message - val sent = cItem.chatDir.sent - Box( - Modifier.padding( - start = if (sent) 76.dp else 12.dp, - end = if (sent) 12.dp else 76.dp, - ) - ) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall) + + if (chat.chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { + val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null + val member = cItem.chatDir.groupMember + val showMember = showMemberImage(member, prevItem) + Row(Modifier.padding(start = 8.dp, end = 66.dp).then(swipeableModifier)) { + if (showMember) { + val contactId = member.memberContactId + if (contactId == null) { + MemberImage(member) + } else { + Box( + Modifier + .clip(CircleShape) + .clickable { + showMemberInfo(chat.chatInfo.groupInfo, member) + } + ) { + MemberImage(member) + } + } + Spacer(Modifier.size(4.dp)) + } else { + Spacer(Modifier.size(42.dp)) + } + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) + } + } else { + Box(Modifier.padding(start = 86.dp, end = 12.dp).then(swipeableModifier)) { + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall) + } + } + } else { // direct message + val sent = cItem.chatDir.sent + Box( + Modifier.padding( + start = if (sent) 76.dp else 12.dp, + end = if (sent) 12.dp else 76.dp, + ).then(swipeableModifier) + ) { + ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall) + } + } + + if (cItem.isRcvNew) { + LaunchedEffect(cItem.id) { + scope.launch { + delay(750) + markRead(CC.ItemRange(cItem.id, cItem.id), null) + } + } } } } - val len = chatItems.count() - if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) { - scope.launch { - ciListState.value = CIListState(true, len, keyboardState) - listState.animateScrollToItem(len - 1) + } + FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState) +} + +@Composable +fun BoxWithConstraintsScope.FloatingButtons( + chatItems: List, + unreadCount: State, + minUnreadItemId: Long, + searchValue: State, + markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, + setFloatingButton: (@Composable () -> Unit) -> Unit, + listState: LazyListState +) { + val scope = rememberCoroutineScope() + + var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) } + var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) } + var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) } + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { + firstVisibleIndex = it + firstItemIsVisible = firstVisibleIndex == 0 } + } + + LaunchedEffect(listState) { + // When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block + // so separate them into two LaunchedEffects + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex } + .distinctUntilChanged() + .collect { + lastIndexOfVisibleItems = it + } + } + + val bottomUnreadCount by remember { + derivedStateOf { + if (unreadCount.value == 0) return@derivedStateOf 0 + + val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems + if (chatItems.size <= from || from < 0) return@derivedStateOf 0 + + chatItems.subList(from, chatItems.size).count { it.isRcvNew } } } + + val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt() + + LaunchedEffect(bottomUnreadCount, firstItemIsVisible) { + val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty() + val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible + setFloatingButton( + bottomEndFloatingButton( + bottomUnreadCount, + showButtonWithCounter, + showButtonWithArrow, + onClickArrowDown = { + scope.launch { listState.animateScrollToItem(0) } + }, + onClickCounter = { + scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) } + } + )) + } + // Don't show top FAB if is in search + if (searchValue.value.isNotEmpty()) return + val fabSize = 56.dp + val topUnreadCount by remember { + derivedStateOf { unreadCount.value - bottomUnreadCount } + } + val showButtonWithCounter = topUnreadCount > 0 + val height = with(LocalDensity.current) { maxHeight.toPx() } + var showDropDown by remember { mutableStateOf(false) } + + TopEndFloatingButton( + Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd), + topUnreadCount, + showButtonWithCounter, + onClick = { scope.launch { listState.animateScrollBy(height) } }, + onLongClick = { showDropDown = true } + ) + + DropdownMenu( + expanded = showDropDown, + onDismissRequest = { showDropDown = false }, + Modifier.width(220.dp), + offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize) + ) { + DropdownMenuItem( + onClick = { + markRead( + CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1), + bottomUnreadCount + ) + showDropDown = false + } + ) { + Text( + generalGetString(R.string.mark_read), + maxLines = 1, + ) + } + } +} + +@Composable +fun PreloadItems( + listState: LazyListState, + remaining: Int = 10, + chat: Chat, + items: List<*>, + onLoadMore: (chat: Chat) -> Unit, +) { + LaunchedEffect(listState, chat, items) { + snapshotFlow { listState.layoutInfo } + .map { + val totalItemsNumber = it.totalItemsCount + val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + if (lastVisibleItemIndex > (totalItemsNumber - remaining)) + totalItemsNumber + else + 0 + } + .distinctUntilChanged() + .filter { it > 0 } + .collect { + onLoadMore(chat) + } + } } fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean { @@ -410,6 +700,88 @@ fun MemberImage(member: GroupMember) { ProfileImage(38.dp, member.memberProfile.image) } +@Composable +private fun TopEndFloatingButton( + modifier: Modifier = Modifier, + unreadCount: Int, + showButtonWithCounter: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit +) = when { + showButtonWithCounter -> { + val interactionSource = interactionSourceWithDetection(onClick, onLongClick) + FloatingActionButton( + {}, // no action here + modifier.size(48.dp), + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp), + interactionSource = interactionSource, + ) { + Text( + unreadCountStr(unreadCount), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } + } + else -> { + } +} + +private fun bottomEndFloatingButton( + unreadCount: Int, + showButtonWithCounter: Boolean, + showButtonWithArrow: Boolean, + onClickArrowDown: () -> Unit, + onClickCounter: () -> Unit +): @Composable () -> Unit = when { + showButtonWithCounter -> { + { + FloatingActionButton( + onClick = onClickCounter, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.size(48.dp) + ) { + Text( + unreadCountStr(unreadCount), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } + } + } + showButtonWithArrow -> { + { + FloatingActionButton( + onClick = onClickArrowDown, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + } + } + else -> { + {} + } +} + +private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { + override val longPressTimeoutMillis + get() = + this@bigTouchSlop.longPressTimeoutMillis + override val doubleTapTimeoutMillis + get() = + this@bigTouchSlop.doubleTapTimeoutMillis + override val doubleTapMinTimeMillis + get() = + this@bigTouchSlop.doubleTapMinTimeMillis + override val touchSlop: Float get() = slop +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -437,6 +809,8 @@ fun PreviewChatLayout() { 6, CIDirection.DirectRcv(), Clock.System.now(), "hello" ) ) + val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } + val searchValue = remember { mutableStateOf("") } ChatLayout( user = User.sampleData, chat = Chat( @@ -444,22 +818,28 @@ fun PreviewChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), + unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, attachmentOption = remember { mutableStateOf(null) }, scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, + searchValue, useLinkPreviews = true, + chatModelIncognito = false, back = {}, info = {}, - openDirectChat = {}, + showMemberInfo = {_, _ -> }, + loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, receiveFile = {}, joinGroup = {}, startCall = {}, acceptCall = { _ -> }, - addMembers = { _ -> } + addMembers = { _ -> }, + markRead = { _, _ -> }, + onSearchValueChanged = {}, ) } } @@ -486,6 +866,8 @@ fun PreviewGroupChatLayout() { 6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" ) ) + val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } + val searchValue = remember { mutableStateOf("") } ChatLayout( user = User.sampleData, chat = Chat( @@ -493,22 +875,28 @@ fun PreviewGroupChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), + unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, attachmentOption = remember { mutableStateOf(null) }, scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, + searchValue, useLinkPreviews = true, + chatModelIncognito = false, back = {}, info = {}, - openDirectChat = {}, + showMemberInfo = {_, _ -> }, + loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, receiveFile = {}, joinGroup = {}, startCall = {}, acceptCall = { _ -> }, - addMembers = { _ -> } + addMembers = { _ -> }, + markRead = { _, _ -> }, + onSearchValueChanged = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt index 48917ec05e..dd7b628e1e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt @@ -1,5 +1,4 @@ import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -31,7 +30,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo Modifier .padding(start = 4.dp, end = 2.dp) .size(36.dp), - tint = if (isSystemInDarkTheme()) FileDark else FileLight + tint = if (isInDarkTheme()) FileDark else FileLight ) Text(fileName) Spacer(Modifier.weight(1f)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 6c254935a9..d96b8a75ca 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -4,8 +4,7 @@ import ComposeFileView import ComposeImageView import android.Manifest import android.app.Activity -import android.content.Context -import android.content.Intent +import android.content.* import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.ImageDecoder @@ -26,6 +25,7 @@ import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Reply import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -42,21 +42,26 @@ import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString import java.io.File +@Serializable sealed class ComposePreview { - object NoPreview: ComposePreview() - class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() - class ImagePreview(val image: String): ComposePreview() - class FilePreview(val fileName: String): ComposePreview() + @Serializable object NoPreview: ComposePreview() + @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() + @Serializable class ImagePreview(val image: String): ComposePreview() + @Serializable class FilePreview(val fileName: String): ComposePreview() } +@Serializable sealed class ComposeContextItem { - object NoContextItem: ComposeContextItem() - class QuotedItem(val chatItem: ChatItem): ComposeContextItem() - class EditingItem(val chatItem: ChatItem): ComposeContextItem() + @Serializable object NoContextItem: ComposeContextItem() + @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() + @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() } +@Serializable data class ComposeState( val message: String = "", val preview: ComposePreview = ComposePreview.NoPreview, @@ -99,6 +104,15 @@ data class ComposeState( is ComposePreview.CLinkPreview -> preview.linkPreview else -> null } + + companion object { + fun saver(): Saver, *> = Saver( + save = { json.encodeToString(serializer(), it.value) }, + restore = { + mutableStateOf(json.decodeFromString(it)) + } + ) + } } fun chatItemPreview(chatItem: ChatItem): ComposePreview { @@ -180,7 +194,16 @@ fun ComposeView( Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show() } } - val galleryLauncher = rememberGetContentLauncher { uri: Uri? -> + val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { uri: Uri? -> + if (uri != null) { + val source = ImageDecoder.createSource(context.contentResolver, uri) + val bitmap = ImageDecoder.decodeBitmap(source) + chosenImage.value = bitmap + val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) + composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) + } + } + val galleryLauncherFallback = rememberGetContentLauncher { uri: Uri? -> if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) @@ -221,7 +244,11 @@ fun ComposeView( attachmentOption.value = null } AttachmentOption.PickImage -> { - galleryLauncher.launch("image/*") + try { + galleryLauncher.launch(0) + } catch (e: ActivityNotFoundException) { + galleryLauncherFallback.launch("image/*") + } attachmentOption.value = null } AttachmentOption.PickFile -> { @@ -486,3 +513,9 @@ fun ComposeView( } } } + +class PickFromGallery: ActivityResultContract() { + override fun createIntent(context: Context, input: Int) = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI) + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index b88f5230cc..e456f05766 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -12,11 +12,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.ArrowUpward import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.ui.* import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization @@ -26,7 +28,9 @@ import chat.simplex.app.R import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme +import kotlinx.coroutines.delay +@OptIn(ExperimentalComposeUiApi::class) @Composable fun SendMsgView( composeState: MutableState, @@ -35,6 +39,16 @@ fun SendMsgView( textStyle: MutableState ) { val cs = composeState.value + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + LaunchedEffect(cs.contextItem) { + if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect + // In replying state + focusRequester.requestFocus() + delay(50) + keyboard?.show() + } + BasicTextField( value = cs.message, onValueChange = onMessageChange, @@ -44,7 +58,7 @@ fun SendMsgView( capitalization = KeyboardCapitalization.Sentences, autoCorrect = true ), - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(vertical = 8.dp).focusRequester(focusRequester), cursorBrush = SolidColor(HighOrLowlight), decorationBox = { innerTextField -> Surface( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt index 0411843d2d..252543d150 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt @@ -11,11 +11,14 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.TheaterComedy import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -41,7 +44,10 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> inviteMembers = { withApi { selectedContacts.forEach { - chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value) + val member = chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value) + if (member != null) { + chatModel.upsertGroupMember(groupInfo, member) + } } close.invoke() } @@ -90,7 +96,7 @@ fun AddGroupMembersLayout( ChatInfoToolbarTitle( ChatInfo.Group(groupInfo), imageSize = 60.dp, - iconColor = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight + iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight ) } SectionSpacer() @@ -122,7 +128,7 @@ fun AddGroupMembersLayout( SectionSpacer() SectionView { - ContactList(contacts = contactsToAdd, selectedContacts, addContact, removeContact) + ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, addContact, removeContact) } SectionSpacer() } @@ -252,6 +258,7 @@ fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) fun ContactList( contacts: List, selectedContacts: SnapshotStateList, + groupInfo: GroupInfo, addContact: (Long) -> Unit, removeContact: (Long) -> Unit ) { @@ -259,7 +266,7 @@ fun ContactList( contacts.forEachIndexed { index, contact -> SectionItemView { ContactCheckRow( - contact, addContact, removeContact, + contact, groupInfo, addContact, removeContact, checked = selectedContacts.contains(contact.apiId) ) } @@ -273,14 +280,35 @@ fun ContactList( @Composable fun ContactCheckRow( contact: Contact, + groupInfo: GroupInfo, addContact: (Long) -> Unit, removeContact: (Long) -> Unit, checked: Boolean ) { + val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito + val icon: ImageVector + val iconColor: Color + if (prohibitedToInviteIncognito) { + icon = Icons.Filled.TheaterComedy + iconColor = HighOrLowlight + } else if (checked) { + icon = Icons.Filled.CheckCircle + iconColor = MaterialTheme.colors.primary + } else { + icon = Icons.Outlined.Circle + iconColor = HighOrLowlight + } Row( Modifier .fillMaxSize() - .clickable { if (!checked) addContact(contact.apiId) else removeContact(contact.apiId) }, + .clickable { + if (prohibitedToInviteIncognito) { + showProhibitedToInviteIncognitoAlertDialog() + } else if (!checked) + addContact(contact.apiId) + else + removeContact(contact.apiId) + }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -289,16 +317,27 @@ fun ContactCheckRow( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { ProfileImage(size = 36.dp, contact.image) - Text(contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified + ) } Icon( - if (checked) Icons.Filled.CheckCircle else Icons.Outlined.Circle, + icon, contentDescription = stringResource(R.string.icon_descr_contact_checked), - tint = if (checked) MaterialTheme.colors.primary else HighOrLowlight + tint = iconColor ) } } +fun showProhibitedToInviteIncognitoAlertDialog() { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.invite_prohibited), + text = generalGetString(R.string.invite_prohibited_description), + confirmText = generalGetString(R.string.ok), + ) +} + @Preview @Composable fun PreviewAddGroupMembersLayout() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt index c998e99940..587b3f40aa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt @@ -11,11 +11,12 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -24,7 +25,8 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.* -import chat.simplex.app.views.chatlist.populateGroupMembers +import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert +import chat.simplex.app.views.chatlist.setGroupMembers import chat.simplex.app.views.helpers.* @Composable @@ -43,11 +45,11 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) { developerTools, addMembers = { withApi { - populateGroupMembers(groupInfo, chatModel) + setGroupMembers(groupInfo, chatModel) ModalManager.shared.showCustomModal { close -> ModalView( close = close, modifier = Modifier, - background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { AddGroupMembersView(groupInfo, chatModel, close) } @@ -56,13 +58,13 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) { }, showMemberInfo = { member -> withApi { - val connStats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) - ModalManager.shared.showCustomModal { close -> + val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + ModalManager.shared.showCustomModal { closeCurrent -> ModalView( - close = close, modifier = Modifier, - background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + close = closeCurrent, modifier = Modifier, + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { - GroupMemberInfoView(groupInfo, member, connStats, chatModel, close) + GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() } } } } @@ -72,7 +74,10 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) { }, deleteGroup = { deleteGroupDialog(chat.chatInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, - leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) } + leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }, + changeNtfsState = { enabled -> + changeNtfsState(enabled, chat, chatModel) + }, ) } } @@ -122,6 +127,7 @@ fun GroupChatInfoLayout( deleteGroup: () -> Unit, clearChat: () -> Unit, leaveGroup: () -> Unit, + changeNtfsState: (Boolean) -> Unit, ) { Column( Modifier @@ -133,14 +139,16 @@ fun GroupChatInfoLayout( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - ChatInfoHeader(chat.chatInfo) + GroupChatInfoHeader(chat.chatInfo) } SectionSpacer() SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) { if (groupInfo.canAddMembers) { SectionItemView { - AddMembersButton(addMembers) + val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary + val onClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers + AddMembersButton(tint, onClick) } SectionDivider() } @@ -154,6 +162,17 @@ fun GroupChatInfoLayout( } SectionSpacer() + var ntfsEnabled by remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + SectionView(title = stringResource(R.string.settings_section_title_settings)) { + SectionItemView { + NtfsSwitch(ntfsEnabled) { + ntfsEnabled = !ntfsEnabled + changeNtfsState(ntfsEnabled) + } + } + } + SectionSpacer() + SectionView { if (groupInfo.canEdit) { SectionItemView { @@ -191,7 +210,31 @@ fun GroupChatInfoLayout( } @Composable -fun AddMembersButton(addMembers: () -> Unit) { +fun GroupChatInfoHeader(cInfo: ChatInfo) { + Column( + Modifier.padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) + Text( + cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + Text( + cInfo.fullName, style = MaterialTheme.typography.h2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, addMembers: () -> Unit) { Row( Modifier .fillMaxSize() @@ -201,10 +244,10 @@ fun AddMembersButton(addMembers: () -> Unit) { Icon( Icons.Outlined.Add, stringResource(R.string.button_add_members), - tint = MaterialTheme.colors.primary + tint = tint ) Spacer(Modifier.size(8.dp)) - Text(stringResource(R.string.button_add_members), color = MaterialTheme.colors.primary) + Text(stringResource(R.string.button_add_members), color = tint) } } @@ -236,7 +279,8 @@ fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = nu ) { ProfileImage(size = 46.dp, member.image) Column { - Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified) val s = member.memberStatus.shortText val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s Text( @@ -322,7 +366,8 @@ fun PreviewGroupChatInfoLayout() { groupInfo = GroupInfo.sampleData, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, - addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {} + addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, + changeNtfsState = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index 1f303e63db..53a19528fe 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -24,10 +24,18 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.SimplexServers +import chat.simplex.app.views.chatlist.openChat import chat.simplex.app.views.helpers.* @Composable -fun GroupMemberInfoView(groupInfo: GroupInfo, member: GroupMember, connStats: ConnectionStats?, chatModel: ChatModel, close: () -> Unit) { +fun GroupMemberInfoView( + groupInfo: GroupInfo, + member: GroupMember, + connStats: ConnectionStats?, + chatModel: ChatModel, + close: () -> Unit, + closeAll: () -> Unit, // Close all open windows up to ChatView +) { BackHandler(onBack = close) val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val developerTools = chatModel.controller.appPrefs.developerTools.get() @@ -37,19 +45,38 @@ fun GroupMemberInfoView(groupInfo: GroupInfo, member: GroupMember, connStats: Co member, connStats, developerTools, - removeMember = { removeMemberDialog(member, chatModel, close) } + openDirectChat = { + withApi { + val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi) + if (oldChat != null) { + openChat(oldChat.chatInfo, chatModel) + } else { + var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi + // TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend + newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected())) + chatModel.addChat(newChat) + chatModel.chatItems.clear() + chatModel.chatId.value = newChat.id + } + closeAll() + } + }, + removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) } ) } } -fun removeMemberDialog(member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertMsg( title = generalGetString(R.string.button_remove_member), text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone), confirmText = generalGetString(R.string.remove_member_confirmation), onConfirm = { withApi { - chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId) + val removedMember = chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId) + if (removedMember != null) { + chatModel.upsertGroupMember(groupInfo, removedMember) + } close?.invoke() } } @@ -62,6 +89,7 @@ fun GroupMemberInfoLayout( member: GroupMember, connStats: ConnectionStats?, developerTools: Boolean, + openDirectChat: () -> Unit, removeMember: () -> Unit, ) { Column( @@ -78,6 +106,13 @@ fun GroupMemberInfoLayout( } SectionSpacer() + SectionView { + SectionItemView { + OpenChatButton(openDirectChat) + } + } + SectionSpacer() + SectionView(title = stringResource(R.string.member_info_section_title_member)) { InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName) val conn = member.activeConn @@ -136,7 +171,7 @@ fun GroupMemberInfoHeader(member: GroupMember) { Modifier.padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - ProfileImage(size = 192.dp, member.image, color = if (isSystemInDarkTheme()) GroupDark else SettingsSecondaryLight) + ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) Text( member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, @@ -172,6 +207,25 @@ fun RemoveMemberButton(removeMember: () -> Unit) { } } +@Composable +fun OpenChatButton(onClick: () -> Unit) { + Row( + Modifier + .fillMaxSize() + .clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Message, + stringResource(R.string.button_send_direct_message), + Modifier.padding(top = 5.dp), + tint = MaterialTheme.colors.primary + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.button_send_direct_message), color = MaterialTheme.colors.primary) + } +} + @Preview @Composable fun PreviewGroupMemberInfoLayout() { @@ -181,6 +235,7 @@ fun PreviewGroupMemberInfoLayout() { member = GroupMember.sampleData, connStats = null, developerTools = false, + openDirectChat = {}, removeMember = {} ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt index fdf25baba9..dd5992600d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -2,7 +2,6 @@ package chat.simplex.app.views.chat.item import android.widget.Toast import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -39,7 +38,7 @@ fun CIFileView( @Composable fun fileIcon( innerIcon: ImageVector? = null, - color: Color = if (isSystemInDarkTheme()) FileDark else FileLight + color: Color = if (isInDarkTheme()) FileDark else FileLight ) { Box( contentAlignment = Alignment.Center @@ -105,7 +104,7 @@ fun CIFileView( fun progressIndicator() { CircularProgressIndicator( Modifier.size(32.dp), - color = if (isSystemInDarkTheme()) FileDark else FileLight, + color = if (isInDarkTheme()) FileDark else FileLight, strokeWidth = 4.dp ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt index 03db38ba4b..3bdd453b41 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt @@ -3,7 +3,6 @@ package chat.simplex.app.views.chat.item import android.content.res.Configuration import android.util.Log import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -19,7 +18,6 @@ import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R -import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @@ -29,6 +27,7 @@ fun CIGroupInvitationView( ci: ChatItem, groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole, + chatIncognito: Boolean = false, joinGroup: (Long) -> Unit ) { val sent = ci.chatDir.sent @@ -38,8 +37,8 @@ fun CIGroupInvitationView( fun groupInfoView() { val p = groupInvitation.groupProfile val iconColor = - if (action) MaterialTheme.colors.primary - else if (isSystemInDarkTheme()) FileDark else FileLight + if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary + else if (isInDarkTheme()) FileDark else FileLight Row( Modifier @@ -47,7 +46,7 @@ fun CIGroupInvitationView( .padding(vertical = 4.dp) .padding(end = 2.dp) ) { - ProfileImage(size = 60.dp, icon = Icons.Filled.SupervisedUserCircle, color = iconColor) + ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = Icons.Filled.SupervisedUserCircle, color = iconColor) Spacer(Modifier.padding(horizontal = 3.dp)) Column( Modifier.defaultMinSize(minHeight = 60.dp), @@ -72,13 +71,10 @@ fun CIGroupInvitationView( } } - fun acceptInvitation() { - Log.d(TAG, "CIGroupInvitationView acceptInvitation") - joinGroup(groupInvitation.groupId) - } - Surface( - modifier = if (action) Modifier.clickable(onClick = ::acceptInvitation) else Modifier, + modifier = if (action) Modifier.clickable(onClick = { + joinGroup(groupInvitation.groupId) + }) else Modifier, shape = RoundedCornerShape(18.dp), color = if (sent) SentColorLight else ReceivedColorLight, ) { @@ -100,7 +96,9 @@ fun CIGroupInvitationView( Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) if (action) { groupInvitationText() - Text(stringResource(R.string.group_invitation_tap_to_join), color = MaterialTheme.colors.primary) + Text(stringResource( + if (chatIncognito) R.string.group_invitation_tap_to_join_incognito else R.string.group_invitation_tap_to_join), + color = if (chatIncognito) Indigo else MaterialTheme.colors.primary) } else { Box(Modifier.padding(end = 48.dp)) { groupInvitationText() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index 126b477e80..f5566b6f19 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -1,8 +1,7 @@ package chat.simplex.app.views.chat.item import androidx.compose.foundation.layout.* -import androidx.compose.material.Icon -import androidx.compose.material.Text +import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable @@ -16,7 +15,6 @@ import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight -import chat.simplex.app.ui.theme.SimplexBlue import kotlinx.datetime.Clock @Composable @@ -56,7 +54,7 @@ fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) { Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow) } is CIStatus.RcvNew -> { - Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue) + Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary) } else -> {} } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 19732416a4..a8a806ecbf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -35,6 +35,7 @@ fun ChatItemView( cxt: Context, uriHandler: UriHandler? = null, showMember: Boolean = false, + chatModelIncognito: Boolean, useLinkPreviews: Boolean, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, @@ -147,8 +148,8 @@ fun ChatItemView( is CIContent.SndCall -> CallItem(c.status, c.duration) is CIContent.RcvCall -> CallItem(c.status, c.duration) is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember) - is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup) - is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup) + is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.RcvGroupEventContent -> CIGroupEventView(cItem) is CIContent.SndGroupEventContent -> CIGroupEventView(cItem) } @@ -164,7 +165,8 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo text, modifier = Modifier .fillMaxWidth() - .weight(1F), + .weight(1F) + .padding(end = 15.dp), color = color ) Icon(icon, text, tint = color) @@ -183,13 +185,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM .padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = Arrangement.End, ) { - Button(onClick = { + TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() }) { Text(stringResource(R.string.for_me_only)) } if (chatItem.meta.editable) { Spacer(Modifier.padding(horizontal = 4.dp)) - Button(onClick = { + TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) AlertManager.shared.hideAlert() }) { Text(stringResource(R.string.for_everybody)) } @@ -212,6 +214,7 @@ fun PreviewChatItemView() { useLinkPreviews = true, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, cxt = LocalContext.current, + chatModelIncognito = false, deleteMessage = { _, _ -> }, receiveFile = {}, joinGroup = {}, @@ -231,6 +234,7 @@ fun PreviewChatItemViewDeletedContent() { useLinkPreviews = true, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, cxt = LocalContext.current, + chatModelIncognito = false, deleteMessage = { _, _ -> }, receiveFile = {}, joinGroup = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt index 8d6e59edc1..7ac5e0ca7e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt @@ -31,12 +31,19 @@ fun EmojiText(text: String) { Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont) } -private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C +// https://stackoverflow.com/a/46279500 +private const val emojiStr = "^(" + + "(?:[\\u2700-\\u27bf]|" + + "(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" + + "[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?" + + "(?:\\u200d(?:[^\\ud800-\\udfff]|" + + "(?:[\\ud83c\\udde6-\\ud83c\\uddff]){2}|" + + "[\\ud800\\udc00-\\uDBFF\\uDFFF]|[\\u2600-\\u26FF])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe23\\u20d0-\\u20f0]|[\\ud83c\\udffb-\\ud83c\\udfff])?)*|" + + "[\\u0023-\\u0039]\\ufe0f?\\u20e3|\\u3299|\\u3297|\\u303d|\\u3030|\\u24c2|[\\ud83c\\udd70-\\ud83c\\udd71]|[\\ud83c\\udd7e-\\ud83c\\udd7f]|\\ud83c\\udd8e|[\\ud83c\\udd91-\\ud83c\\udd9a]|[\\ud83c\\udde6-\\ud83c\\uddff]|[\\ud83c\\ude01-\\ud83c\\ude02]|\\ud83c\\ude1a|\\ud83c\\ude2f|[\\ud83c\\ude32-\\ud83c\\ude3a]|[\\ud83c\\ude50-\\ud83c\\ude51]|\\u203c|\\u2049|[\\u25aa-\\u25ab]|\\u25b6|\\u25c0|[\\u25fb-\\u25fe]|\\u00a9|\\u00ae|\\u2122|\\u2139|\\ud83c\\udc04|[\\u2600-\\u26FF]|\\u2b05|\\u2b06|\\u2b07|\\u2b1b|\\u2b1c|\\u2b50|\\u2b55|\\u231a|\\u231b|\\u2328|\\u23cf|[\\u23e9-\\u23f3]|[\\u23f8-\\u23fa]|\\ud83c\\udccf|\\u2934|\\u2935|[\\u2190-\\u21ff]" + + ")+$" // Multiple matches with emojis where one follows another without interruptions from other characters +private val emojiRegex = Regex(emojiStr) -fun isEmoji(c: Int): Boolean = isSimpleEmoji(c) // || isCombinedIntoEmoji(c) - -// TODO count perceived emojis, possibly using icu4j fun isShortEmoji(str: String): Boolean { val s = str.trim() - return s.codePoints().count() in 1..5 && s.codePoints().allMatch(::isEmoji) + return s.codePoints().count() in 1..5 && emojiRegex.matches(str) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index ff69501d0c..d74392946d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -87,7 +87,7 @@ fun FramedItemView( Modifier .padding(top = 6.dp, end = 4.dp) .size(22.dp), - tint = if (isSystemInDarkTheme()) FileDark else FileLight + tint = if (isInDarkTheme()) FileDark else FileLight ) } else -> ciQuotedMsgView(qi) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index c92f404075..b8f4d8b1e4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TheaterComedy import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -15,8 +16,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.ui.theme.WarningOrange +import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.* import chat.simplex.app.views.chat.group.deleteGroupDialog import chat.simplex.app.views.chat.group.leaveGroupDialog @@ -38,7 +38,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { when (chat.chatInfo) { is ChatInfo.Direct -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, stopped) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, @@ -46,7 +46,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ) is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, stopped) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) }, click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, showMenu, @@ -54,7 +54,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( - chatLinkPreview = { ContactRequestView(chat.chatInfo) }, + chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) }, click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, showMenu, @@ -96,7 +96,19 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { } } -suspend fun populateGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { +suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) { + val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT) + val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return + chatModel.chatItems.addAll(0, chat.chatItems) +} + +suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) { + val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return + chatModel.chatItems.clear() + chatModel.chatItems.addAll(0, chat.chatItems) +} + +suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId) chatModel.groupMembers.clear() chatModel.groupMembers.addAll(groupMembers) @@ -107,6 +119,7 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> { - JoinGroupAction(groupInfo, chatModel, showMenu) + JoinGroupAction(chat, groupInfo, chatModel, showMenu) if (groupInfo.canDelete) { DeleteGroupAction(chat, chatModel, showMenu) } @@ -124,6 +137,7 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM if (showMarkRead) { MarkReadChatAction(chat, chatModel, showMenu) } + ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu) ClearChatAction(chat, chatModel, showMenu) if (groupInfo.membership.memberCurrent) { LeaveGroupAction(groupInfo, chatModel, showMenu) @@ -148,6 +162,18 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState< ) } +@Composable +fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState) { + ItemAction( + if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat), + if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications, + onClick = { + changeNtfsState(!ntfsEnabled, chat, chatModel) + showMenu.value = false + } + ) +} + @Composable fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { ItemAction( @@ -188,12 +214,14 @@ fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { +fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { + val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } } ItemAction( - stringResource(R.string.join_group_button), - Icons.Outlined.Login, + if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button), + if (chat.chatInfo.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.Login, + color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground, onClick = { - withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } + joinGroup() showMenu.value = false } ) @@ -215,8 +243,9 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab @Composable fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { ItemAction( - stringResource(R.string.accept_contact_button), - Icons.Outlined.Check, + if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button), + if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check, + color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground, onClick = { acceptContactRequest(chatInfo, chatModel) showMenu.value = false @@ -247,12 +276,16 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: } fun markChatRead(chat: Chat, chatModel: ChatModel) { + // Just to be sure + if (chat.chatStats.unreadCount == 0) return + + val minUnreadItemId = chat.chatStats.minUnreadItemId chatModel.markChatItemsRead(chat.chatInfo) withApi { chatModel.controller.apiChatRead( chat.chatInfo.chatType, chat.chatInfo.apiId, - CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id) + CC.ItemRange(minUnreadItemId, chat.chatItems.last().id) ) } } @@ -261,7 +294,7 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel AlertManager.shared.showAlertDialog( title = generalGetString(R.string.accept_connection_request__question), text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified), - confirmText = generalGetString(R.string.accept_contact_button), + confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button), onConfirm = { acceptContactRequest(contactRequest, chatModel) }, dismissText = generalGetString(R.string.reject_contact_button), onDismiss = { rejectContactRequest(contactRequest, chatModel) } @@ -302,14 +335,14 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel .padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = Arrangement.End, ) { - Button(onClick = { + TextButton(onClick = { AlertManager.shared.hideAlert() deleteContactConnectionAlert(connection, chatModel) }) { Text(stringResource(R.string.delete_verb)) } Spacer(Modifier.padding(horizontal = 4.dp)) - Button(onClick = { AlertManager.shared.hideAlert() }) { + TextButton(onClick = { AlertManager.shared.hideAlert() }) { Text(stringResource(R.string.ok)) } } @@ -358,13 +391,21 @@ fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) AlertManager.shared.showAlertDialog( title = generalGetString(R.string.join_group_question), text = generalGetString(R.string.you_are_invited_to_group_join_to_connect_with_group_members), - confirmText = generalGetString(R.string.join_group_button), + confirmText = if (groupInfo.membership.memberIncognito) generalGetString(R.string.join_group_incognito_button) else generalGetString(R.string.join_group_button), onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }, dismissText = generalGetString(R.string.delete_verb), onDismiss = { deleteGroup(groupInfo, chatModel) } ) } +fun cantInviteIncognitoAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.alert_title_cant_invite_contacts), + text = generalGetString(R.string.alert_title_cant_invite_contacts_descr), + confirmText = generalGetString(R.string.ok), + ) +} + fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) { withApi { val r = chatModel.controller.apiDeleteChat(ChatType.Group, groupInfo.apiId) @@ -443,6 +484,8 @@ fun PreviewChatListNavLinkDirect() { ), chatStats = Chat.ChatStats() ), + false, + null, stopped = false ) }, @@ -478,6 +521,8 @@ fun PreviewChatListNavLinkGroup() { ), chatStats = Chat.ChatStats() ), + false, + null, stopped = false ) }, @@ -500,7 +545,7 @@ fun PreviewChatListNavLinkContactRequest() { SimpleXTheme { ChatListNavLinkLayout( chatLinkPreview = { - ContactRequestView(ChatInfo.ContactRequest.sampleData) + ContactRequestView(false, ChatInfo.ContactRequest.sampleData) }, click = {}, dropdownMenuItems = null, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index aeff3cc29c..7ea1ce4df6 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.chatlist +import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -7,21 +8,22 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* +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.res.stringResource +import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.ToolbarDark -import chat.simplex.app.ui.theme.ToolbarLight -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.Indigo +import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.onboarding.MakeConnection import chat.simplex.app.views.usersettings.SettingsView @@ -71,7 +73,9 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: LaunchedEffect(chatModel.clearOverlays.value) { if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse() } + var searchInList by rememberSaveable { mutableStateOf("") } BottomSheetScaffold( + topBar = { ChatListToolbar(chatModel, scaffoldCtrl, stopped) { searchInList = it.trim() } }, scaffoldState = scaffoldCtrl.state, drawerContent = { SettingsView(chatModel, setPerformLA) }, sheetPeekHeight = 0.dp, @@ -84,10 +88,8 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: .fillMaxSize() .background(MaterialTheme.colors.background) ) { - ChatListToolbar(scaffoldCtrl, stopped) - Divider() if (chatModel.chats.isNotEmpty()) { - ChatList(chatModel) + ChatList(chatModel, search = searchInList) } else { MakeConnection(chatModel) } @@ -105,58 +107,80 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: } @Composable -fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .height(52.dp) - .background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight) - .padding(horizontal = 8.dp) - ) { - IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) { - Icon( - Icons.Outlined.Menu, - stringResource(R.string.icon_descr_settings), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) - ) +fun ChatListToolbar(chatModel: ChatModel, scaffoldCtrl: ScaffoldController, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { + var showSearch by rememberSaveable { mutableStateOf(false) } + val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false } + if (showSearch) { + BackHandler(onBack = hideSearchOnBack) + } + val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() + if (chatModel.chats.size >= 8) { + barButtons.add { + IconButton({ showSearch = true }) { + Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary) + } } - Text( - stringResource(R.string.your_chats), - color = MaterialTheme.colors.onBackground, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(5.dp) - ) - if (!stopped) { + } + if (!stopped) { + barButtons.add { IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { Icon( Icons.Outlined.AddCircle, stringResource(R.string.add_contact), tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp).size(26.dp) ) } - } else { - IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) { + } + } else { + barButtons.add { + IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), + generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) { Icon( Icons.Filled.Report, generalGetString(R.string.chat_is_stopped_indication), tint = Color.Red, - modifier = Modifier.padding(10.dp) ) } } } + + DefaultTopAppBar( + navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.your_chats), + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.SemiBold, + ) + if (chatModel.incognito.value) { + Icon( + Icons.Filled.TheaterComedy, + stringResource(R.string.incognito), + tint = Indigo, + modifier = Modifier.padding(10.dp).size(26.dp) + ) + } + } + }, + onTitleClick = null, + showSearch = showSearch, + onSearchValueChanged = onSearchValueChanged, + buttons = barButtons + ) + Divider() } @Composable -fun ChatList(chatModel: ChatModel) { +fun ChatList(chatModel: ChatModel, search: String) { + val filter: (Chat) -> Boolean = { chat: Chat -> + chat.chatInfo.chatViewName.lowercase().contains(search.lowercase()) + } + val chats by remember(search) { derivedStateOf { if (search.isEmpty()) chatModel.chats else chatModel.chats.filter(filter) } } LazyColumn( modifier = Modifier.fillMaxWidth() ) { - items(chatModel.chats) { chat -> + items(chats) { chat -> ChatListNavLinkView(chat, chatModel) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index ec5a2d94b2..accbffb2e1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -2,13 +2,13 @@ package chat.simplex.app.views.chatlist import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,11 +23,10 @@ import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.ChatInfoImage -import chat.simplex.app.views.helpers.badgeLayout +import chat.simplex.app.views.helpers.* @Composable -fun ChatPreviewView(chat: Chat, stopped: Boolean) { +fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) { val cInfo = chat.chatInfo @Composable @@ -71,7 +70,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight) is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> chatPreviewTitleText(MaterialTheme.colors.primary) + GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary) GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight) else -> chatPreviewTitleText() } @@ -80,7 +79,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { } @Composable - fun chatPreviewText() { + fun chatPreviewText(chatModelIncognito: Boolean) { val ci = chat.chatItems.lastOrNull() if (ci != null) { MarkdownText( @@ -88,7 +87,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { metaText = ci.timestampText, maxLines = 2, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.body1.copy(color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp), + style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp), ) } else { when (cInfo) { @@ -98,7 +97,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> Text(stringResource(R.string.group_preview_you_are_invited)) + GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo)) GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight) else -> {} } @@ -120,7 +119,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { .weight(1F) ) { chatPreviewTitle() - chatPreviewText() + chatPreviewText(chatModelIncognito) } val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt) @@ -134,22 +133,38 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { modifier = Modifier.padding(bottom = 5.dp) ) val n = chat.chatStats.unreadCount + val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) if (n > 0) { Box( Modifier.padding(top = 24.dp), contentAlignment = Alignment.Center ) { Text( - if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation), + unreadCountStr(n), color = MaterialTheme.colors.onPrimary, fontSize = 11.sp, modifier = Modifier - .background(if (stopped) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape) + .background(if (stopped || showNtfsIcon) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape) .badgeLayout() .padding(horizontal = 3.dp) .padding(vertical = 1.dp) ) } + } else if (showNtfsIcon) { + Box( + Modifier.padding(top = 24.dp), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.NotificationsOff, + contentDescription = generalGetString(R.string.notifications), + tint = HighOrLowlight, + modifier = Modifier + .padding(horizontal = 3.dp) + .padding(vertical = 1.dp) + .size(17.dp) + ) + } } if (cInfo is ChatInfo.Direct) { Box( @@ -163,6 +178,21 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { } } +@Composable +private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String { + return if (groupInfo.membership.memberIncognito) + String.format(stringResource(R.string.group_preview_join_as), groupInfo.membership.memberProfile.displayName) + else if (chatModelIncognito) + String.format(stringResource(R.string.group_preview_join_as), currentUserProfileDisplayName ?: "") + else + stringResource(R.string.group_preview_you_are_invited) +} + +@Composable +fun unreadCountStr(n: Int): String { + return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation) +} + @Composable fun ChatStatusImage(chat: Chat) { val s = chat.serverInfo.networkStatus @@ -195,6 +225,6 @@ fun ChatStatusImage(chat: Chat) { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, stopped = false) + ChatPreviewView(Chat.sampleData, false, "", stopped = false) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt index 318f1a0093..2ee852aeb3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.chatlist -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -37,7 +36,7 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) { fontWeight = FontWeight.Bold, color = HighOrLowlight ) - Text(contactConnection.description, maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight) + Text(contactConnection.description, maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight) } val ts = getTimestampText(contactConnection.updatedAt) Column( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt index 3b90163243..70b91e634e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.chatlist -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -11,13 +10,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.ChatInfo -import chat.simplex.app.model.getTimestampText +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.ChatInfoImage @Composable -fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { +fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) { Row { ChatInfoImage(contactRequest, size = 72.dp) Column( @@ -31,9 +29,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.primary + color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary ) - Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isSystemInDarkTheme()) MessagePreviewDark else MessagePreviewLight) + Text(stringResource(R.string.contact_wants_to_connect_with_you), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight) } val ts = getTimestampText(contactRequest.contactRequest.updatedAt) Column( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt index 7e22b00d2d..5ae9e32341 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt @@ -2,8 +2,10 @@ package chat.simplex.app.views.helpers import android.util.Log import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import chat.simplex.app.R import chat.simplex.app.TAG @@ -44,7 +46,8 @@ class AlertManager { confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null, dismissText: String = generalGetString(R.string.cancel_verb), - onDismiss: (() -> Unit)? = null + onDismiss: (() -> Unit)? = null, + destructive: Boolean = false ) { val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) } showAlert { @@ -53,13 +56,13 @@ class AlertManager { title = { Text(title) }, text = alertText, confirmButton = { - Button(onClick = { + TextButton(onClick = { onConfirm?.invoke() hideAlert() - }) { Text(confirmText) } + }) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } }, dismissButton = { - Button(onClick = { + TextButton(onClick = { onDismiss?.invoke() hideAlert() }) { Text(dismissText) } @@ -79,7 +82,7 @@ class AlertManager { title = { Text(title) }, text = alertText, confirmButton = { - Button(onClick = { + TextButton(onClick = { onConfirm?.invoke() hideAlert() }) { Text(confirmText) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index 5a596cd5ec..f1d6fa9caa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -6,8 +6,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.SupervisedUserCircle +import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -31,6 +30,17 @@ fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme ProfileImage(size, chatInfo.image, icon, iconColor) } +@Composable +fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary) { + Box(Modifier.size(size)) { + Icon( + Icons.Filled.TheaterComedy, stringResource(R.string.incognito), + modifier = Modifier.size(size).padding(size / 12), + iconColor + ) + } +} + @Composable fun ProfileImage( size: Dp, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt new file mode 100644 index 0000000000..31de2f5212 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt @@ -0,0 +1,110 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.foundation.text.* +import androidx.compose.material.* +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +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.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DefaultBasicTextField( + modifier: Modifier, + initialValue: String, + placeholder: (@Composable () -> Unit)? = null, + focus: Boolean = false, + color: Color = MaterialTheme.colors.onBackground, + textStyle: TextStyle = TextStyle.Default, + selectTextOnFocus: Boolean = false, + keyboardOptions: KeyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions: KeyboardActions = KeyboardActions(), + onValueChange: (String) -> Unit, +) { + val state = remember { + mutableStateOf(TextFieldValue(initialValue)) + } + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + if (!focus) return@LaunchedEffect + focusRequester.requestFocus() + delay(200) + keyboard?.show() + } + val enabled = true + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + textColor = MaterialTheme.colors.onBackground, + focusedIndicatorColor = Color.Unspecified, + unfocusedIndicatorColor = Color.Unspecified, + ) + val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = state.value, + modifier = modifier + .background(colors.backgroundColor(enabled).value, shape) + .indicatorLine(enabled, false, interactionSource, colors) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused && selectTextOnFocus) { + val text = state.value.text + state.value = state.value.copy( + selection = TextRange(0, text.length) + ) + } + } + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ), + onValueChange = { + state.value = it + onValueChange(it.text) + }, + cursorBrush = SolidColor(colors.cursorColor(false).value), + visualTransformation = VisualTransformation.None, + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions(onDone = { + keyboard?.hide() + keyboardActions.onDone?.invoke(this) + }), + singleLine = true, + textStyle = textStyle.copy( + color = color, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = state.value.text, + innerTextField = innerTextField, + placeholder = placeholder, + singleLine = true, + enabled = enabled, + interactionSource = interactionSource, + contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp), + visualTransformation = VisualTransformation.None, + colors = colors + ) + } + ) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt new file mode 100644 index 0000000000..a4962dc7a8 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt @@ -0,0 +1,123 @@ +package chat.simplex.app.views.helpers + +import chat.simplex.app.R +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowBackIos +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.ui.theme.* + +@Composable +fun DefaultTopAppBar( + navigationButton: @Composable RowScope.() -> Unit, + title: @Composable () -> Unit, + onTitleClick: (() -> Unit)? = null, + showSearch: Boolean, + onSearchValueChanged: (String) -> Unit, + buttons: List<@Composable RowScope.() -> Unit> = emptyList(), +) { + // 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 + + TopAppBar( + modifier = modifier, + title = { + if (!showSearch) { + title() + } else { + SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged) + } + }, + backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight, + navigationIcon = navigationButton, + buttons = if (!showSearch) buttons else emptyList(), + centered = !showSearch + ) +} + +@Composable +fun NavigationButtonBack(onButtonClicked: () -> Unit) { + IconButton(onButtonClicked) { + Icon( + Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +fun NavigationButtonMenu(onButtonClicked: () -> Unit) { + IconButton(onClick = onButtonClicked) { + Icon( + Icons.Outlined.Menu, + stringResource(R.string.icon_descr_settings), + tint = MaterialTheme.colors.primary, + ) + } +} + +@Composable +private fun TopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable (RowScope.() -> Unit)? = null, + buttons: List<@Composable RowScope.() -> Unit> = emptyList(), + backgroundColor: Color = MaterialTheme.colors.primarySurface, + centered: Boolean, +) { + Box( + modifier + .fillMaxWidth() + .height(AppBarHeight) + .background(backgroundColor) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (navigationIcon != null) { + Row( + Modifier + .fillMaxHeight() + .width(TitleInsetWithIcon - AppBarHorizontalPadding), + verticalAlignment = Alignment.CenterVertically, + content = navigationIcon + ) + } + + Row( + Modifier + .fillMaxHeight() + .fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + buttons.forEach { it() } + } + val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon + val endPadding = (buttons.size * 50f).dp + Box( + Modifier + .fillMaxWidth() + .padding( + start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding, + end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding + ), + contentAlignment = Alignment.Center + ) { + title() + } + } +} + +val AppBarHeight = 56.dp +private val AppBarHorizontalPadding = 4.dp +private val TitleInsetWithoutIcon = 16.dp - AppBarHorizontalPadding +private val TitleInsetWithIcon = 72.dp diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt index 306d7113b0..e19f00f971 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt @@ -16,7 +16,13 @@ package chat.simplex.app.views.helpers +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.interaction.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException @@ -31,19 +37,19 @@ import androidx.compose.ui.input.pointer.consumeAllChanges import androidx.compose.ui.input.pointer.consumeDownChange import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.positionChangeConsumed +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import chat.simplex.app.TAG +import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex /** * See original code here: [androidx.compose.foundation.gestures.detectTapGestures] * */ - -interface PressGestureScope : Density { +interface PressGestureScope: Density { suspend fun tryAwaitRelease(): Boolean } @@ -67,7 +73,6 @@ suspend fun PointerInputScope.detectGesture( if (onPress !== NoPressGesture) launch { pressScope.onPress(down.position) } - val longPressTimeout = onLongPress?.let { viewConfiguration.longPressTimeoutMillis } ?: (Long.MAX_VALUE / 2) @@ -81,12 +86,7 @@ suspend fun PointerInputScope.detectGesture( } else { if (shouldConsume) upOrCancel.consumeDownChange() - - // If onLongPress event is needed, cancel short press event - if (onLongPress != null) - pressScope.cancel() - else - pressScope.release() + pressScope.release() } } catch (_: PointerEventTimeoutCancellationException) { onLongPress?.invoke(down.position) @@ -138,7 +138,6 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange ) { return null } - val consumeCheck = awaitPointerEvent(PointerEventPass.Final) if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) { return null @@ -148,7 +147,7 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange private class PressGestureScopeImpl( density: Density -) : PressGestureScope, Density by density { +): PressGestureScope, Density by density { private var isReleased = false private var isCanceled = false private val mutex = Mutex(locked = false) @@ -173,6 +172,52 @@ private class PressGestureScopeImpl( if (!isReleased && !isCanceled) { mutex.lock() } - return isCanceled + return isReleased && !isCanceled } } + +/** + * Captures click events and calls [onLongClick] or [onClick] when such even happens. Otherwise, does nothing. + * Apply [MutableInteractionSource] to any element that allows to pass it in (for example, in [Modifier.clickable]). + * Works in situations when using [Modifier.combinedClickable] doesn't work because external element overrides [Modifier.clickable] + * */ +@Composable +fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit): MutableInteractionSource { + val interactionSource = remember { MutableInteractionSource() } + val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis + var topLevelInteraction: Interaction? by remember { mutableStateOf(null) } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + topLevelInteraction = interaction + } + } + LaunchedEffect(topLevelInteraction is PressInteraction.Press) { + if (topLevelInteraction !is PressInteraction.Press) return@LaunchedEffect + try { + withTimeout(longPressTimeoutMillis) { + while (isActive) { + delay(10) + when (topLevelInteraction) { + is PressInteraction.Press -> {} + is PressInteraction.Release -> { + onClick(); break + } + is PressInteraction.Cancel -> break + } + } + } + } catch (_: TimeoutCancellationException) { + // Long click happened + onLongClick() + } catch (ex: CancellationException) { + // Canceled coroutine + PressInteraction.Release == short click + if (topLevelInteraction is PressInteraction.Release) + onClick() + Log.e(TAG, ex.stackTraceToString()) + } catch (ex: Exception) { + // Should never be called + Log.e(TAG, ex.stackTraceToString()) + } + } + return interactionSource +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt index a8d9f0e435..f60d644a88 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt @@ -1,7 +1,15 @@ package chat.simplex.app.views.helpers +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.offset +import androidx.compose.material.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.roundToInt fun Modifier.badgeLayout() = layout { measurable, constraints -> @@ -15,3 +23,22 @@ fun Modifier.badgeLayout() = placeable.place((width - placeable.width) / 2, 0) } } + +@Composable +fun SwipeToDismissModifier( + state: DismissState, + directions: Set = setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd), + swipeDistance: Float, +): Modifier { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val anchors = mutableMapOf(0f to DismissValue.Default) + if (DismissDirection.StartToEnd in directions) anchors += swipeDistance to DismissValue.DismissedToEnd + if (DismissDirection.EndToStart in directions) anchors += -swipeDistance to DismissValue.DismissedToStart + return Modifier.swipeable( + state = state, + anchors = anchors, + thresholds = { _, _ -> FractionalThreshold(0.5f) }, + orientation = Orientation.Horizontal, + reverseDirection = isRtl, + ).offset { IntOffset(state.offset.value.roundToInt(), 0) } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt new file mode 100644 index 0000000000..09967ca0b6 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt @@ -0,0 +1,97 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.foundation.text.* +import androidx.compose.material.* +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import kotlinx.coroutines.delay + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) { + var searchText by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + delay(200) + keyboard?.show() + } + + val enabled = true + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + textColor = MaterialTheme.colors.onBackground, + focusedIndicatorColor = Color.Unspecified, + unfocusedIndicatorColor = Color.Unspecified, + ) + val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = searchText, + modifier = modifier + .background(colors.backgroundColor(enabled).value, shape) + .indicatorLine(enabled, false, interactionSource, colors) + .focusRequester(focusRequester) + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ), + onValueChange = { + searchText = it + onValueChange(it.text) + }, + cursorBrush = SolidColor(colors.cursorColor(false).value), + visualTransformation = VisualTransformation.None, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + singleLine = true, + textStyle = TextStyle( + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = searchText.text, + innerTextField = innerTextField, + placeholder = { + Text(placeholder) + }, + trailingIcon = if (searchText.text.isNotEmpty()) {{ + IconButton({ searchText = TextFieldValue(""); onValueChange("") }) { + Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,) + } + }} else null, + singleLine = true, + enabled = enabled, + interactionSource = interactionSource, + contentPadding = textFieldWithLabelPadding(start = 0.dp, end = 0.dp), + visualTransformation = VisualTransformation.None, + colors = colors + ) + } + ) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt index f77c179e57..9ec912abcb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt @@ -1,13 +1,13 @@ import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import chat.simplex.app.ui.theme.GroupDark -import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.* @Composable fun SectionView(title: String? = null, content: (@Composable () -> Unit)) { @@ -18,7 +18,7 @@ fun SectionView(title: String? = null, content: (@Composable () -> Unit)) { modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp ) } - Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) { + Surface(color = if (isInDarkTheme()) GroupDark else MaterialTheme.colors.background) { Column(Modifier.padding(horizontal = 6.dp).fillMaxWidth()) { content() } } } @@ -38,6 +38,27 @@ fun SectionItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: B } } +@Composable +fun SectionItemViewSpaceBetween( + click: (() -> Unit)? = null, + height: Dp = 46.dp, + padding: PaddingValues = PaddingValues(horizontal = 8.dp), + disabled: Boolean = false, + content: (@Composable () -> Unit) +) { + val modifier = Modifier + .padding(padding) + .fillMaxWidth() + .height(height) + Row( + if (click == null || disabled) modifier else modifier.clickable(onClick = click), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + @Composable fun SectionTextFooter(text: String) { Text( @@ -49,9 +70,9 @@ fun SectionTextFooter(text: String) { } @Composable -fun SectionCustomFooter(content: (@Composable () -> Unit)) { +fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = 16.dp, end = 16.dp, top = 5.dp), content: (@Composable () -> Unit)) { Row( - Modifier.padding(horizontal = 16.dp).padding(top = 5.dp) + Modifier.padding(padding) ) { content() } @@ -80,3 +101,26 @@ fun InfoRow(title: String, value: String) { } } } + +@Composable +fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) { + SectionItemView { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val configuration = LocalConfiguration.current + Text(title) + Text(value, + Modifier + .padding(start = 10.dp) + .widthIn(max = (configuration.screenWidthDp / 2).dp) + .clickable(onClick = onClick), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = HighOrLowlight + ) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt index 0340693610..b2a5bb8bbd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt @@ -2,9 +2,10 @@ package chat.simplex.app.views.newchat import android.content.res.Configuration import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TheaterComedy +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -18,8 +19,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.SimpleButton -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.helpers.shareText @Composable @@ -28,6 +29,7 @@ fun AddContactView(chatModel: ChatModel) { if (connReq != null) { val cxt = LocalContext.current AddContactLayout( + chatModelIncognito = chatModel.incognito.value, connReq = connReq, share = { shareText(cxt, connReq) } ) @@ -35,22 +37,32 @@ fun AddContactView(chatModel: ChatModel) { } @Composable -fun AddContactLayout(connReq: String, share: () -> Unit) { +fun AddContactLayout(chatModelIncognito: Boolean, connReq: String, share: () -> Unit) { BoxWithConstraints { val screenHeight = maxHeight Column( - horizontalAlignment = Alignment.CenterHorizontally, + Modifier.padding(bottom = 16.dp), verticalArrangement = Arrangement.SpaceBetween, ) { Text( stringResource(R.string.add_contact), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + modifier = Modifier + .padding(vertical = 5.dp) + .padding(horizontal = 8.dp) ) Text( stringResource(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline), - style = MaterialTheme.typography.h3, - textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp) ) + Row(Modifier.padding(horizontal = 8.dp)) { + InfoAboutIncognito( + chatModelIncognito, + true, + generalGetString(R.string.incognito_random_profile_description), + generalGetString(R.string.your_profile_will_be_sent) + ) + } QRCode( connReq, Modifier .weight(1f, fill = false) @@ -59,14 +71,52 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { ) Text( stringResource(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel), - textAlign = TextAlign.Center, lineHeight = 22.sp, modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 8.dp) .padding(bottom = if (screenHeight > 600.dp) 16.dp else 8.dp) ) - SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) - Spacer(Modifier.height(10.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + SimpleButton(stringResource(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) + } + } + } +} + +@Composable +fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String) { + if (chatModelIncognito) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + if (supportedIncognito) Icons.Filled.TheaterComedy else Icons.Outlined.Info, + stringResource(R.string.incognito), + tint = if (supportedIncognito) Indigo else WarningOrange, + modifier = Modifier.padding(end = 10.dp).size(20.dp) + ) + Text(onText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2) + } + } else { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Info, + stringResource(R.string.incognito), + tint = HighOrLowlight, + modifier = Modifier.padding(end = 10.dp).size(20.dp) + ) + Text(offText, textAlign = TextAlign.Left, style = MaterialTheme.typography.body2) } } } @@ -81,6 +131,7 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { fun PreviewAddContactView() { SimpleXTheme { AddContactLayout( + chatModelIncognito = false, connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", share = {} ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt index 73e2c7fa0f..f18ccecbcd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -21,7 +22,7 @@ import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.ProfileNameField import chat.simplex.app.views.chat.group.AddGroupMembersView -import chat.simplex.app.views.chatlist.populateGroupMembers +import chat.simplex.app.views.chatlist.setGroupMembers import chat.simplex.app.views.helpers.* import chat.simplex.app.views.isValidDisplayName import chat.simplex.app.views.onboarding.ReadableText @@ -34,6 +35,7 @@ import kotlinx.coroutines.launch @Composable fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { AddGroupLayout( + chatModel.incognito.value, createGroup = { groupProfile -> withApi { val groupInfo = chatModel.controller.apiNewGroup(groupProfile) @@ -41,12 +43,12 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() chatModel.chatId.value = groupInfo.id - populateGroupMembers(groupInfo, chatModel) + setGroupMembers(groupInfo, chatModel) close.invoke() ModalManager.shared.showCustomModal { close -> ModalView( close = close, modifier = Modifier, - background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight ) { AddGroupMembersView(groupInfo, chatModel, close) } @@ -59,7 +61,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { } @Composable -fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { +fun AddGroupLayout(chatModelIncognito: Boolean, createGroup: (GroupProfile) -> Unit, close: () -> Unit) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = remember { mutableStateOf("") } @@ -92,11 +94,16 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { ) { Text( stringResource(R.string.create_secret_group_title), - style = MaterialTheme.typography.h4, + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), modifier = Modifier.padding(vertical = 5.dp) ) - ReadableText(R.string.group_is_decentralized) - Spacer(Modifier.height(10.dp)) + Text(stringResource(R.string.group_is_decentralized)) + InfoAboutIncognito( + chatModelIncognito, + false, + generalGetString(R.string.group_unsupported_incognito_main_profile_sent), + generalGetString(R.string.group_main_profile_sent) + ) Box( Modifier .fillMaxWidth() @@ -170,6 +177,7 @@ fun CreateGroupButton(color: Color, modifier: Modifier) { fun PreviewAddGroupLayout() { SimpleXTheme { AddGroupLayout( + chatModelIncognito = false, createGroup = {}, close = {} ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt index 6e9372dcc2..bfb62fd89b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt @@ -31,6 +31,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { val clipboard = getSystemService(context, ClipboardManager::class.java) BackHandler(onBack = close) PasteToConnectLayout( + chatModel.incognito.value, connectionLink = connectionLink, pasteFromClipboard = { connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as String @@ -55,6 +56,7 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @Composable fun PasteToConnectLayout( + chatModelIncognito: Boolean, connectionLink: MutableState, pasteFromClipboard: () -> Unit, connectViaLink: (String) -> Unit, @@ -62,16 +64,22 @@ fun PasteToConnectLayout( ) { ModalView(close) { Column( - horizontalAlignment = Alignment.CenterHorizontally, + Modifier.padding(bottom = 16.dp), verticalArrangement = Arrangement.SpaceBetween, ) { Text( stringResource(R.string.connect_via_link), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(vertical = 5.dp) ) Text(stringResource(R.string.paste_connection_link_below_to_connect)) - Text(stringResource(R.string.profile_will_be_sent_to_contact_sending_link)) + + InfoAboutIncognito( + chatModelIncognito, + true, + generalGetString(R.string.incognito_random_profile_from_contact_description), + generalGetString(R.string.profile_will_be_sent_to_contact_sending_link) + ) Box(Modifier.padding(top = 16.dp, bottom = 6.dp)) { TextEditor(Modifier.height(180.dp), text = connectionLink) @@ -111,6 +119,7 @@ fun PasteToConnectLayout( fun PreviewPasteToConnectTextbox() { SimpleXTheme { PasteToConnectLayout( + chatModelIncognito = false, connectionLink = remember { mutableStateOf("") }, pasteFromClipboard = {}, connectViaLink = { link -> diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt index 11c67a409a..465591c544 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -22,6 +21,7 @@ import chat.simplex.app.views.helpers.* fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { BackHandler(onBack = close) ConnectContactLayout( + chatModelIncognito = chatModel.incognito.value, qrCodeScanner = { QRCodeScanner { connReqUri -> try { @@ -67,21 +67,22 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) { } @Composable -fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) { +fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable () -> Unit, close: () -> Unit) { ModalView(close) { Column( - horizontalAlignment = Alignment.CenterHorizontally, + Modifier.padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( generalGetString(R.string.scan_QR_code), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + modifier = Modifier.padding(vertical = 5.dp) ) - Text( - generalGetString(R.string.your_chat_profile_will_be_sent_to_your_contact), - style = MaterialTheme.typography.h3, - textAlign = TextAlign.Center, - modifier = Modifier.padding(bottom = 4.dp) + InfoAboutIncognito( + chatModelIncognito, + true, + generalGetString(R.string.incognito_random_profile_description), + generalGetString(R.string.your_profile_will_be_sent) ) Box( Modifier @@ -106,6 +107,7 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( + chatModelIncognito = false, qrCodeScanner = { Surface {} }, close = {}, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt index 9b66a14af8..5c95b8a727 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt @@ -3,7 +3,6 @@ package chat.simplex.app.views.onboarding import android.content.res.Configuration import androidx.annotation.StringRes import androidx.compose.foundation.Image -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -77,7 +76,7 @@ fun SimpleXInfoLayout( @Composable fun SimpleXLogo() { Image( - painter = painterResource(if (isSystemInDarkTheme()) R.drawable.logo_light else R.drawable.logo), + painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo), contentDescription = stringResource(R.string.image_descr_simplex_logo), modifier = Modifier .padding(vertical = 20.dp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt index 8a35d64916..3ab5ac25ab 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt @@ -1,5 +1,8 @@ package chat.simplex.app.views.usersettings +import SectionCustomFooter +import SectionItemViewSpaceBetween +import SectionSpacer import SectionView import android.content.ComponentName import android.content.pm.PackageManager @@ -10,11 +13,13 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.* import androidx.compose.material.MaterialTheme.colors +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -24,7 +29,10 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import chat.simplex.app.* import chat.simplex.app.R +import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import com.godaddy.android.colorpicker.* enum class AppIcon(val resId: Int) { DEFAULT(R.mipmap.icon), @@ -32,7 +40,9 @@ enum class AppIcon(val resId: Int) { } @Composable -fun AppearanceView() { +fun AppearanceView( + showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), +) { val appIcon = remember { mutableStateOf(findEnabledIcon()) } fun setAppIcon(newIcon: AppIcon) { @@ -54,18 +64,33 @@ fun AppearanceView() { AppearanceLayout( appIcon, - changeIcon = ::setAppIcon + changeIcon = ::setAppIcon, + showThemeSelector = showCustomModal { _, close -> + ModalView( + close = close, modifier = Modifier, + background = if (isInDarkTheme()) colors.background else SettingsBackgroundLight + ) { ThemeSelectorView() } + }, + editPrimaryColor = { primary -> + showCustomModal { _, close -> + ModalView( + close = close, modifier = Modifier, + background = if (isInDarkTheme()) colors.background else SettingsBackgroundLight + ) { ColorEditor(primary, close) } + }() + }, ) } @Composable fun AppearanceLayout( icon: MutableState, - changeIcon: (AppIcon) -> Unit + changeIcon: (AppIcon) -> Unit, + showThemeSelector: () -> Unit, + editPrimaryColor: (Color) -> Unit, ) { Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( stringResource(R.string.appearance_settings), @@ -73,10 +98,7 @@ fun AppearanceView() { style = MaterialTheme.typography.h1 ) SectionView(stringResource(R.string.settings_section_title_icon)) { - LazyRow( - Modifier - .padding(horizontal = 8.dp) - ) { + LazyRow { items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index -> val item = AppIcon.values()[index] val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!! @@ -97,9 +119,82 @@ fun AppearanceView() { } } } + + SectionSpacer() + val currentTheme by CurrentColors.collectAsState() + SectionView(stringResource(R.string.settings_section_title_themes)) { + Column( + Modifier.padding(horizontal = 8.dp) + ) { + SectionItemViewSpaceBetween(showThemeSelector, padding = PaddingValues()) { + Text(generalGetString(R.string.theme)) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + + SectionItemViewSpaceBetween({ editPrimaryColor(currentTheme.first.primary) }, padding = PaddingValues()) { + val title = generalGetString(R.string.color_primary) + Text(title) + Icon(Icons.Filled.Circle, title, tint = colors.primary) + } + } + } + if (currentTheme.first.primary != LightColorPalette.primary) { + SectionCustomFooter(PaddingValues(start = 7.dp, end = 7.dp, top = 5.dp)) { + TextButton( + onClick = { + ThemeManager.saveAndApplyPrimaryColor(LightColorPalette.primary) + }, + ) { + Text(generalGetString(R.string.reset_color)) + } + } + } } } +@Composable +fun ColorEditor( + initialColor: Color, + close: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + ) { + var currentColor by remember { mutableStateOf(initialColor) } + ColorPicker(initialColor) { + currentColor = it + } + + SectionSpacer() + + TextButton( + onClick = { + ThemeManager.saveAndApplyPrimaryColor(currentColor) + close() + }, + Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.textButtonColors(contentColor = currentColor) + ) { + Text(generalGetString(R.string.save_color)) + } + } +} + +@Composable +fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) { + ClassicColorPicker( + color = initialColor, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + showAlphaBar = false, + onColorChanged = { color: HsvColor -> + onColorChanged(color.toColor()) + } + ) +} + private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon -> SimplexApp.context.packageManager.getComponentEnabledSetting( ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}") @@ -112,7 +207,9 @@ fun PreviewAppearanceSettings() { SimpleXTheme { AppearanceLayout( icon = remember { mutableStateOf(AppIcon.DARK_BLUE) }, - changeIcon = {} + changeIcon = {}, + showThemeSelector = {}, + editPrimaryColor = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index 95478fd9d9..7535138ee7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -3,11 +3,15 @@ package chat.simplex.app.views.usersettings import SectionDivider import SectionItemView import SectionView +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -82,6 +86,40 @@ fun SharedPreferenceToggle( } } +@Composable +fun SharedPreferenceToggleWithIcon( + text: String, + icon: ImageVector, + stopped: Boolean = false, + onClickInfo: () -> Unit, + preference: Preference, + preferenceState: MutableState? = null +) { + val prefState = preferenceState ?: remember { mutableStateOf(preference.get()) } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(text, Modifier.padding(end = 4.dp)) + Icon( + icon, + null, + Modifier.clickable(onClick = onClickInfo), + tint = MaterialTheme.colors.primary + ) + Spacer(Modifier.fillMaxWidth().weight(1f)) + Switch( + checked = prefState.value, + onCheckedChange = { + preference.set(it) + prefState.value = it + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colors.primary, + uncheckedThumbColor = HighOrLowlight + ), + enabled = !stopped + ) + } +} + @Composable fun SharedPreferenceRadioButton(text: String, prefState: MutableState, preference: Preference, value: T) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt new file mode 100644 index 0000000000..002dedf43a --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt @@ -0,0 +1,47 @@ +package chat.simplex.app.views.usersettings + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.views.helpers.generalGetString + +@Composable +fun IncognitoView() { + IncognitoLayout() +} + +@Composable +fun IncognitoLayout() { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + stringResource(R.string.settings_section_title_incognito), + Modifier.padding(start = 8.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 8.dp) + ) { + Column( + Modifier.padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text(generalGetString(R.string.incognito_info_protects)) + Text(generalGetString(R.string.incognito_info_allows)) + Text(generalGetString(R.string.incognito_info_share)) + Text(generalGetString(R.string.incognito_info_find)) + } + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt index 0ac31464fb..a6fd331681 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt @@ -10,12 +10,14 @@ import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.NetCfg +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @@ -25,16 +27,19 @@ fun NetworkAndServersView( showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) ) { - val netCfg: MutableState = remember { mutableStateOf(chatModel.controller.getNetCfg()) } - val networkUseSocksProxy: MutableState = remember { mutableStateOf(netCfg.value.useSocksProxy) } + // It's not a state, just a one-time value. Shouldn't be used in any state-related situations + val netCfg = remember { chatModel.controller.getNetCfg() } + val networkUseSocksProxy: MutableState = remember { mutableStateOf(netCfg.useSocksProxy) } val developerTools = chatModel.controller.appPrefs.developerTools.get() + val onionHosts = remember { mutableStateOf(netCfg.onionHosts) } NetworkAndServersLayout( developerTools = developerTools, networkUseSocksProxy = networkUseSocksProxy, + onionHosts = onionHosts, showModal = showModal, showSettingsModal = showSettingsModal, - toggleSocksProxy = { enable -> + toggleSocksProxy = { enable -> if (enable) { AlertManager.shared.showAlertMsg( title = generalGetString(R.string.network_enable_socks), @@ -45,6 +50,7 @@ fun NetworkAndServersView( chatModel.controller.apiSetNetworkConfig(NetCfg.proxyDefaults) chatModel.controller.setNetCfg(NetCfg.proxyDefaults) networkUseSocksProxy.value = true + onionHosts.value = NetCfg.proxyDefaults.onionHosts } } ) @@ -58,10 +64,29 @@ fun NetworkAndServersView( chatModel.controller.apiSetNetworkConfig(NetCfg.defaults) chatModel.controller.setNetCfg(NetCfg.defaults) networkUseSocksProxy.value = false + onionHosts.value = NetCfg.defaults.onionHosts } } ) } + }, + useOnion = { + val prevValue = onionHosts.value + onionHosts.value = it + updateNetworkSettingsDialog(onDismiss = { + onionHosts.value = prevValue + }) { + withApi { + val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it) + val res = chatModel.controller.apiSetNetworkConfig(newCfg) + if (res) { + chatModel.controller.setNetCfg(newCfg) + onionHosts.value = it + } else { + onionHosts.value = prevValue + } + } + } } ) } @@ -69,9 +94,11 @@ fun NetworkAndServersView( @Composable fun NetworkAndServersLayout( developerTools: Boolean, networkUseSocksProxy: MutableState, + onionHosts: MutableState, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - toggleSocksProxy: (Boolean) -> Unit + toggleSocksProxy: (Boolean) -> Unit, + useOnion: (OnionHosts) -> Unit, ) { Column( Modifier.fillMaxWidth(), @@ -89,6 +116,10 @@ fun NetworkAndServersView( SectionItemView { UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) } + SectionDivider() + SectionItemView { + UseOnionHosts(onionHosts, networkUseSocksProxy, useOnion) + } if (developerTools) { SectionDivider() SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) @@ -129,6 +160,116 @@ fun UseSocksProxySwitch( } } +@Composable +private fun UseOnionHosts(onionHosts: MutableState, enabled: State, useOnion: (OnionHosts) -> Unit) { + val values = remember { + OnionHosts.values().map { + when (it) { + OnionHosts.NEVER -> OnionHosts.NEVER to generalGetString(R.string.network_use_onion_hosts_no) + OnionHosts.PREFER -> OnionHosts.PREFER to generalGetString(R.string.network_use_onion_hosts_prefer) + OnionHosts.REQUIRED -> OnionHosts.REQUIRED to generalGetString(R.string.network_use_onion_hosts_required) + } + } + } + ExposedDropDownSettingRow( + generalGetString(R.string.network_use_onion_hosts), + values, + onionHosts, + icon = Icons.Outlined.Security, + enabled = enabled, + onSelected = useOnion + ) +} + +@Composable +fun ExposedDropDownSettingRow( + title: String, + values: List>, + selection: State, + label: String? = null, + icon: ImageVector? = null, + iconTint: Color = HighOrLowlight, + enabled: State = mutableStateOf(true), + onSelected: (T) -> Unit +) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + var expanded by remember { mutableStateOf(false) } + + if (icon != null) { + Icon( + icon, + "", + Modifier.padding(end = 8.dp), + tint = iconTint + ) + } + Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight) + + Spacer(Modifier.fillMaxWidth().weight(1f)) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !expanded && enabled.value + } + ) { + Row( + Modifier.padding(start = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Text( + values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = HighOrLowlight + ) + Spacer(Modifier.size(12.dp)) + Icon( + if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess, + generalGetString(R.string.icon_descr_more_button), + tint = HighOrLowlight + ) + } + ExposedDropdownMenu( + modifier = Modifier.widthIn(min = 200.dp), + expanded = expanded, + onDismissRequest = { + expanded = false + } + ) { + values.forEach { selectionOption -> + DropdownMenuItem( + onClick = { + onSelected(selectionOption.first) + expanded = false + } + ) { + Text( + selectionOption.second + (if (label != null) " $label" else ""), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +private fun updateNetworkSettingsDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.update_network_settings_question), + text = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers), + confirmText = generalGetString(R.string.update_network_settings_confirmation), + onDismiss = onDismiss, + onConfirm = onConfirm, + ) +} + @Preview(showBackground = true) @Composable fun PreviewNetworkAndServersLayout() { @@ -138,7 +279,9 @@ fun PreviewNetworkAndServersLayout() { networkUseSocksProxy = remember { mutableStateOf(true) }, showModal = { {} }, showSettingsModal = { {} }, - toggleSocksProxy = {} + toggleSocksProxy = {}, + onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, + useOnion = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 74d8970a20..e7c453ef0f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -21,9 +21,10 @@ import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* -import chat.simplex.app.BuildConfig +import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* @@ -37,6 +38,8 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false + MaintainIncognitoState(chatModel) + fun setRunServiceInBackground(on: Boolean) { chatModel.controller.appPrefs.runServiceInBackground.set(on) if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) { @@ -44,12 +47,15 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { } chatModel.controller.showBackgroundServiceNoticeIfNeeded() chatModel.runServiceInBackground.value = on + SimplexService.StartReceiver.toggleReceiver(on) } if (user != null) { SettingsLayout( profile = user.profile, stopped, + chatModel.incognito, + chatModel.controller.appPrefs.incognito, runServiceInBackground = chatModel.runServiceInBackground, developerTools = chatModel.controller.appPrefs.developerTools, setRunServiceInBackground = ::setRunServiceInBackground, @@ -57,24 +63,12 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close -> ModalView(close = close, modifier = Modifier, - background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) { + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) { modalView(chatModel) } } } }, showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }, - showAppearance = { - withApi { - ModalManager.shared.showCustomModal { close -> - ModalView( - close = close, modifier = Modifier, - background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight - ) { - AppearanceView() - } - } - } - } // showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } }, ) } @@ -87,7 +81,7 @@ val simplexTeamUri = //fun showSectionedModal(chatModel: ChatModel, modalView: (@Composable (ChatModel) -> Unit)) { // ModalManager.shared.showCustomModal { close -> // ModalView(close = close, modifier = Modifier, -// background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) { +// background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) { // modalView(chatModel) // } // } @@ -95,8 +89,10 @@ val simplexTeamUri = @Composable fun SettingsLayout( - profile: Profile, + profile: LocalProfile, stopped: Boolean, + incognito: MutableState, + incognitoPref: Preference, runServiceInBackground: MutableState, developerTools: Preference, setRunServiceInBackground: (Boolean) -> Unit, @@ -105,7 +101,6 @@ fun SettingsLayout( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showTerminal: () -> Unit, - showAppearance: () -> Unit // showVideoChatPrototype: () -> Unit ) { val uriHandler = LocalUriHandler.current @@ -113,7 +108,7 @@ fun SettingsLayout( Column( Modifier .fillMaxSize() - .background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) + .background(if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) .padding(top = 16.dp) ) { Text( @@ -128,6 +123,8 @@ fun SettingsLayout( ProfilePreview(profile, stopped = stopped) } SectionDivider() + SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { onClickIncognitoInfo(showModal) } + SectionDivider() SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped) SectionDivider() DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) @@ -141,7 +138,7 @@ fun SettingsLayout( SectionDivider() SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped) SectionDivider() - SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showAppearance, disabled = stopped) + SettingsActionItem(Icons.Outlined.LightMode, stringResource(R.string.appearance_settings), showSettingsModal { AppearanceView(showCustomModal) }, disabled = stopped) SectionDivider() SettingsActionItem(Icons.Outlined.WifiTethering, stringResource(R.string.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal) }, disabled = stopped) } @@ -161,7 +158,7 @@ fun SettingsLayout( SectionSpacer() SectionView(stringResource(R.string.settings_section_title_develop)) { - ChatConsoleItem(showTerminal, stopped) + ChatConsoleItem(showTerminal) SectionDivider() SettingsPreferenceItem(Icons.Outlined.Construction, stringResource(R.string.settings_developer_tools), developerTools) SectionDivider() @@ -175,6 +172,47 @@ fun SettingsLayout( } } +@Composable +fun SettingsIncognitoActionItem( + incognitoPref: Preference, + incognito: MutableState, + stopped: Boolean, + onClickInfo: () -> Unit, +) { + SettingsPreferenceItemWithInfo( + if (incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.TheaterComedy, + if (incognito.value) Indigo else HighOrLowlight, + stringResource(R.string.incognito), + stopped, + onClickInfo, + incognitoPref, + incognito + ) +} + +private val onClickIncognitoInfo: ((@Composable (ChatModel) -> Unit) -> (() -> Unit)) -> Unit = { showModal -> + showModal { IncognitoView() }() +} + +@Composable +fun MaintainIncognitoState(chatModel: ChatModel) { + // Cache previous value and once it changes in background, update it via API + var cachedIncognito by remember { mutableStateOf(chatModel.incognito.value) } + LaunchedEffect(chatModel.incognito.value) { + // Don't do anything if nothing changed + if (cachedIncognito == chatModel.incognito.value) return@LaunchedEffect + try { + chatModel.controller.apiSetIncognito(chatModel.incognito.value) + } catch (e: Exception) { + // Rollback the state + chatModel.controller.appPrefs.incognito.set(cachedIncognito) + // Crash the app + throw e + } + cachedIncognito = chatModel.incognito.value + } +} + @Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemView(openDatabaseView) { Row( @@ -264,18 +302,15 @@ fun SettingsLayout( } } -@Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) { - SectionItemView(showTerminal, disabled = stopped) { +@Composable private fun ChatConsoleItem(showTerminal: () -> Unit) { + SectionItemView(showTerminal) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), contentDescription = stringResource(R.string.chat_console), tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - stringResource(R.string.chat_console), - color = if (stopped) HighOrLowlight else Color.Unspecified - ) + Text(stringResource(R.string.chat_console)) } } @@ -287,7 +322,7 @@ fun SettingsLayout( tint = HighOrLowlight, ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) + Text(generalGetString(R.string.install_simplex_chat_for_terminal), color = MaterialTheme.colors.primary) } } @@ -305,11 +340,15 @@ fun SettingsLayout( profileOf.displayName, style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, - color = if (stopped) HighOrLowlight else Color.Unspecified + color = if (stopped) HighOrLowlight else Color.Unspecified, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Text( profileOf.fullName, - color = if (stopped) HighOrLowlight else Color.Unspecified + color = if (stopped) HighOrLowlight else Color.Unspecified, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } @@ -334,6 +373,25 @@ fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference Unit, + pref: Preference, + prefState: MutableState? = null +) { + SectionItemView() { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { onClickInfo() }) { + Icon(icon, text, tint = if (stopped) HighOrLowlight else iconTint) + Spacer(Modifier.padding(horizontal = 4.dp)) + SharedPreferenceToggleWithIcon(text, Icons.Outlined.Info, stopped, onClickInfo, pref, prefState) + } + } +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -344,8 +402,10 @@ fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference>, + onSelectTheme: (String) -> Unit, +) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + stringResource(R.string.settings_section_title_themes).lowercase().capitalize(Locale.current), + Modifier.padding(start = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + val currentTheme by CurrentColors.collectAsState() + SectionView(null) { + LazyColumn( + Modifier.padding(horizontal = 8.dp) + ) { + items(allThemes.size) { index -> + val item = allThemes[index] + val onClick = { + onSelectTheme(item.second.name) + } + SectionItemViewSpaceBetween(onClick, padding = PaddingValues()) { + Text(item.third) + if (currentTheme.second == item.second) { + Icon(Icons.Outlined.Check, item.third, tint = HighOrLowlight) + } + } + Spacer(Modifier.padding(horizontal = 4.dp)) + } + } + } + } +} + diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 97b895f8fc..54a268e394 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -22,8 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.Profile +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* @@ -37,7 +36,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { val user = chatModel.currentUser.value if (user != null) { val editProfile = remember { mutableStateOf(false) } - var profile by remember { mutableStateOf(user.profile) } + var profile by remember { mutableStateOf(user.profile.toProfile()) } UserProfileLayout( close = close, editProfile = editProfile, @@ -47,7 +46,9 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { val p = Profile(displayName, fullName, image) val newProfile = chatModel.controller.apiUpdateProfile(p) if (newProfile != null) { - chatModel.updateUserProfile(newProfile) + chatModel.currentUser.value?.profile?.profileId?.let { + chatModel.updateUserProfile(newProfile.toLocalProfile(it)) + } profile = newProfile } editProfile.value = false diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 8d483270c2..928cf18d0d 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -31,8 +31,11 @@ приглашение соединиться соединяется… вы создали одноразовую ссылку + вы создали одноразовую ссылку инкогнито через ссылку-контакт + инкогнито через ссылку-контакт через одноразовую ссылку + инкогнито через одноразовую ссылку Ошибка при сохранении SMP серверов @@ -114,6 +117,7 @@ Ваши чаты соединяется… вы приглашены в группу + вступить как %s соединяется… @@ -141,10 +145,14 @@ Файл не найден Ошибка сохранения файла + + Уведомления + Удалить контакт? Контакт и все сообщения будут удалены - это действие нельзя отменить! Удалить контакт + Имя контакта… Соединение с сервером установлено Соединение с сервером не установлено Ошибка соединения с сервером @@ -157,9 +165,10 @@ Назад Отменить Подтвердить - + OK нет описания Добавить контакт + Скопировано в буфер обмена Начать новый разговор @@ -195,6 +204,7 @@ Принять запрос на соединение? Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение. Принять + Принять инкогнито Отклонить @@ -204,6 +214,10 @@ Очистить чат Прочитано + + Без звука + Уведомлять + Вы пригласили ваш контакт Вы приняли приглашение соединиться @@ -233,6 +247,7 @@ SimpleX команда SimpleX логотип Email + Больше Неверный QR код @@ -242,13 +257,15 @@ Запрос на соединение послан! Соединение будет установлено когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! Соединение будет установлено когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже! - Покажите QR код вашему контакту, чтобы сосканировать его из приложения + Покажите QR код вашему контакту, чтобы сосканировать его из приложения. Если вы не можете встретиться лично, вы можете показать QR код во время видеозвонка или отправить ссылку через любой другой канал связи. Ваш профиль будет отправлен\nвашему контакту Если вы не можете встретиться лично, вы можете сосканировать QR код во время видеозвонка, или ваш контакт может отправить вам ссылку. Поделиться ссылкой Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. - + Ваш профиль будет отправлен вашему контакту + + Настройки Ваш SimpleX адрес @@ -263,7 +280,7 @@ Блокировка SimpleX Консоль SMP серверы - SimpleX Chat для терминала + SimpleX Chat для терминала Использовать серверы предосталенные SimpleX Chat? Сохраненные SMP серверы будут удалены. Ваши SMP серверы @@ -272,7 +289,7 @@ Введите SMP серверы, каждый сервер в отдельной строке: Инфо Сохранить - Сеть & серверы + Сеть и серверы Настройки сети Настройки сети Использовать SOCKS прокси (порт 9050) @@ -280,6 +297,10 @@ Соединяться с серверами через SOCKS прокси через порт 9050? Прокси должен быть запущен до включения этой опции. Использовать прямое соединение с Интернет? Если вы подтвердите, серверы смогут видеть ваш IP адрес, а провайдер - с какими серверами вы соединяетесь. + Использовать .onion хосты + Когда возможно + Нет + Обязательно Интерфейс @@ -459,6 +480,8 @@ Экспериментальные функции SOCKS ПРОКСИ ИКОНКА + ТЕМЫ + Режим Инкогнито Данные чата @@ -509,6 +532,7 @@ Вступить в группу? Вы приглашены в группу. Вступите, чтобы соединиться с членами группы. Вступить + Вступить инкогнито Вступление в группу Вы вступили в эту группу. Устанавливается соединение с пригласившим членом группы. Выйти @@ -521,11 +545,14 @@ Группа не найдена! Эта группа больше не существует. Ошибка приглашения + Нельзя пригласить контакты! + Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено Вы отправили приглашение в группу Вы приглашены в группу Нажмите, чтобы вступить + Нажмите, чтобы вступить инкогнито Вы вступили в эту группу Вы отклонили приглашение в группу Приглашение в группу истекло @@ -571,6 +598,8 @@ Очистить Выбрано контактов: %1$s Контакты не выбраны + Нельзя пригласить контакт! + Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль Пригласить членов группы @@ -589,6 +618,7 @@ Удалить члена группы + Отправить сообщение Член группы будет удален - это действие нельзя отменить! Удалить ЧЛЕН ГРУППЫ @@ -608,6 +638,8 @@ Группа полностью децентрализована — она видна только членам. Имя группы: Полное имя: + Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы + Ваш профиль чата будет отправлен членам группы Профиль группы хранится на устройствах членов, а не на серверах. @@ -626,4 +658,26 @@ Обновить настройки сети? Обновление настроек приведет к переподключению клиента ко всем серверам. Обновить + + + Инкогнито + Случайный профиль + Вашему контакту будет отправлен случайный профиль + Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль + + Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль. + Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя. + Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом. + Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата. + + + Системная + Светлая + Темная + + + Тема + Сохранить цвет + Сбросить цвета + Акцент diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index c8a26ff348..9e3651b5dd 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -31,8 +31,11 @@ invited to connect connecting… you shared one-time link + you shared one-time link incognito via contact address link + incognito via contact address link via one-time link + incognito via one-time link Error saving SMP servers @@ -114,6 +117,7 @@ Your chats connecting… you are invited to group + join as %s connecting… @@ -141,10 +145,14 @@ File not found Error saving file + + Notifications + Delete contact? Contact and all messages will be deleted - this cannot be undone! Delete contact + Set contact name… Connected Disconnected Error @@ -157,9 +165,10 @@ Back Cancel Confirm - Ok + OK no details Add contact + Copied to clipboard Start new chat @@ -195,6 +204,7 @@ Accept connection request? If you choose to reject sender will NOT be notified. Accept + Accept incognito Reject @@ -204,6 +214,10 @@ Clear chat Mark read + + Mute + Unmute + You invited your contact You accepted connection @@ -233,6 +247,7 @@ SimpleX Team SimpleX Logo Email + More Invalid QR code @@ -242,12 +257,13 @@ Connection request sent! You will be connected when your connection request is accepted, please wait or check later! You will be connected when your contact\'s device is online, please wait or check later! - Show QR code for your contact\nto scan from the app - If you cannot meet in person, you can show QR code in the video call, or you can share the invitation link via any other channel. + Show QR code for your contact to scan from the app. + If you can\'t meet in person, you can show QR code in the video call, or you can share the invitation link via any other channel. Your chat profile will be sent\nto your contact If you cannot meet in person, you can scan QR code in the video call, or your contact can share an invitation link. Share invitation link Paste the link you received into the box below to connect with your contact. + Your chat profile will be sent to your contact Connect via link @@ -268,7 +284,7 @@ SimpleX Lock Chat console SMP servers - Install SimpleX Chat for terminal + Install SimpleX Chat for terminal Use SimpleX Chat servers? Saved SMP servers will be removed. Your SMP servers @@ -285,6 +301,10 @@ Access the servers via SOCKS proxy on port 9050? Proxy must be started before enabling this option. Use direct Internet connection? If you confirm, the messaging servers will be able to see your IP address, and your provider - which servers you are connecting to. + Use .onion hosts + When available + No + Required Appearance @@ -461,6 +481,8 @@ Experimental features SOCKS PROXY APP ICON + THEMES + Incognito mode Your chat database @@ -511,6 +533,7 @@ Join group? You are invited to group. Join to connect with group members. Join + Join incognito Joining group You joined this group. Connecting to inviting group member. Leave @@ -523,11 +546,14 @@ Group not found! This group no longer exists. Error joining group + Can\'t invite contacts! + You\'re using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed You sent group invitation You are invited to group Tap to join + Tap to join incognito You joined this group You rejected group invitation Group invitation expired @@ -573,6 +599,8 @@ Clear %1$s contact(s) selected No contacts selected + Can\'t invite contact! + You\'re trying to invite contact with whom you\'ve shared an incognito profile to the group in which you\'re using your main profile Invite members @@ -591,6 +619,7 @@ Remove member + Send direct message Member will be removed from group - this cannot be undone! Remove MEMBER @@ -610,6 +639,9 @@ The group is fully decentralized – it is visible only to the members. Group display name: Group full name: + Incognito mode is not supported here - your main profile will be sent to group members + Your chat profile will be sent to group members + Group profile is stored on members\' devices, not on the servers. @@ -628,4 +660,26 @@ Update network settings? Updating settings will re-connect the client to all servers. Update + + + Incognito + Your random profile + A random profile will be sent to your contact + A random profile will be sent to the contact that you received this link from + + Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created. + It allows having many anonymous connections without any shared data between them in a single chat profile. + When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. + To find the profile used for an incognito connection, tap the contact or group name on top of the chat. + + + System + Light + Dark + + + Theme + Save color + Reset colors + Accent diff --git a/apps/android/build.gradle b/apps/android/build.gradle index 88f2d35f7e..e7be963f32 100644 --- a/apps/android/build.gradle +++ b/apps/android/build.gradle @@ -1,14 +1,36 @@ buildscript { + Properties localProperties = new Properties() + if (rootProject.file('local.properties').canRead()) { + localProperties.load(rootProject.file("local.properties").newDataInputStream()) + } + ext { - compose_version = '1.2.0-beta02' + compose_version = localProperties['compose_version'] ?: '1.2.0-beta02' + kotlin_version = localProperties['kotlin_version'] ?: '1.6.21' + gradle_plugin_version = localProperties['gradle_plugin_version'] ?: '7.2.0' + + // Name that will be shown for debug build. By default it is from strings + app_name = localProperties['app_name'] ?: "@string/app_name" + // Whether the app is debuggable or not. Specify `false` if you want good performance in debug builds + enable_debuggable = localProperties['debuggable'] ?: true + // Ending part of package name. + // Provide, for example, `application_id_suffix=.debug` in local.properties + // to allow debug & release versions to coexist + application_id_suffix = localProperties['application_id_suffix'] ?: '' + + // Compression level for debug AND release apk. 0 = disable compression. Max is 9 + compression_level = localProperties['compression_level'] ?: '0' + + // NOTE: If you need a different version of something, provide it in `local.properties` + // like so: compose_version=123, or gradle_plugin_version=1.2.3, etc } repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21" + classpath "com.android.tools.build:gradle:$gradle_plugin_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2" // NOTE: Do not place your application dependencies here; they belong @@ -16,10 +38,10 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.2.0' apply false - id 'com.android.library' version '7.2.0' apply false - id 'org.jetbrains.kotlin.android' version '1.6.21' apply false - id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21' + id 'com.android.application' version "$gradle_plugin_version" apply false + id 'com.android.library' version "$gradle_plugin_version" apply false + id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false + id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version" } task clean(type: Delete) { diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index b06fac9ee0..b79d9bbbe3 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -80,6 +80,16 @@ class AppDelegate: NSObject, UIApplicationDelegate { terminateChat() } + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + if connectingSceneSession.role == .windowApplication { + configuration.delegateClass = SceneDelegate.self + } + return configuration + } + private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let complete = BGManager.shared.completionHandler { logger.debug("AppDelegate: completed BGManager.receiveMessages") @@ -89,3 +99,14 @@ class AppDelegate: NSObject, UIApplicationDelegate { BGManager.shared.receiveMessages(complete) } } + +class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + window = windowScene.keyWindow + window?.tintColor = UIColor(cgColor: getUIAccentColorDefault()) + window?.overrideUserInterfaceStyle = getUserInterfaceStyleDefault() + } +} diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8a25d57de2..ab1739846a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -22,8 +22,9 @@ final class ChatModel: ObservableObject { @Published var chats: [Chat] = [] // current chat @Published var chatId: String? - @Published var chatItems: [ChatItem] = [] + @Published var reversedChatItems: [ChatItem] = [] @Published var chatToTop: String? + @Published var groupMembers: [GroupMember] = [] // items in the terminal view @Published var terminalItems: [TerminalItem] = [] @Published var userAddress: String? @@ -35,6 +36,7 @@ final class ChatModel: ObservableObject { @Published var tokenStatus: NtfTknStatus? @Published var notificationMode = NotificationsMode.off @Published var notificationPreview: NotificationPreviewMode? = ntfPreviewModeGroupDefault.get() + @Published var incognito: Bool = incognitoGroupDefault.get() // pending notification actions @Published var ntfContactRequest: ChatId? @Published var ntfCallInvitationAction: (ChatId, NtfCallAction)? @@ -57,6 +59,16 @@ final class ChatModel: ObservableObject { chats.first(where: { $0.id == id }) } + func getContactChat(_ contactId: Int64) -> Chat? { + chats.first { chat in + if case let .direct(contact) = chat.chatInfo { + return contact.contactId == contactId + } else { + return false + } + } + } + private func getChatIndex(_ id: String) -> Int? { chats.firstIndex(where: { $0.id == id }) } @@ -78,7 +90,7 @@ final class ChatModel: ObservableObject { } func updateContact(_ contact: Contact) { - updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact()) + updateChat(.direct(contact: contact), addMissing: !contact.isIndirectContact) } func updateGroup(_ groupInfo: GroupInfo) { @@ -158,17 +170,7 @@ final class ChatModel: ObservableObject { } // add to current chat if chatId == cInfo.id { - withAnimation { chatItems.append(cItem) } - if case .rcvNew = cItem.meta.itemStatus { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if self.chatId == cInfo.id { - Task { - await apiMarkChatItemRead(cInfo, cItem) - NtfManager.shared.decNtfBadgeCount() - } - } - } - } + withAnimation { reversedChatItems.insert(cItem, at: 0) } } } @@ -186,13 +188,14 @@ final class ChatModel: ObservableObject { } // update current chat if chatId == cInfo.id { - if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) { + if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { withAnimation(.default) { - self.chatItems[i] = cItem + self.reversedChatItems[i] = cItem + self.reversedChatItems[i].viewTimestamp = .now } return false } else { - withAnimation { chatItems.append(cItem) } + withAnimation { reversedChatItems.insert(cItem, at: 0) } return true } } else { @@ -209,12 +212,12 @@ final class ChatModel: ObservableObject { } // remove from current chat if chatId == cInfo.id { - if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) { - if chatItems[i].isRcvNew() == true { + if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + if reversedChatItems[i].isRcvNew() == true { NtfManager.shared.decNtfBadgeCount() } _ = withAnimation { - self.chatItems.remove(at: i) + self.reversedChatItems.remove(at: i) } } } @@ -228,13 +231,44 @@ final class ChatModel: ObservableObject { } // update current chat if chatId == cInfo.id { - var i = 0 - while i < chatItems.count { - if case .rcvNew = chatItems[i].meta.itemStatus { - chatItems[i].meta.itemStatus = .rcvRead - } - i = i + 1 + markCurrentChatRead() + } + } + + private func markCurrentChatRead(fromIndex i: Int = 0) { + var j = i + while j < reversedChatItems.count { + if case .rcvNew = reversedChatItems[j].meta.itemStatus { + reversedChatItems[j].meta.itemStatus = .rcvRead + reversedChatItems[j].viewTimestamp = .now } + j += 1 + } + } + + func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { + if let cItem = aboveItem { + if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + markCurrentChatRead(fromIndex: i) + if let chat = getChat(cInfo.id) { + var unreadBelow = 0 + var j = i - 1 + while j >= 0 { + if case .rcvNew = reversedChatItems[j].meta.itemStatus { + unreadBelow += 1 + } + j -= 1 + } + // update preview + let markedCount = chat.chatStats.unreadCount - unreadBelow + if markedCount > 0 { + NtfManager.shared.decNtfBadgeCount(by: markedCount) + chat.chatStats.unreadCount -= markedCount + } + } + } + } else { + markChatItemsRead(cInfo) } } @@ -248,7 +282,7 @@ final class ChatModel: ObservableObject { } // clear current chat if chatId == cInfo.id { - chatItems = [] + reversedChatItems = [] } } @@ -258,8 +292,9 @@ final class ChatModel: ObservableObject { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1 } // update current chat - if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) { - chatItems[j].meta.itemStatus = .rcvRead + if chatId == cInfo.id, let j = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + reversedChatItems[j].meta.itemStatus = .rcvRead + reversedChatItems[j].viewTimestamp = .now } } @@ -268,8 +303,8 @@ final class ChatModel: ObservableObject { } func getPrevChatItem(_ ci: ChatItem) -> ChatItem? { - if let i = chatItems.firstIndex(where: { $0.id == ci.id }), i > 0 { - return chatItems[i - 1] + if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 { + return reversedChatItems[i + 1] } else { return nil } @@ -291,6 +326,51 @@ final class ChatModel: ObservableObject { chats.removeAll(where: { $0.id == id }) } } + + func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool { + // update current chat + if chatId == groupInfo.id { + if let i = groupMembers.firstIndex(where: { $0.id == member.id }) { + withAnimation(.default) { + self.groupMembers[i] = member + } + return false + } else { + withAnimation { groupMembers.append(member) } + return true + } + } else { + return false + } + } + + func unreadChatItemCounts(itemsInView: Set) -> UnreadChatItemCounts { + var i = 0 + var totalBelow = 0 + var unreadBelow = 0 + while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) { + totalBelow += 1 + if reversedChatItems[i].isRcvNew() { + unreadBelow += 1 + } + i += 1 + } + return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow) + } + + func topItemInView(itemsInView: Set) -> ChatItem? { + let maxIx = reversedChatItems.count - 1 + var i = 0 + let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) } + while i < maxIx && !inView(i) { i += 1 } + while i < maxIx && inView(i) { i += 1 } + return reversedChatItems[min(i - 1, maxIx)] + } +} + +struct UnreadChatItemCounts { + var totalBelow: Int + var unreadBelow: Int } final class Chat: ObservableObject, Identifiable { diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 114baa36b2..0c36e58d0c 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -200,7 +200,9 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") - addNotification(createMessageReceivedNtf(cInfo, cItem)) + if cInfo.ntfsEnabled { + addNotification(createMessageReceivedNtf(cInfo, cItem)) + } } func notifyCallInvitation(_ invitation: RcvCallInvitation) { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 75320f91ca..6db4b01075 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -167,6 +167,12 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } +func apiSetIncognito(incognito: Bool) throws { + let r = chatSendCmdSync(.setIncognito(incognito: incognito)) + if case .cmdOk = r { return } + throw r +} + func apiExportArchive(config: ArchiveConfig) async throws { try await sendCommandOkResp(.apiExportArchive(config: config)) } @@ -185,19 +191,26 @@ func apiGetChats() throws -> [ChatData] { throw r } -func apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination = .last(count: 100)) throws -> Chat { - let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: pagination)) +func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat { + let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search)) if case let .apiChat(chat) = r { return Chat.init(chat) } throw r } -func loadChat(chat: Chat) { +func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { + let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) + if case let .apiChat(chat) = r { return chat.chatItems } + throw r +} + +func loadChat(chat: Chat, search: String = "") { do { let cInfo = chat.chatInfo - let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId) let m = ChatModel.shared + m.reversedChatItems = [] + let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) m.updateChatInfo(chat.chatInfo) - m.chatItems = chat.chatItems + m.reversedChatItems = chat.chatItems.reversed() } catch let error { logger.error("loadChat error: \(responseError(error))") } @@ -302,15 +315,19 @@ func setNetworkConfig(_ cfg: NetCfg) throws { throw r } -func apiContactInfo(contactId: Int64) async throws -> ConnectionStats? { +func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws { + try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings)) +} + +func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) - if case let .contactInfo(_, connStats) = r { return connStats } + if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) } throw r } -func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> ConnectionStats? { +func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?) { let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberInfo(_, _, connStats_) = r { return connStats_ } + if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) } throw r } @@ -419,6 +436,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? { } } +func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? { + let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) + if case let .contactAliasUpdated(toContact) = r { return toContact } + throw r +} + func apiCreateUserAddress() async throws -> String { let r = await chatSendCmd(.createMyAddress) if case let .userContactLinkCreated(connReq) = r { return connReq } @@ -534,13 +557,13 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } -func markChatRead(_ chat: Chat) async { +func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async { do { let minItemId = chat.chatStats.minUnreadItemId - let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId) + let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId) let cInfo = chat.chatInfo try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange) - DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) } + DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) } } catch { logger.error("markChatRead apiChatRead error: \(responseError(error))") } @@ -548,10 +571,11 @@ func markChatRead(_ chat: Chat) async { func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { do { + logger.debug("apiMarkChatItemRead: \(cItem.id)") try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) - DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) } + await MainActor.run { ChatModel.shared.markChatItemRead(cInfo, cItem) } } catch { - logger.error("markChatItemRead apiChatRead error: \(responseError(error))") + logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))") } } @@ -567,17 +591,9 @@ func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { throw r } -func addMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) async { - do { - try await apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole) - } catch let error { - logger.error("addMember error: \(responseError(error))") - } -} - -func apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) async throws { +func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) - if case .sentGroupInvitation = r { return } + if case let .sentGroupInvitation(_, _, member) = r { return member } throw r } @@ -597,7 +613,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { } } -func apiRemoveMember(groupId: Int64, memberId: Int64) async throws -> GroupMember { +func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember { let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false) if case let .userDeletedMember(_, member) = r { return member } throw r @@ -643,6 +659,7 @@ func initializeChat(start: Bool) throws { do { let m = ChatModel.shared try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + try apiSetIncognito(incognito: incognitoGroupDefault.get()) m.currentUser = try apiGetActiveUser() if m.currentUser == nil { m.onboardingStage = .step1_SimpleXInfo @@ -820,12 +837,22 @@ func processReceivedMsg(_ res: ChatResponse) async { chatItems: [] )) // NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation? + case let .joinedGroupMemberConnecting(groupInfo, _, member): + _ = m.upsertGroupMember(groupInfo, member) + case let .deletedMemberUser(groupInfo, _): // TODO update user member + m.updateGroup(groupInfo) + case let .deletedMember(groupInfo, _, deletedMember): + _ = m.upsertGroupMember(groupInfo, deletedMember) + case let .leftMember(groupInfo, member): + _ = m.upsertGroupMember(groupInfo, member) + case let .groupDeleted(groupInfo, _): // TODO update user member + m.updateGroup(groupInfo) case let .userJoinedGroup(groupInfo): m.updateGroup(groupInfo) - case let .groupDeleted(groupInfo, _): - m.updateGroup(groupInfo) - case let .deletedMemberUser(groupInfo, _): - m.updateGroup(groupInfo) + case let .joinedGroupMember(groupInfo, member): + _ = m.upsertGroupMember(groupInfo, member) + case let .connectedToGroupMember(groupInfo, member): + _ = m.upsertGroupMember(groupInfo, member) case let .groupUpdated(toGroup): m.updateGroup(toGroup) case let .rcvFileStart(aChatItem): diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 8187de64ec..386ae0c431 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -116,11 +116,6 @@ struct SimpleXApp: App { if let id = chatModel.chatId, let chat = chatModel.getChat(id) { loadChat(chat: chat) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if chatModel.chatId == chat.id { - Task { await markChatRead(chat) } - } - } } if let chatId = chatModel.ntfContactRequest { chatModel.ntfContactRequest = nil diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index d57e0b9925..7e527aef44 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -19,6 +19,10 @@ struct ChatInfoToolbar: View { var body: some View { let cInfo = chat.chatInfo return HStack { + if (cInfo.incognito) { + Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo) + Spacer().frame(width: 16) + } ChatInfoImage( chat: chat, color: colorScheme == .dark diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 253ccb0f15..667ae0e597 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -46,7 +46,11 @@ struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat + var contact: Contact var connectionStats: ConnectionStats? + var customUserProfile: Profile? + @State var localAlias: String + @FocusState private var aliasTextFieldFocused: Bool @State private var alert: ChatInfoViewAlert? = nil @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -63,6 +67,20 @@ struct ChatInfoView: View { List { contactInfoHeader() .listRowBackground(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + aliasTextFieldFocused = false + } + + localAliasTextEdit() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + if let customUserProfile = customUserProfile { + Section("Incognito") { + infoRow("Your random profile", customUserProfile.chatViewName) + } + } if let connStats = connectionStats { Section("Servers") { @@ -106,11 +124,11 @@ struct ChatInfoView: View { .frame(width: 192, height: 192) .padding(.top, 12) .padding() - Text(cInfo.displayName) + Text(contact.profile.displayName) .font(.largeTitle) .lineLimit(1) .padding(.bottom, 2) - if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName { + if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName { Text(cInfo.fullName) .font(.title2) .lineLimit(2) @@ -119,6 +137,37 @@ struct ChatInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } + func localAliasTextEdit() -> some View { + TextField("Set contact name…", text: $localAlias) + .disableAutocorrection(true) + .focused($aliasTextFieldFocused) + .submitLabel(.done) + .onChange(of: aliasTextFieldFocused) { focused in + if !focused { + setContactAlias() + } + } + .onSubmit { + setContactAlias() + } + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + } + + private func setContactAlias() { + Task { + do { + if let contact = try await apiSetContactAlias(contactId: chat.chatInfo.apiId, localAlias: localAlias) { + await MainActor.run { + chatModel.updateContact(contact) + } + } + } catch { + logger.error("setContactAlias error: \(responseError(error))") + } + } + } + func networkStatusRow() -> some View { HStack { Text("Network status") @@ -167,6 +216,7 @@ struct ChatInfoView: View { try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) await MainActor.run { chatModel.removeChat(chat.chatInfo.id) + chatModel.chatId = nil dismiss() } } catch let error { @@ -202,6 +252,6 @@ struct ChatInfoView: View { struct ChatInfoView_Previews: PreviewProvider { static var previews: some View { - ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) + ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), contact: Contact.sampleData, localAlias: "") } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 4419a884d4..c7ec3ca713 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -10,10 +10,12 @@ import SwiftUI import SimpleXChat struct CIGroupInvitationView: View { + @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme var chatItem: ChatItem var groupInvitation: CIGroupInvitation var memberRole: GroupMemberRole + var chatIncognito: Bool = false @State private var frameWidth: CGFloat = 0 var body: some View { @@ -29,10 +31,13 @@ struct CIGroupInvitationView: View { Divider().frame(width: frameWidth) if action { - groupInvitationText().overlay(DetermineWidth()) - Text("Tap to join") - .foregroundColor(.accentColor) + groupInvitationText() + .overlay(DetermineWidth()) + Text(chatIncognito ? "Tap to join incognito" : "Tap to join") + .foregroundColor(chatIncognito ? .indigo : .accentColor) .font(.callout) + .padding(.trailing, 60) + .overlay(DetermineWidth()) } else { groupInvitationText() .padding(.trailing, 60) @@ -52,17 +57,26 @@ struct CIGroupInvitationView: View { .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } if action { - v.onTapGesture { acceptInvitation() } + v.onTapGesture { + joinGroup(groupInvitation.groupId) + } } else { v } } private func groupInfoView(_ action: Bool) -> some View { - HStack(alignment: .top) { + var color: Color + if action { + color = chatIncognito ? .indigo : .accentColor + } else { + color = Color(uiColor: .tertiaryLabel) + } + return HStack(alignment: .top) { ProfileImage( + imageStr: groupInvitation.groupProfile.image, iconName: "person.2.circle.fill", - color: action ? .accentColor : Color(uiColor: .tertiaryLabel) + color: color ) .frame(width: 44, height: 44) .padding(.trailing, 4) @@ -94,13 +108,6 @@ struct CIGroupInvitationView: View { } } } - - private func acceptInvitation() { - Task { - logger.debug("acceptInvitation") - await joinGroup(groupInvitation.groupId) - } - } } struct CIGroupInvitationView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index c43fc9daf0..af71f74307 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -48,7 +48,7 @@ struct ChatItemView: View { } private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { - CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole) + CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito) } private func groupEventItemView() -> some View { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 0046245627..63368ecf48 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -17,64 +17,35 @@ struct ChatView: View { @ObservedObject var chat: Chat @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false - @State private var membersToAdd: [Contact] = [] @State private var composeState = ComposeState() @State private var deletingItem: ChatItem? = nil @FocusState private var keyboardVisible: Bool @State private var showDeleteMessage = false @State private var connectionStats: ConnectionStats? + @State private var customUserProfile: Profile? + @State private var tableView: UITableView? + @State private var loadingItems = false + @State private var firstPage = false + @State private var itemsInView: Set = [] + @State private var scrollProxy: ScrollViewProxy? + @State private var searchMode = false + @State private var searchText: String = "" + @FocusState private var searchFocussed + // opening GroupMemberInfoView on member icon + @State private var selectedMember: GroupMember? = nil + @State private var memberConnectionStats: ConnectionStats? var body: some View { let cInfo = chat.chatInfo - - return VStack { - GeometryReader { g in - let maxWidth = - cInfo.chatType == .group - ? (g.size.width - 28) * 0.84 - 42 - : (g.size.width - 32) * 0.84 - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 5) { - ForEach(chatModel.chatItems) { ci in - if case let .groupRcv(member) = ci.chatDir { - let prevItem = chatModel.getPrevChatItem(ci) - HStack(alignment: .top, spacing: 0) { - let showMember = prevItem == nil || showMemberImage(member, prevItem) - if showMember { - ProfileImage(imageStr: member.memberProfile.image) - .frame(width: memberImageSize, height: memberImageSize) - } else { - Rectangle().fill(.clear) - .frame(width: memberImageSize, height: memberImageSize) - } - chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8) - } - .padding(.trailing) - .padding(.leading, 12) - } else { - chatItemWithMenu(ci, maxWidth).padding(.horizontal) - } - } - .onAppear { - DispatchQueue.main.async { - scrollToFirstUnread(proxy) - } - markAllRead() - } - .onChange(of: chatModel.chatItems.last?.id) { _ in - scrollToBottom(proxy) - } - .onChange(of: keyboardVisible) { _ in - if keyboardVisible { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - scrollToBottom(proxy, animation: .easeInOut(duration: 1)) - } - } - } - } - } - .onTapGesture { hideKeyboard() } + return VStack(spacing: 0) { + if searchMode { + searchToolbar() + Divider() + } + ZStack(alignment: .trailing) { + chatItemsList() + if let proxy = scrollProxy { + floatingButtons(proxy) } } @@ -85,45 +56,66 @@ struct ChatView: View { composeState: $composeState, keyboardVisible: $keyboardVisible ) - .disabled(!chat.chatInfo.sendMsgEnabled) + .disabled(!cInfo.sendMsgEnabled) } + .padding(.top, 1) .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button { chatModel.chatId = nil } label: { - HStack(spacing: 4) { + Button { + chatModel.chatId = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + if chatModel.chatId == nil { + chatModel.reversedChatItems = [] + } + } + } label: { + HStack(spacing: 0) { Image(systemName: "chevron.backward") - Text("Chats", comment: "back button to return to chats list") + Text("Chats") } } } ToolbarItem(placement: .principal) { - Button { - if case .direct = cInfo { + if case let .direct(contact) = cInfo { + Button { Task { do { - let stats = try await apiContactInfo(contactId: chat.chatInfo.apiId) - await MainActor.run { connectionStats = stats } + let (stats, profile) = try await apiContactInfo(contactId: chat.chatInfo.apiId) + await MainActor.run { + connectionStats = stats + customUserProfile = profile + } } catch let error { logger.error("apiContactInfo error: \(responseError(error))") } await MainActor.run { showChatInfoSheet = true } } - } else { - showChatInfoSheet = true + } label: { + ChatInfoToolbar(chat: chat) } - } label: { - ChatInfoToolbar(chat: chat) - } - .sheet(isPresented: $showChatInfoSheet) { - switch cInfo { - case .direct: - ChatInfoView(chat: chat, connectionStats: connectionStats) - case let .group(groupInfo): + .sheet(isPresented: $showChatInfoSheet, onDismiss: { + connectionStats = nil + customUserProfile = nil + }) { + ChatInfoView(chat: chat, contact: contact, connectionStats: connectionStats, customUserProfile: customUserProfile, localAlias: chat.chatInfo.localAlias) + } + } else if case let .group(groupInfo) = cInfo { + Button { + Task { + let groupMembers = await apiListMembers(groupInfo.groupId) + await MainActor.run { + ChatModel.shared.groupMembers = groupMembers + showChatInfoSheet = true + } + } + } label: { + ChatInfoToolbar(chat: chat) + } + .sheet(isPresented: $showChatInfoSheet) { GroupChatInfoView(chat: chat, groupInfo: groupInfo) - default: - EmptyView() } } } @@ -132,21 +124,183 @@ struct ChatView: View { case let .direct(contact): HStack { callButton(contact, .audio, imageName: "phone") - callButton(contact, .video, imageName: "video") + Menu { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") + } + searchButton() + toggleNtfsButton(chat) + } label: { + Image(systemName: "ellipsis") + } } case let .group(groupInfo): - if groupInfo.canAddMembers { - addMembersButton() - .sheet(isPresented: $showAddMembersSheet) { - AddGroupMembersView(chat: chat, groupInfo: groupInfo, membersToAdd: membersToAdd) + HStack { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + Image(systemName: "person.crop.circle.badge.plus") + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) } + } else { + addMembersButton() + .sheet(isPresented: $showAddMembersSheet) { + AddGroupMembersView(chat: chat, groupInfo: groupInfo) + } } + } + Menu { + searchButton() + toggleNtfsButton(chat) + } label: { + Image(systemName: "ellipsis") + } } default: EmptyView() } } } - .navigationBarBackButtonHidden(true) + } + + private func searchToolbar() -> some View { + HStack { + HStack { + Image(systemName: "magnifyingglass") + TextField("Search", text: $searchText) + .focused($searchFocussed) + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill").opacity(searchText == "" ? 0 : 1) + } + } + .padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)) + .foregroundColor(.secondary) + .background(Color(.secondarySystemBackground)) + .cornerRadius(10.0) + + Button ("Cancel") { + searchText = "" + searchMode = false + searchFocussed = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + chatModel.reversedChatItems = [] + loadChat(chat: chat) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + private func chatItemsList() -> some View { + let cInfo = chat.chatInfo + return GeometryReader { g in + ScrollViewReader { proxy in + ScrollView { + let maxWidth = + cInfo.chatType == .group + ? (g.size.width - 28) * 0.84 - 42 + : (g.size.width - 32) * 0.84 + LazyVStack(spacing: 5) { + ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in + chatItemView(ci, maxWidth) + .scaleEffect(x: 1, y: -1, anchor: .center) + .onAppear { + itemsInView.insert(ci.viewId) + loadChatItems(cInfo, ci, proxy) + if ci.isRcvNew() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { + if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) { + Task { + await apiMarkChatItemRead(cInfo, ci) + NtfManager.shared.decNtfBadgeCount() + } + } + } + } + } + .onDisappear { + itemsInView.remove(ci.viewId) + } + } + } + } + .onAppear { + scrollProxy = proxy + } + .onTapGesture { hideKeyboard() } + .onChange(of: searchText) { _ in + loadChat(chat: chat, search: searchText) + } + .onChange(of: chatModel.chatId) { _ in + if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { + showChatInfoSheet = false + loadChat(chat: chat) + DispatchQueue.main.async { + scrollToBottom(proxy) + } + } + } + } + } + .scaleEffect(x: 1, y: -1, anchor: .center) + } + + private func floatingButtons(_ proxy: ScrollViewProxy) -> some View { + let counts = chatModel.unreadChatItemCounts(itemsInView: itemsInView) + return VStack { + let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow + if unreadAbove > 0 { + circleButton { + unreadCountText(unreadAbove) + .font(.callout) + .foregroundColor(.accentColor) + } + .onTapGesture { scrollUp(proxy) } + .contextMenu { + Button { + if let ci = chatModel.topItemInView(itemsInView: itemsInView) { + Task { + await markChatRead(chat, aboveItem: ci) + } + } + } label: { + Label("Mark read", systemImage: "checkmark") + } + } + } + Spacer() + if counts.unreadBelow > 0 { + circleButton { + unreadCountText(counts.unreadBelow) + .font(.callout) + .foregroundColor(.accentColor) + } + .onTapGesture { scrollToBottom(proxy) } + } else if counts.totalBelow > 16 { + circleButton { + Image(systemName: "chevron.down") + .foregroundColor(.accentColor) + } + .onTapGesture { scrollToBottom(proxy) } + } + } + .padding() + } + + private func circleButton(_ content: @escaping () -> Content) -> some View { + ZStack { + Circle() + .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground)) + .frame(width: 44, height: 44) + content() + } } private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { @@ -157,13 +311,23 @@ struct ChatView: View { } } + private func searchButton() -> some View { + Button { + searchMode = true + searchFocussed = true + searchText = "" + } label: { + Label("Search", systemImage: "magnifyingglass") + } + } + private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { Task { - let ms = await apiListMembers(gInfo.apiId) + let groupMembers = await apiListMembers(gInfo.groupId) await MainActor.run { - membersToAdd = filterMembersToAdd(ms) + ChatModel.shared.groupMembers = groupMembers showAddMembersSheet = true } } @@ -173,76 +337,172 @@ struct ChatView: View { } } - private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { - let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth) - .contextMenu { - if ci.isMsgContent() { - Button { - withAnimation { - if composeState.editing() { - composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) - } else { - composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) - } - } - } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } - Button { - var shareItems: [Any] = [ci.content.text] - if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { - shareItems.append(image) - } - showShareSheet(items: shareItems) - } label: { Label("Share", systemImage: "square.and.arrow.up") } - Button { - if case let .image(text, _) = ci.content.msgContent, - text == "", - let image = getLoadedImage(ci.file) { - UIPasteboard.general.image = image + private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { + if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { + if loadingItems || firstPage { return } + loadingItems = true + Task { + do { + let items = try await apiGetChatItems( + type: cInfo.chatType, + id: cInfo.apiId, + pagination: .before(chatItemId: firstItem.id, count: 50), + search: searchText + ) + await MainActor.run { + if items.count == 0 { + firstPage = true } else { - UIPasteboard.general.string = ci.content.text + chatModel.reversedChatItems.append(contentsOf: items.reversed()) } - } label: { Label("Copy", systemImage: "doc.on.doc") } - if case .image = ci.content.msgContent, - let image = getLoadedImage(ci.file) { - Button { - UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) - } label: { Label("Save", systemImage: "square.and.arrow.down") } + loadingItems = false } - if ci.meta.editable { - Button { - withAnimation { - composeState = ComposeState(editingItem: ci) - } - } label: { Label("Edit", systemImage: "square.and.pencil") } - } - Button(role: .destructive) { - showDeleteMessage = true - deletingItem = ci - } label: { Label("Delete", systemImage: "trash") } - } else if ci.isDeletedContent() { - Button(role: .destructive) { - showDeleteMessage = true - deletingItem = ci - } label: { Label("Delete", systemImage: "trash") } + } catch let error { + logger.error("apiGetChat error: \(responseError(error))") + await MainActor.run { loadingItems = false } } } + } + } + + @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { + if case let .groupRcv(member) = ci.chatDir, + case let .group(groupInfo) = chat.chatInfo { + let prevItem = chatModel.getPrevChatItem(ci) + HStack(alignment: .top, spacing: 0) { + let showMember = prevItem == nil || showMemberImage(member, prevItem) + if showMember { + ProfileImage(imageStr: member.memberProfile.image) + .frame(width: memberImageSize, height: memberImageSize) + .onTapGesture { + Task { + do { + let stats = try await apiGroupMemberInfo(member.groupId, member.groupMemberId) + await MainActor.run { memberConnectionStats = stats } + } catch let error { + logger.error("apiGroupMemberInfo error: \(responseError(error))") + } + await MainActor.run { selectedMember = member } + } + } + .sheet(item: $selectedMember, onDismiss: { memberConnectionStats = nil }) { member in + GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: memberConnectionStats) + } + } else { + Rectangle().fill(.clear) + .frame(width: memberImageSize, height: memberImageSize) + } + chatItemWithMenu(ci, maxWidth, showMember: showMember).padding(.leading, 8) + } + .padding(.trailing) + .padding(.leading, 12) + } else { + chatItemWithMenu(ci, maxWidth).padding(.horizontal) + } + } + + private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { + let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading + var menu: [UIAction] = [] + if ci.isMsgContent() { + menu.append(contentsOf: [ + UIAction( + title: NSLocalizedString("Reply", comment: "chat item action"), + image: UIImage(systemName: "arrowshape.turn.up.left") + ) { _ in + withAnimation { + if composeState.editing() { + composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) + } else { + composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + } + } + }, + UIAction( + title: NSLocalizedString("Share", comment: "chat item action"), + image: UIImage(systemName: "square.and.arrow.up") + ) { _ in + var shareItems: [Any] = [ci.content.text] + if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { + shareItems.append(image) + } + showShareSheet(items: shareItems) + }, + UIAction( + title: NSLocalizedString("Copy", comment: "chat item action"), + image: UIImage(systemName: "doc.on.doc") + ) { _ in + if case let .image(text, _) = ci.content.msgContent, + text == "", + let image = getLoadedImage(ci.file) { + UIPasteboard.general.image = image + } else { + UIPasteboard.general.string = ci.content.text + } + } + ]) + if case .image = ci.content.msgContent, + let image = getLoadedImage(ci.file) { + menu.append( + UIAction( + title: NSLocalizedString("Save", comment: "chat item action"), + image: UIImage(systemName: "square.and.arrow.down") + ) { _ in + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + ) + } + if ci.meta.editable { + menu.append( + UIAction( + title: NSLocalizedString("Edit", comment: "chat item action"), + image: UIImage(systemName: "square.and.pencil") + ) { _ in + withAnimation { + composeState = ComposeState(editingItem: ci) + } + } + ) + } + menu.append( + UIAction( + title: NSLocalizedString("Delete", comment: "chat item action"), + image: UIImage(systemName: "trash"), + attributes: [.destructive] + ) { _ in + showDeleteMessage = true + deletingItem = ci + } + ) + } else if ci.isDeletedContent() { + menu.append( + UIAction( + title: NSLocalizedString("Delete", comment: "chat item action"), + image: UIImage(systemName: "trash"), + attributes: [.destructive] + ) { _ in + showDeleteMessage = true + deletingItem = ci + } + ) + } + + return ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth) + .uiKitContextMenu(actions: menu) .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal) } - if let di = deletingItem { - if di.meta.editable { - Button("Delete for everyone",role: .destructive) { - deleteMessage(.cidmBroadcast) - } + if let di = deletingItem, di.meta.editable { + Button("Delete for everyone",role: .destructive) { + deleteMessage(.cidmBroadcast) } } } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) } - + private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { switch (prevItem?.chatDir) { case .groupSnd: return true @@ -251,34 +511,19 @@ struct ChatView: View { } } - func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { - withAnimation(animation) { scrollToBottom_(proxy) } - } - - func scrollToBottom_(_ proxy: ScrollViewProxy) { - if let id = chatModel.chatItems.last?.id { - proxy.scrollTo(id, anchor: .bottom) + private func scrollToBottom(_ proxy: ScrollViewProxy) { + if let ci = chatModel.reversedChatItems.first { + withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } } } - // align first unread with the top or the last unread with bottom - func scrollToFirstUnread(_ proxy: ScrollViewProxy) { - if let cItem = chatModel.chatItems.first(where: { $0.isRcvNew() }) { - proxy.scrollTo(cItem.id) - } else { - scrollToBottom_(proxy) + private func scrollUp(_ proxy: ScrollViewProxy) { + if let ci = chatModel.topItemInView(itemsInView: itemsInView) { + withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } } } - func markAllRead() { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if chatModel.chatId == chat.id { - Task { await markChatRead(chat) } - } - } - } - - func deleteMessage(_ mode: CIDeleteMode) { + private func deleteMessage(_ mode: CIDeleteMode) { logger.debug("ChatView deleteMessage") Task { logger.debug("ChatView deleteMessage: in Task") @@ -302,11 +547,45 @@ struct ChatView: View { } } +@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View { + Button { + toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + } label: { + if chat.chatInfo.ntfsEnabled { + Label("Mute", systemImage: "speaker.slash") + } else { + Label("Unmute", systemImage: "speaker.wave.2") + } + } +} + +func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { + Task { + do { + let chatSettings = ChatSettings(enableNtfs: enableNtfs) + try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings) + await MainActor.run { + switch chat.chatInfo { + case var .direct(contact): + contact.chatSettings = chatSettings + ChatModel.shared.updateContact(contact) + case var .group(groupInfo): + groupInfo.chatSettings = chatSettings + ChatModel.shared.updateGroup(groupInfo) + default: () + } + } + } catch let error { + logger.error("apiSetChatSettings error \(responseError(error))") + } + } +} + struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.chatId = "@1" - chatModel.chatItems = [ + chatModel.reversedChatItems = [ ChatItem.getSample(1, .directSnd, .now, "hello"), ChatItem.getSample(2, .directRcv, .now, "hi"), ChatItem.getSample(3, .directRcv, .now, "hi there"), diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index bae4f60257..2ab0fc5161 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -14,14 +14,29 @@ struct AddGroupMembersView: View { @Environment(\.dismiss) var dismiss: DismissAction var chat: Chat var groupInfo: GroupInfo - var membersToAdd: [Contact] var showSkip: Bool = false + var showFooterCounter: Bool = true var addedMembersCb: ((Set) -> Void)? = nil @State private var selectedContacts = Set() @State private var selectedRole: GroupMemberRole = .admin + @State private var alert: AddGroupMembersAlert? + + private enum AddGroupMembersAlert: Identifiable { + case prohibitedToInviteIncognito + case error(title: LocalizedStringKey, error: String = "") + + var id: String { + switch self { + case .prohibitedToInviteIncognito: return "prohibitedToInviteIncognito" + case let .error(title, _): return "error \(title)" + } + } + } var body: some View { NavigationView { + let membersToAdd = filterMembersToAdd(chatModel.groupMembers) + let v = List { ChatInfoToolbar(chat: chat, imageSize: 48) .frame(maxWidth: .infinity, alignment: .center) @@ -41,15 +56,17 @@ struct AddGroupMembersView: View { inviteMembersButton() .disabled(count < 1) } footer: { - if (count >= 1) { - HStack { - Button { selectedContacts.removeAll() } label: { Text("Clear") } - Spacer() - Text("\(count) contact(s) selected") + if showFooterCounter { + if (count >= 1) { + HStack { + Button { selectedContacts.removeAll() } label: { Text("Clear") } + Spacer() + Text("\(count) contact(s) selected") + } + } else { + Text("No contacts selected") + .frame(maxWidth: .infinity, alignment: .trailing) } - } else { - Text("No contacts selected") - .frame(maxWidth: .infinity, alignment: .trailing) } } @@ -76,17 +93,22 @@ struct AddGroupMembersView: View { } } .frame(maxHeight: .infinity, alignment: .top) + .alert(item: $alert) { alert in + switch alert { + case .prohibitedToInviteIncognito: + return Alert( + title: Text("Can't invite contact!"), + message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile") + ) + case let .error(title, error): + return Alert(title: Text(title), message: Text("\(error)")) + } + } } - func inviteMembersButton() -> some View { + private func inviteMembersButton() -> some View { Button { - Task { - for contactId in selectedContacts { - await addMember(groupId: chat.chatInfo.apiId, contactId: contactId, memberRole: selectedRole) - } - await MainActor.run { dismiss() } - if let cb = addedMembersCb { cb(selectedContacts) } - } + inviteMembers() } label: { HStack { Text("Invite to group") @@ -96,7 +118,22 @@ struct AddGroupMembersView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - func rolePicker() -> some View { + private func inviteMembers() { + Task { + do { + for contactId in selectedContacts { + let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole) + await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) } + } + await MainActor.run { dismiss() } + if let cb = addedMembersCb { cb(selectedContacts) } + } catch { + alert = .error(title: "Error adding member(s)", error: responseError(error)) + } + } + } + + private func rolePicker() -> some View { Picker("New member role", selection: $selectedRole) { ForEach(GroupMemberRole.allCases) { role in if role <= groupInfo.membership.memberRole { @@ -106,13 +143,32 @@ struct AddGroupMembersView: View { } } - func contactCheckView(_ contact: Contact) -> some View { + private func contactCheckView(_ contact: Contact) -> some View { let checked = selectedContacts.contains(contact.apiId) - return Button { + let prohibitedToInviteIncognito = !chat.chatInfo.incognito && contact.contactConnIncognito + var icon: String + var iconColor: Color + if prohibitedToInviteIncognito { + icon = "theatermasks.circle.fill" + iconColor = Color(uiColor: .tertiaryLabel) + } else { if checked { - selectedContacts.remove(contact.apiId) + icon = "checkmark.circle.fill" + iconColor = .accentColor } else { - selectedContacts.insert(contact.apiId) + icon = "circle" + iconColor = Color(uiColor: .tertiaryLabel) + } + } + return Button { + if prohibitedToInviteIncognito { + alert = .prohibitedToInviteIncognito + } else { + if checked { + selectedContacts.remove(contact.apiId) + } else { + selectedContacts.insert(contact.apiId) + } } } label: { HStack{ @@ -120,11 +176,11 @@ struct AddGroupMembersView: View { .frame(width: 30, height: 30) .padding(.trailing, 2) Text(ChatInfo.direct(contact: contact).chatViewName) - .foregroundColor(.primary) + .foregroundColor(prohibitedToInviteIncognito ? .secondary : .primary) .lineLimit(1) Spacer() - Image(systemName: checked ? "checkmark.circle.fill": "circle") - .foregroundColor(checked ? .accentColor : Color(uiColor: .tertiaryLabel)) + Image(systemName: icon) + .foregroundColor(iconColor) } } } @@ -132,6 +188,6 @@ struct AddGroupMembersView: View { struct AddGroupMembersView_Previews: PreviewProvider { static var previews: some View { - AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), groupInfo: GroupInfo.sampleData, membersToAdd: []) + AddGroupMembersView(chat: Chat(chatInfo: ChatInfo.sampleData.group), groupInfo: GroupInfo.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index e39bd323e4..6cb92a10ac 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -15,7 +15,6 @@ struct GroupChatInfoView: View { @ObservedObject var chat: Chat var groupInfo: GroupInfo @ObservedObject private var alertManager = AlertManager.shared - @State private var members: [GroupMember] = [] @State private var alert: GroupChatInfoViewAlert? = nil @State private var showAddMembersSheet: Bool = false @State private var selectedMember: GroupMember? = nil @@ -27,19 +26,30 @@ struct GroupChatInfoView: View { case deleteGroupAlert case clearChatAlert case leaveGroupAlert + case cantInviteIncognitoAlert var id: GroupChatInfoViewAlert { get { self } } } var body: some View { NavigationView { + let members = chatModel.groupMembers + .filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved } + .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } + List { groupInfoHeader() .listRowBackground(Color.clear) Section("\(members.count + 1) members") { if groupInfo.canAddMembers { - addMembersButton() + if (chat.chatInfo.incognito) { + Label("Invite members", systemImage: "plus") + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .onTapGesture { alert = .cantInviteIncognitoAlert } + } else { + addMembersButton() + } } memberView(groupInfo.membership, user: true) ForEach(members) { member in @@ -57,7 +67,7 @@ struct GroupChatInfoView: View { } } .sheet(isPresented: $showAddMembersSheet) { - AddGroupMembersView(chat: chat, groupInfo: groupInfo, membersToAdd: filterMembersToAdd(members)) + AddGroupMembersView(chat: chat, groupInfo: groupInfo) } .sheet(item: $selectedMember, onDismiss: { connectionStats = nil }) { member in GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: connectionStats) @@ -94,14 +104,8 @@ struct GroupChatInfoView: View { case .deleteGroupAlert: return deleteGroupAlert() case .clearChatAlert: return clearChatAlert() case .leaveGroupAlert: return leaveGroupAlert() - } - } - .task { - let ms = await apiListMembers(chat.chatInfo.apiId) - .filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved } - .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } - await MainActor.run { - members = ms + case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert() + } } } @@ -128,7 +132,13 @@ struct GroupChatInfoView: View { private func addMembersButton() -> some View { Button { - showAddMembersSheet = true + Task { + let groupMembers = await apiListMembers(groupInfo.groupId) + await MainActor.run { + ChatModel.shared.groupMembers = groupMembers + showAddMembersSheet = true + } + } } label: { Label("Invite members", systemImage: "plus") } @@ -149,7 +159,7 @@ struct GroupChatInfoView: View { VStack(alignment: .leading) { Text(member.chatViewName) .lineLimit(1) - .foregroundColor(.primary) + .foregroundColor(member.memberIncognito ? .indigo : .primary) let s = Text(member.memberStatus.shortText) (user ? Text ("you: ") + s : s) .lineLimit(1) @@ -211,6 +221,7 @@ struct GroupChatInfoView: View { try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) await MainActor.run { chatModel.removeChat(chat.chatInfo.id) + chatModel.chatId = nil dismiss() } } catch let error { @@ -251,6 +262,13 @@ struct GroupChatInfoView: View { } } +func cantInviteIncognitoAlert() -> Alert { + Alert( + title: Text("Can't invite contacts!"), + message: Text("You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed") + ) +} + struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 6476a483e5..e68378f4c7 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -30,6 +30,12 @@ struct GroupMemberInfoView: View { groupMemberInfoHeader() .listRowBackground(Color.clear) + if let contactId = member.memberContactId { + Section { + openDirectChatButton(contactId) + } + } + Section("Member") { infoRow("Group", groupInfo.displayName) // TODO change role @@ -72,6 +78,30 @@ struct GroupMemberInfoView: View { } } + func openDirectChatButton(_ contactId: Int64) -> some View { + Button { + var chat = chatModel.getContactChat(contactId) + if chat == nil { + do { + chat = try apiGetChat(type: .direct, id: contactId) + if let chat = chat { + // TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend + chat.serverInfo = Chat.ServerInfo(networkStatus: .connected) + chatModel.addChat(chat) + } + } catch let error { + logger.error("openDirectChatButton apiGetChat error: \(responseError(error))") + } + } + if let chat = chat { + dismissAllSheets(animated: true) + chatModel.chatId = chat.id + } + } label: { + Label("Send direct message", systemImage: "message") + } + } + private func groupMemberInfoHeader() -> some View { VStack { ProfileImage(imageStr: member.image, color: Color(uiColor: .tertiarySystemFill)) @@ -107,10 +137,13 @@ struct GroupMemberInfoView: View { primaryButton: .destructive(Text("Remove")) { Task { do { - _ = try await apiRemoveMember(groupId: member.groupId, memberId: member.groupMemberId) - dismiss() + let member = try await apiRemoveMember(groupInfo.groupId, member.groupMemberId) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(groupInfo, member) + dismiss() + } } catch let error { - logger.error("removeMemberAlert apiRemoveMember error: \(error.localizedDescription)") + logger.error("apiRemoveMember error: \(responseError(error))") } } }, diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index eedcf78d91..3d950aead6 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -25,7 +25,7 @@ struct GroupProfileView: View { var body: some View { return VStack(alignment: .leading) { Text("Group profile is stored on members' devices, not on the servers.") - .padding(.bottom) + .padding(.vertical) ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { @@ -109,7 +109,7 @@ struct GroupProfileView: View { .onTapGesture { hideKeyboard() } } - func profileNameTextEdit(_ label: String, _ name: Binding) -> some View { + func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding) -> some View { TextField(label, text: name) .textInputAutocapitalization(.never) .disableAutocorrection(true) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index ecf978e67c..562abfc746 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -28,28 +28,20 @@ struct ChatListNavLink: View { } } - private func chatView() -> some View { - ChatView(chat: chat) - .onAppear { loadChat(chat: chat) } - } - @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { let v = NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - destination: { chatView() }, label: { ChatPreviewView(chat: chat) }, disabled: !contact.ready ) - .swipeActions(edge: .leading) { + .swipeActions(edge: .leading, allowsFullSwipe: true) { if chat.chatStats.unreadCount > 0 { markReadButton() } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { clearChatButton() - } - .swipeActions(edge: .trailing) { Button(role: .destructive) { AlertManager.shared.showAlert( contact.ready @@ -77,16 +69,16 @@ struct ChatListNavLink: View { ChatPreviewView(chat: chat) .frame(height: 80) .swipeActions(edge: .trailing, allowsFullSwipe: true) { - joinGroupButton() - } - .swipeActions(edge: .trailing) { + joinGroupButton(groupInfo.hostConnCustomUserProfileId) if groupInfo.canDelete { deleteGroupChatButton(groupInfo) } } .onTapGesture { showJoinGroupDialog = true } .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { - Button("Join group") { Task { await joinGroup(groupInfo.groupId) } } + Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { + joinGroup(groupInfo.groupId) + } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } } case .memAccepted: @@ -99,27 +91,24 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - destination: { chatView() }, label: { ChatPreviewView(chat: chat) }, disabled: !groupInfo.ready ) .frame(height: 80) - .swipeActions(edge: .leading) { + .swipeActions(edge: .leading, allowsFullSwipe: true) { if chat.chatStats.unreadCount > 0 { markReadButton() } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { clearChatButton() - } - .swipeActions(edge: .trailing) { if (groupInfo.membership.memberCurrent) { Button { AlertManager.shared.showAlert(leaveGroupAlert(groupInfo)) } label: { Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") } - .tint(Color.indigo) + .tint(Color.yellow) } } .swipeActions(edge: .trailing) { @@ -130,13 +119,13 @@ struct ChatListNavLink: View { } } - private func joinGroupButton() -> some View { + private func joinGroupButton(_ hostConnCustomUserProfileId: Int64?) -> some View { Button { - Task { await joinGroup(chat.chatInfo.apiId) } + joinGroup(chat.chatInfo.apiId) } label: { - Label("Join", systemImage: "ipad.and.arrow.forward") + Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward") } - .tint(Color.accentColor) + .tint(chat.chatInfo.incognito ? .indigo : .accentColor) } private func markReadButton() -> some View { @@ -168,9 +157,10 @@ struct ChatListNavLink: View { private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button { Task { await acceptContactRequest(contactRequest) } } - label: { Label("Accept", systemImage: "checkmark") } - .tint(Color.accentColor) + Button { + Task { await acceptContactRequest(contactRequest) } + } label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") } + .tint(chatModel.incognito ? .indigo : .accentColor) Button(role: .destructive) { AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) } label: { @@ -180,7 +170,7 @@ struct ChatListNavLink: View { .frame(height: 80) .onTapGesture { showContactRequestDialog = true } .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) { - Button("Accept contact") { Task { await acceptContactRequest(contactRequest) } } + Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } } Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } } } } @@ -331,32 +321,35 @@ struct ChatListNavLink: View { } } -func joinGroup(_ groupId: Int64) async { - do { - let r = try await apiJoinGroup(groupId) - switch r { - case let .joined(groupInfo): - await MainActor.run { ChatModel.shared.updateGroup(groupInfo) } - case .invitationRemoved: - AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.") - await deleteGroup() - case .groupNotFound: - AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") - await deleteGroup() - } - } catch let error { - let err = responseError(error) - AlertManager.shared.showAlert(Alert(title: Text("Error joining group"), message: Text(err))) - logger.error("apiJoinGroup error: \(err)") - } - - func deleteGroup() async { +func joinGroup(_ groupId: Int64) { + Task { + logger.debug("joinGroup") do { - // TODO this API should update chat item with the invitation as well - try await apiDeleteChat(type: .group, id: groupId) - await MainActor.run { ChatModel.shared.removeChat("#\(groupId)") } - } catch { - logger.error("apiDeleteChat error: \(responseError(error))") + let r = try await apiJoinGroup(groupId) + switch r { + case let .joined(groupInfo): + await MainActor.run { ChatModel.shared.updateGroup(groupInfo) } + case .invitationRemoved: + AlertManager.shared.showAlertMsg(title: "Invitation expired!", message: "Group invitation is no longer valid, it was removed by sender.") + await deleteGroup() + case .groupNotFound: + AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") + await deleteGroup() + } + } catch let error { + let err = responseError(error) + AlertManager.shared.showAlert(Alert(title: Text("Error joining group"), message: Text(err))) + logger.error("apiJoinGroup error: \(err)") + } + + func deleteGroup() async { + do { + // TODO this API should update chat item with the invitation as well + try await apiDeleteChat(type: .group, id: groupId) + await MainActor.run { ChatModel.shared.removeChat("#\(groupId)") } + } catch { + logger.error("apiDeleteChat error: \(responseError(error))") + } } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index def0d4202f..4c504eda8a 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -14,6 +14,7 @@ struct ChatListView: View { // not really used in this view @State private var showSettings = false @State private var searchText = "" + @State private var selectedChat: ChatId? var body: some View { let v = NavigationView { @@ -25,6 +26,7 @@ struct ChatListView: View { } } .onChange(of: chatModel.chatId) { _ in + selectedChat = chatModel.chatId if chatModel.chatId == nil, let chatId = chatModel.chatToTop { chatModel.chatToTop = nil chatModel.popChat(chatId) @@ -44,6 +46,17 @@ struct ChatListView: View { ToolbarItem(placement: .navigationBarLeading) { SettingsButton() } + ToolbarItem(placement: .principal) { + if (chatModel.incognito) { + HStack { + if (chatModel.chats.count > 8) { + Text("Your chats").font(.headline) + Spacer().frame(width: 16) + } + Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo) + } + } + } ToolbarItem(placement: .navigationBarTrailing) { switch chatModel.chatRunning { case .some(true): NewChatButton() @@ -52,6 +65,15 @@ struct ChatListView: View { } } } + .background( + NavigationLink( + destination: chatView(selectedChat), + isActive: Binding( + get: { selectedChat != nil }, + set: { _, _ in selectedChat = nil } + ) + ) { EmptyView() } + ) } .navigationViewStyle(.stack) @@ -62,13 +84,28 @@ struct ChatListView: View { } } + @ViewBuilder private func chatView(_ chatId: ChatId?) -> some View { + if let chatId = chatId, let chat = chatModel.getChat(chatId) { + ChatView(chat: chat).onAppear { + loadChat(chat: chat) + } + } + } + private func filteredChats() -> [Chat] { let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase return s == "" ? chatModel.chats - : chatModel.chats.filter { - $0.chatInfo.chatType != .contactConnection && - $0.chatInfo.chatViewName.localizedLowercase.contains(s) + : chatModel.chats.filter { chat in + let contains = chat.chatInfo.chatViewName.localizedLowercase.contains(s) + switch chat.chatInfo { + case let .direct(contact): + return contains + || contact.profile.displayName.localizedLowercase.contains(s) + || contact.fullName.localizedLowercase.contains(s) + case .contactConnection: return false + default: return contains + } } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index efda4c2341..dd2e5a1469 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct ChatPreviewView: View { + @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat @Environment(\.colorScheme) var colorScheme var darkGreen = Color(red: 0, green: 0.5, blue: 0) @@ -23,7 +24,6 @@ struct ChatPreviewView: View { .frame(width: 63, height: 63) chatPreviewImageOverlayIcon() .padding([.bottom, .trailing], 1) - } .padding(.leading, 4) @@ -36,7 +36,6 @@ struct ChatPreviewView: View { .frame(minWidth: 60, alignment: .trailing) .foregroundColor(.secondary) .padding(.top, 4) - } .padding(.top, 4) .padding(.horizontal, 8) @@ -90,7 +89,7 @@ struct ChatPreviewView: View { case .group(groupInfo: let groupInfo): switch (groupInfo.membership.memberStatus) { case .memInvited: - v.foregroundColor(.accentColor) + chat.chatInfo.incognito ? v.foregroundColor(.indigo) : v.foregroundColor(.accentColor) case .memAccepted: v.foregroundColor(.secondary) default: v @@ -108,13 +107,16 @@ struct ChatPreviewView: View { .padding(.trailing, 36) .padding(.bottom, 4) if unread > 0 { - Text(unread > 999 ? "\(unread / 1000)k" : "\(unread)") + unreadCountText(unread) .font(.caption) .foregroundColor(.white) .padding(.horizontal, 4) .frame(minWidth: 18, minHeight: 18) - .background(Color.accentColor) + .background(chat.chatInfo.ntfsEnabled ? Color.accentColor : Color.secondary) .cornerRadius(10) + } else if !chat.chatInfo.ntfsEnabled { + Image(systemName: "speaker.slash.fill") + .foregroundColor(.secondary) } } } else { @@ -125,7 +127,7 @@ struct ChatPreviewView: View { } case let .group(groupInfo): switch (groupInfo.membership.memberStatus) { - case .memInvited: chatPreviewInfoText("you are invited to group") + case .memInvited: groupInvitationPreviewText(groupInfo) case .memAccepted: chatPreviewInfoText("connecting…") default: EmptyView() } @@ -134,6 +136,15 @@ struct ChatPreviewView: View { } } + @ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View { + groupInfo.membership.memberIncognito + ? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)") + : (chatModel.incognito + ? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")") + : chatPreviewInfoText("you are invited to group") + ) + } + @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { Text(text) .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) @@ -170,6 +181,10 @@ struct ChatPreviewView: View { } } +func unreadCountText(_ n: Int) -> Text { + Text(n > 999 ? "\(n / 1000)k" : "\(n)") +} + struct ChatPreviewView_Previews: PreviewProvider { static var previews: some View { Group { diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 2edb1ad0ee..388666d2a5 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct ContactRequestView: View { + @EnvironmentObject var chatModel: ChatModel var contactRequest: UserContactRequest @ObservedObject var chat: Chat @@ -17,12 +18,13 @@ struct ContactRequestView: View { return HStack(spacing: 8) { ChatInfoImage(chat: chat) .frame(width: 63, height: 63) + .padding(.leading, 4) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top) { Text(contactRequest.chatViewName) .font(.title3) .fontWeight(.bold) - .foregroundColor(.blue) + .foregroundColor(chatModel.incognito ? .indigo : .accentColor) .padding(.leading, 8) .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) diff --git a/apps/ios/Shared/Views/Helpers/ContextMenu.swift b/apps/ios/Shared/Views/Helpers/ContextMenu.swift new file mode 100644 index 0000000000..e2342c274b --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ContextMenu.swift @@ -0,0 +1,88 @@ +// +// ContextMenu2.swift +// SimpleX (iOS) +// +// Created by Evgeny on 09/08/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UIKit +import SwiftUI + +extension View { + func uiKitContextMenu(title: String = "", actions: [UIAction]) -> some View { + self.overlay(Color(uiColor: .systemBackground)) + .overlay( + InteractionView(content: self, menu: UIMenu(title: title, children: actions)) + ) + } +} + +private struct InteractionConfig { + let content: Content + let menu: UIMenu +} + +private struct InteractionView: UIViewRepresentable { + let content: Content + let menu: UIMenu + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .clear + let hostView = UIHostingController(rootView: content) + hostView.view.translatesAutoresizingMaskIntoConstraints = false + let constraints = [ + hostView.view.topAnchor.constraint(equalTo: view.topAnchor), + hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostView.view.widthAnchor.constraint(equalTo: view.widthAnchor), + hostView.view.heightAnchor.constraint(equalTo: view.heightAnchor) + ] + view.addSubview(hostView.view) + view.addConstraints(constraints) + let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator) + view.addInteraction(menuInteraction) + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIContextMenuInteractionDelegate { + let parent: InteractionView + + init(_ parent: InteractionView) { + self.parent = parent + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration( + identifier: nil, + previewProvider: nil, + actionProvider: { [weak self] _ in + guard let self = self else { return nil } + return self.parent.menu + } + ) + } + + // func contextMenuInteraction( + // _ interaction: UIContextMenuInteraction, + // willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, + // animator: UIContextMenuInteractionCommitAnimating + // ) { + // animator.addCompletion { + // print("user tapped") + // } + // } + } +} diff --git a/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift b/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift index fb12292b6a..3dde57a427 100644 --- a/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift +++ b/apps/ios/Shared/Views/Helpers/NavLinkPlain.swift @@ -8,10 +8,9 @@ import SwiftUI -struct NavLinkPlain: View { +struct NavLinkPlain: View { @State var tag: V @Binding var selection: V? - @ViewBuilder var destination: () -> Destination @ViewBuilder var label: () -> Label var disabled = false @@ -21,10 +20,6 @@ struct NavLinkPlain: View { .disabled(disabled) label() } - .background { - NavigationLink("", tag: tag, selection: $selection, destination: destination) - .hidden() - } } } diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index a965d72dbb..3dd99ca9d3 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -10,6 +10,7 @@ import SwiftUI import CoreImage.CIFilterBuiltins struct AddContactView: View { + @EnvironmentObject var chatModel: ChatModel var connReqInvitation: String var body: some View { ScrollView { @@ -17,8 +18,23 @@ struct AddContactView: View { Text("One-time invitation link") .font(.title) .padding(.vertical) - Text("Your contact can scan it from the app") + Text("Your contact can scan it from the app.") + .padding(.bottom, 4) + if (chatModel.incognito) { + HStack { + Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote) + Spacer().frame(width: 8) + Text("A random profile will be sent to your contact").font(.footnote) + } .padding(.bottom) + } else { + HStack { + Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) + Spacer().frame(width: 8) + Text("Your chat profile will be sent to your contact").font(.footnote) + } + .padding(.bottom) + } QRCode(uri: connReqInvitation) .padding(.bottom) Text("If you can't meet in person, **show QR code in the video call**, or share the link.") diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index dab36fd998..ed4e82c0e0 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -24,10 +24,12 @@ struct AddGroupView: View { var body: some View { if let chat = chat, let groupInfo = groupInfo { - AddGroupMembersView(chat: chat, - groupInfo: groupInfo, - membersToAdd: filterMembersToAdd([]), - showSkip: true) { _ in + AddGroupMembersView( + chat: chat, + groupInfo: groupInfo, + showSkip: true, + showFooterCounter: false + ) { _ in dismiss() DispatchQueue.main.async { m.chatId = groupInfo.id @@ -45,7 +47,21 @@ struct AddGroupView: View { .padding(.vertical, 4) Text("The group is fully decentralized – it is visible only to the members.") .padding(.bottom, 4) + if (m.incognito) { + HStack { + Image(systemName: "info.circle").foregroundColor(.orange).font(.footnote) + Spacer().frame(width: 8) + Text("Incognito mode is not supported here - your main profile will be sent to group members").font(.footnote) + } .padding(.bottom) + } else { + HStack { + Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) + Spacer().frame(width: 8) + Text("Your chat profile will be sent to group members").font(.footnote) + } + .padding(.bottom) + } ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { @@ -148,6 +164,12 @@ struct AddGroupView: View { hideKeyboard() do { let gInfo = try apiNewGroup(profile) + Task { + let groupMembers = await apiListMembers(gInfo.groupId) + await MainActor.run { + ChatModel.shared.groupMembers = groupMembers + } + } let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) m.addChat(c) withAnimation { diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index 7bd64a90b5..05e36f63e8 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -9,6 +9,7 @@ import SwiftUI struct PasteToConnectView: View { + @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @State private var connectionLink: String = "" @@ -18,8 +19,22 @@ struct PasteToConnectView: View { .font(.title) .padding(.vertical) Text("Paste the link you received into the box below to connect with your contact.") - Text("Your profile will be sent to the contact that you received this link from") + .padding(.bottom, 4) + if (chatModel.incognito) { + HStack { + Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote) + Spacer().frame(width: 8) + Text("A random profile will be sent to the contact that you received this link from").font(.footnote) + } .padding(.bottom) + } else { + HStack { + Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) + Spacer().frame(width: 8) + Text("Your profile will be sent to the contact that you received this link from").font(.footnote) + } + .padding(.bottom) + } TextEditor(text: $connectionLink) .onSubmit(connect) .textInputAutocapitalization(.never) @@ -55,7 +70,7 @@ struct PasteToConnectView: View { .frame(height: 48) .padding(.bottom) - Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button") + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") } .padding() .frame(maxHeight: .infinity, alignment: .top) diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 49bce632b2..a70c74c095 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -10,6 +10,7 @@ import SwiftUI import CodeScanner struct ScanToConnectView: View { + @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction var body: some View { @@ -17,8 +18,21 @@ struct ScanToConnectView: View { Text("Scan QR code") .font(.title) .padding(.vertical) - Text("Your chat profile will be sent to your contact") + if (chatModel.incognito) { + HStack { + Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote) + Spacer().frame(width: 8) + Text("A random profile will be sent to your contact").font(.footnote) + } .padding(.bottom) + } else { + HStack { + Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) + Spacer().frame(width: 8) + Text("Your chat profile will be sent to your contact").font(.footnote) + } + .padding(.bottom) + } ZStack { CodeScannerView(codeTypes: [.qr], completion: processQRCode) .aspectRatio(1, contentMode: .fit) diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 1c0558b86b..aaa7d95268 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -8,9 +8,18 @@ import SwiftUI +let defaultAccentColor = CGColor.init(red: 0, green: 0.533, blue: 1, alpha: 1) + +let interfaceStyles: [UIUserInterfaceStyle] = [.unspecified, .light, .dark] + +let interfaceStyleNames: [LocalizedStringKey] = ["System", "Light", "Dark"] + struct AppearanceSettings: View { + @EnvironmentObject var sceneDelegate: SceneDelegate @State private var iconLightTapped = false @State private var iconDarkTapped = false + @State private var userInterfaceStyle = getUserInterfaceStyleDefault() + @State private var uiTintColor = getUIAccentColorDefault() var body: some View { VStack{ @@ -22,6 +31,32 @@ struct AppearanceSettings: View { updateAppIcon(image: "icon-dark", icon: "DarkAppIcon", tapped: $iconDarkTapped) } } + + Section { + Picker("Theme", selection: $userInterfaceStyle) { + ForEach(interfaceStyles, id: \.self) { style in + Text(interfaceStyleNames[interfaceStyles.firstIndex(of: style) ?? 0]) + } + } + ColorPicker("Accent color", selection: $uiTintColor, supportsOpacity: false) + } header: { + Text("Colors") + } footer: { + Button { + uiTintColor = defaultAccentColor + setUIAccentColorDefault(defaultAccentColor) + } label: { + Text("Reset colors").font(.callout) + } + } + .onChange(of: userInterfaceStyle) { _ in + sceneDelegate.window?.overrideUserInterfaceStyle = userInterfaceStyle + setUserInterfaceStyleDefault(userInterfaceStyle) + } + .onChange(of: uiTintColor) { _ in + sceneDelegate.window?.tintColor = UIColor(cgColor: uiTintColor) + setUIAccentColorDefault(uiTintColor) + } } } } @@ -44,6 +79,44 @@ struct AppearanceSettings: View { } } +func getUIAccentColorDefault() -> CGColor { + let defs = UserDefaults.standard + return CGColor( + red: defs.double(forKey: DEFAULT_ACCENT_COLOR_RED), + green: defs.double(forKey: DEFAULT_ACCENT_COLOR_GREEN), + blue: defs.double(forKey: DEFAULT_ACCENT_COLOR_BLUE), + alpha: 1 + ) +} + +func setUIAccentColorDefault(_ color: CGColor) { + if let cs = color.components { + let defs = UserDefaults.standard + defs.set(cs[0], forKey: DEFAULT_ACCENT_COLOR_RED) + defs.set(cs[1], forKey: DEFAULT_ACCENT_COLOR_GREEN) + defs.set(cs[2], forKey: DEFAULT_ACCENT_COLOR_BLUE) + } +} + +func getUserInterfaceStyleDefault() -> UIUserInterfaceStyle { + switch UserDefaults.standard.integer(forKey: DEFAULT_USER_INTERFACE_STYLE) { + case 1: return .light + case 2: return .dark + default: return .unspecified + } +} + +func setUserInterfaceStyleDefault(_ style: UIUserInterfaceStyle) { + var v: Int + switch style { + case .unspecified: v = 0 + case .light: v = 1 + case .dark: v = 2 + default: v = 0 + } + UserDefaults.standard.set(v, forKey: DEFAULT_USER_INTERFACE_STYLE) +} + struct AppearanceSettings_Previews: PreviewProvider { static var previews: some View { AppearanceSettings() diff --git a/apps/ios/Shared/Views/UserSettings/CallSettings.swift b/apps/ios/Shared/Views/UserSettings/CallSettings.swift index 7bc79dd0b3..2f7be0eebf 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -14,8 +14,16 @@ struct CallSettings: View { var body: some View { VStack { List { - Section("Settings") { + Section { Toggle("Connect via relay", isOn: $webrtcPolicyRelay) + } header: { + Text("Settings") + } footer: { + if webrtcPolicyRelay { + Text("Relay server protects your IP address, but it can observe the duration of the call.") + } else { + Text("Relay server is only used if necessary. Another party can observe your IP address.") + } } Section("Limitations") { @@ -31,12 +39,12 @@ struct CallSettings: View { } } } +} - private func textListItem(_ n: String, _ text: LocalizedStringKey) -> some View { - ZStack(alignment: .topLeading) { - Text(n) - Text(text).frame(maxWidth: .infinity, alignment: .leading).padding(.leading, 20) - } +func textListItem(_ n: String, _ text: LocalizedStringKey) -> some View { + ZStack(alignment: .topLeading) { + Text(n) + Text(text).frame(maxWidth: .infinity, alignment: .leading).padding(.leading, 20) } } diff --git a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift new file mode 100644 index 0000000000..92f0f8c201 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift @@ -0,0 +1,38 @@ +// +// IncognitoHelp.swift +// SimpleX (iOS) +// +// Created by JRoberts on 22.08.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct IncognitoHelp: View { + var body: some View { + VStack(alignment: .leading) { + Text("Incognito mode") + .font(.largeTitle) + .padding(.vertical) + ScrollView { + VStack(alignment: .leading) { + Group { + Text("Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.") + Text("It allows having many anonymous connections without any shared data between them in a single chat profile.") + Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.") + Text("To find the profile used for an incognito connection, tap the contact or group name on top of the chat.") + } + .padding(.bottom) + } + } + } + .frame(maxWidth: .infinity) + .padding() + } +} + +struct IncognitoHelp_Previews: PreviewProvider { + static var previews: some View { + IncognitoHelp() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift index 8969dd95f4..10ffde6a1e 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift @@ -7,14 +7,32 @@ // import SwiftUI +import SimpleXChat + +enum OnionHostsAlert: Identifiable { + case update(hosts: OnionHosts) + case error(err: String) + + var id: String { + switch self { + case let .update(hosts): return "update \(hosts)" + case let .error(err): return "error \(err)" + } + } +} struct NetworkAndServers: View { @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @State private var cfgLoaded = false + @State private var currentNetCfg = NetCfg.defaults + @State private var netCfg = NetCfg.defaults + @State private var onionHosts: OnionHosts = .no + @State private var showOnionHostsAlert: OnionHostsAlert? var body: some View { VStack { List { - Section("") { + Section { NavigationLink { SMPServers() .navigationTitle("Your SMP servers") @@ -22,6 +40,10 @@ struct NetworkAndServers: View { settingsRow("server.rack") { Text("SMP servers") } } + Picker("Use .onion hosts", selection: $onionHosts) { + ForEach(OnionHosts.values, id: \.self) { Text($0.text) } + } + if developerTools { NavigationLink { AdvancedNetworkSettings() @@ -30,9 +52,76 @@ struct NetworkAndServers: View { settingsRow("app.connected.to.app.below.fill") { Text("Advanced network settings") } } } + } header: { + Text("") + } footer: { + Text("Using .onion hosts requires compatible VPN provider.") } } } + .onAppear { + if cfgLoaded { return } + cfgLoaded = true + currentNetCfg = getNetCfg() + resetNetCfgView() + } + .onChange(of: onionHosts) { _ in + if onionHosts != OnionHosts(netCfg: currentNetCfg) { + showOnionHostsAlert = .update(hosts: onionHosts) + } + } + .alert(item: $showOnionHostsAlert) { a in + switch a { + case let .update(hosts): + return Alert( + title: Text("Update .onion hosts setting?"), + message: Text(onionHostsInfo()) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), + primaryButton: .default(Text("Ok")) { + saveNetCfg(hosts) + }, + secondaryButton: .cancel() { + resetNetCfgView() + } + ) + case let .error(err): + return Alert( + title: Text("Error updating settings"), + message: Text(err) + ) + } + } + } + + private func saveNetCfg(_ hosts: OnionHosts) { + do { + let (hostMode, requiredHostMode) = hosts.hostMode + netCfg.hostMode = hostMode + netCfg.requiredHostMode = requiredHostMode + let def = netCfg.hostMode == .onionHost ? NetCfg.proxyDefaults : NetCfg.defaults + netCfg.tcpConnectTimeout = def.tcpConnectTimeout + netCfg.tcpTimeout = def.tcpTimeout + try setNetworkConfig(netCfg) + currentNetCfg = netCfg + setNetCfg(netCfg) + } catch let error { + let err = responseError(error) + resetNetCfgView() + showOnionHostsAlert = .error(err: err) + logger.error("\(err)") + } + } + + private func resetNetCfgView() { + netCfg = currentNetCfg + onionHosts = OnionHosts(netCfg: netCfg) + } + + private func onionHostsInfo() -> LocalizedStringKey { + switch onionHosts { + case .no: return "Onion hosts will not be used." + case .prefer: return "Onion hosts will be used when available. Requires enabling VPN." + case .require: return "Onion hosts will be required for connection. Requires enabling VPN." + } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index dd2a592c4a..3a2fe30ae5 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -26,6 +26,10 @@ let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName" let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime" let DEFAULT_CHAT_V3_DB_MIGRATION = "chatV3DBMigration" let DEFAULT_DEVELOPER_TOOLS = "developerTools" +let DEFAULT_ACCENT_COLOR_RED = "accentColorRed" +let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" +let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" +let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -36,7 +40,11 @@ let appDefaults: [String: Any] = [ DEFAULT_PRIVACY_LINK_PREVIEWS: true, DEFAULT_EXPERIMENTAL_CALLS: false, DEFAULT_CHAT_V3_DB_MIGRATION: "offer", - DEFAULT_DEVELOPER_TOOLS: false + DEFAULT_DEVELOPER_TOOLS: false, + DEFAULT_ACCENT_COLOR_RED: 0.000, + DEFAULT_ACCENT_COLOR_GREEN: 0.533, + DEFAULT_ACCENT_COLOR_BLUE: 1.000, + DEFAULT_USER_INTERFACE_STYLE: 0 ] private var indent: CGFloat = 36 @@ -52,6 +60,7 @@ struct SettingsView: View { @EnvironmentObject var chatModel: ChatModel @Binding var showSettings: Bool @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @State private var settingsSheet: SettingsSheet? var body: some View { let user: User = chatModel.currentUser! @@ -68,6 +77,9 @@ struct SettingsView: View { } .disabled(chatModel.chatRunning != true) + incognitoRow() + .disabled(chatModel.chatRunning != true) + NavigationLink { UserAddress() .navigationTitle("Your chat address") @@ -173,7 +185,6 @@ struct SettingsView: View { } label: { settingsRow("terminal") { Text("Chat console") } } - .disabled(chatModel.chatRunning != true) settingsRow("gear") { Toggle("Developer tools", isOn: $developerTools) } @@ -196,9 +207,49 @@ struct SettingsView: View { } .navigationTitle("Your settings") } + .sheet(item: $settingsSheet) { sheet in + switch sheet { + case .incognitoInfo: IncognitoHelp() + } + } } - enum NotificationAlert { + @ViewBuilder private func incognitoRow() -> some View { + ZStack(alignment: .leading) { + Image(systemName: chatModel.incognito ? "theatermasks.fill" : "theatermasks") + .frame(maxWidth: 24, maxHeight: 24, alignment: .center) + .foregroundColor(chatModel.incognito ? Color.indigo : .secondary) + Toggle(isOn: $chatModel.incognito) { + HStack { + Text("Incognito") + Spacer().frame(width: 4) + Image(systemName: "info.circle") + .foregroundColor(.accentColor) + .font(.system(size: 14)) + } + .onTapGesture { + settingsSheet = .incognitoInfo + } + } + .onChange(of: chatModel.incognito) { incognito in + incognitoGroupDefault.set(incognito) + do { + try apiSetIncognito(incognito: incognito) + } catch { + logger.error("apiSetIncognito: cannot set incognito \(responseError(error))") + } + } + .padding(.leading, indent) + } + } + + private enum SettingsSheet: Identifiable { + case incognitoInfo + + var id: SettingsSheet { get { self } } + } + + private enum NotificationAlert { case enable case error(LocalizedStringKey, String) } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 05d62cb5ec..05951e5d31 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -77,7 +77,7 @@ struct UserProfile: View { profileNameView("Display name:", user.profile.displayName) profileNameView("Full name:", user.profile.fullName) Button("Edit") { - profile = user.profile + profile = fromLocalProfile(user.profile) editProfile = true } } @@ -131,7 +131,7 @@ struct UserProfile: View { } func startEditingImage(_ user: User) { - profile = user.profile + profile = fromLocalProfile(user.profile) editProfile = true showChooseSource = true } @@ -141,7 +141,9 @@ struct UserProfile: View { do { if let newProfile = try await apiUpdateProfile(profile: profile) { DispatchQueue.main.async { - chatModel.currentUser?.profile = newProfile + if let profileId = chatModel.currentUser?.profile.profileId { + chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "") + } profile = newProfile } } diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 2756d8d15b..3dba7fc0b4 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2,9 +2,16 @@
- +
+ + + + + + No comment provided by engineer. + @@ -165,6 +172,16 @@ A new contact notification title + + A random profile will be sent to the contact that you received this link from + A random profile will be sent to the contact that you received this link from + No comment provided by engineer. + + + A random profile will be sent to your contact + A random profile will be sent to your contact + No comment provided by engineer. + About SimpleX About SimpleX @@ -175,6 +192,11 @@ About SimpleX Chat No comment provided by engineer. + + Accent color + Accent color + No comment provided by engineer. + Accept Accept @@ -191,6 +213,11 @@ Accept contact request from %@? notification body + + Accept incognito + Accept incognito + No comment provided by engineer. + Add contact to start a new chat Add contact to start a new chat @@ -261,6 +288,16 @@ Call already ended! No comment provided by engineer. + + Can't invite contact! + Can't invite contact! + No comment provided by engineer. + + + Can't invite contacts! + Can't invite contacts! + No comment provided by engineer. + Cancel Cancel @@ -336,6 +373,11 @@ Clear conversation? No comment provided by engineer. + + Colors + Colors + No comment provided by engineer. + Configure SMP servers Configure SMP servers @@ -449,7 +491,7 @@ Copy Copy - No comment provided by engineer. + chat item action Create @@ -491,6 +533,11 @@ Currently maximum supported file size is %@. No comment provided by engineer. + + Dark + Dark + No comment provided by engineer. + Database ID Database ID @@ -514,7 +561,7 @@ Delete Delete - No comment provided by engineer. + chat item action Delete Contact @@ -659,7 +706,7 @@ Edit Edit - No comment provided by engineer. + chat item action Edit group profile @@ -701,6 +748,11 @@ Error accessing database file No comment provided by engineer. + + Error adding member(s) + Error adding member(s) + No comment provided by engineer. + Error creating group Error creating group @@ -961,6 +1013,26 @@ In person or via a video call – the most secure way to connect. No comment provided by engineer. + + Incognito + Incognito + No comment provided by engineer. + + + Incognito mode + Incognito mode + No comment provided by engineer. + + + Incognito mode is not supported here - your main profile will be sent to group members + Incognito mode is not supported here - your main profile will be sent to group members + No comment provided by engineer. + + + Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created. + Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created. + No comment provided by engineer. + Incoming audio call Incoming audio call @@ -1006,6 +1078,11 @@ Invite to group No comment provided by engineer. + + It allows having many anonymous connections without any shared data between them in a single chat profile. + It allows having many anonymous connections without any shared data between them in a single chat profile. + No comment provided by engineer. + It can happen when: 1. The messages expire on the server if they were not received for 30 days, @@ -1041,6 +1118,11 @@ We will be adding server redundancy to prevent lost messages. Join group No comment provided by engineer. + + Join incognito + Join incognito + No comment provided by engineer. + Joining group Joining group @@ -1066,6 +1148,11 @@ We will be adding server redundancy to prevent lost messages. Leave group? No comment provided by engineer. + + Light + Light + No comment provided by engineer. + Limitations Limitations @@ -1091,6 +1178,11 @@ We will be adding server redundancy to prevent lost messages. Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?* No comment provided by engineer. + + Mark read + Mark read + No comment provided by engineer. + Markdown in messages Markdown in messages @@ -1141,6 +1233,11 @@ We will be adding server redundancy to prevent lost messages. Most likely this contact has deleted the connection with you. No comment provided by engineer. + + Mute + Mute + No comment provided by engineer. + Network & servers Network & servers @@ -1181,6 +1278,11 @@ We will be adding server redundancy to prevent lost messages. New message notification + + No + No + No comment provided by engineer. + No contacts selected No contacts selected @@ -1236,6 +1338,21 @@ We will be adding server redundancy to prevent lost messages. One-time invitation link No comment provided by engineer. + + Onion hosts will be required for connection. Requires enabling VPN. + Onion hosts will be required for connection. Requires enabling VPN. + No comment provided by engineer. + + + Onion hosts will be used when available. Requires enabling VPN. + Onion hosts will be used when available. Requires enabling VPN. + No comment provided by engineer. + + + Onion hosts will not be used. + Onion hosts will not be used. + No comment provided by engineer. + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. @@ -1376,6 +1493,16 @@ We will be adding server redundancy to prevent lost messages. Reject contact request No comment provided by engineer. + + Relay server is only used if necessary. Another party can observe your IP address. + Relay server is only used if necessary. Another party can observe your IP address. + No comment provided by engineer. + + + Relay server protects your IP address, but it can observe the duration of the call. + Relay server protects your IP address, but it can observe the duration of the call. + No comment provided by engineer. + Remove Remove @@ -1394,6 +1521,16 @@ We will be adding server redundancy to prevent lost messages. Reply Reply + chat item action + + + Required + Required + No comment provided by engineer. + + + Reset colors + Reset colors No comment provided by engineer. @@ -1434,7 +1571,7 @@ We will be adding server redundancy to prevent lost messages. Save Save - No comment provided by engineer. + chat item action Save (and notify contacts) @@ -1466,6 +1603,16 @@ We will be adding server redundancy to prevent lost messages. Scan contact's QR code No comment provided by engineer. + + Search + Search + No comment provided by engineer. + + + Send direct message + Send direct message + No comment provided by engineer. + Send link previews Send link previews @@ -1496,6 +1643,11 @@ We will be adding server redundancy to prevent lost messages. Servers No comment provided by engineer. + + Set contact name… + Set contact name… + No comment provided by engineer. + Set timeouts for proxy/VPN Set timeouts for proxy/VPN @@ -1509,7 +1661,7 @@ We will be adding server redundancy to prevent lost messages. Share Share - No comment provided by engineer. + chat item action Share invitation link @@ -1586,6 +1738,11 @@ We will be adding server redundancy to prevent lost messages. Stop chat? No comment provided by engineer. + + System + System + No comment provided by engineer. + TCP connection timeout TCP connection timeout @@ -1621,6 +1778,11 @@ We will be adding server redundancy to prevent lost messages. Tap to join No comment provided by engineer. + + Tap to join incognito + Tap to join incognito + No comment provided by engineer. + Thank you for installing SimpleX Chat! Thank you for installing SimpleX Chat! @@ -1681,6 +1843,11 @@ We will be adding server redundancy to prevent lost messages. The sender will NOT be notified No comment provided by engineer. + + Theme + Theme + No comment provided by engineer. + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. @@ -1701,6 +1868,11 @@ We will be adding server redundancy to prevent lost messages. To ask any questions and to receive updates: No comment provided by engineer. + + To find the profile used for an incognito connection, tap the contact or group name on top of the chat. + To find the profile used for an incognito connection, tap the contact or group name on top of the chat. + No comment provided by engineer. + To make a new connection To make a new connection @@ -1780,6 +1952,16 @@ To connect, please ask your contact to create another connection link and check Unlock authentication reason + + Unmute + Unmute + No comment provided by engineer. + + + Update .onion hosts setting? + Update .onion hosts setting? + No comment provided by engineer. + Update network settings? Update network settings? @@ -1790,6 +1972,16 @@ To connect, please ask your contact to create another connection link and check Updating settings will re-connect the client to all servers. No comment provided by engineer. + + Updating this setting will re-connect the client to all servers. + Updating this setting will re-connect the client to all servers. + No comment provided by engineer. + + + Use .onion hosts + Use .onion hosts + No comment provided by engineer. + Use SimpleX Chat servers? Use SimpleX Chat servers? @@ -1800,11 +1992,21 @@ To connect, please ask your contact to create another connection link and check Use chat No comment provided by engineer. + + Using .onion hosts requires compatible VPN provider. + Using .onion hosts requires compatible VPN provider. + No comment provided by engineer. + Using SimpleX Chat servers. Using SimpleX Chat servers. No comment provided by engineer. + + Video call + Video call + No comment provided by engineer. + Waiting for file Waiting for file @@ -1820,6 +2022,16 @@ To connect, please ask your contact to create another connection link and check Welcome %@! No comment provided by engineer. + + When available + When available + No comment provided by engineer. + + + When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. + When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. + No comment provided by engineer. + You You @@ -1845,9 +2057,9 @@ To connect, please ask your contact to create another connection link and check You are invited to group No comment provided by engineer. - - You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button - You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button + + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button. + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button. No comment provided by engineer. @@ -1935,6 +2147,16 @@ To connect, please ask your contact to create another connection link and check You will stop receiving messages from this group. Chat history will be preserved. No comment provided by engineer. + + You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile + You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile + No comment provided by engineer. + + + You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed + You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed + No comment provided by engineer. + Your SMP servers Your SMP servers @@ -1965,6 +2187,11 @@ To connect, please ask your contact to create another connection link and check Your chat profile No comment provided by engineer. + + Your chat profile will be sent to group members + Your chat profile will be sent to group members + No comment provided by engineer. + Your chat profile will be sent to your contact Your chat profile will be sent to your contact @@ -1975,9 +2202,9 @@ To connect, please ask your contact to create another connection link and check Your chats No comment provided by engineer. - - Your contact can scan it from the app - Your contact can scan it from the app + + Your contact can scan it from the app. + Your contact can scan it from the app. No comment provided by engineer. @@ -2019,6 +2246,11 @@ SimpleX servers cannot see your profile. Your profile, contacts and delivered messages are stored on your device. No comment provided by engineer. + + Your random profile + Your random profile + No comment provided by engineer. + Your settings Your settings @@ -2219,6 +2451,16 @@ SimpleX servers cannot see your profile. group profile updated snd group event chat item + + incognito via contact address link + incognito via contact address link + chat list item description + + + incognito via one-time link + incognito via one-time link + chat list item description + indirect (%d) indirect (%d) @@ -2249,6 +2491,11 @@ SimpleX servers cannot see your profile. italic No comment provided by engineer. + + join as %@ + join as %@ + No comment provided by engineer. + left left @@ -2424,6 +2671,11 @@ SimpleX servers cannot see your profile. you shared one-time link chat list item description + + you shared one-time link incognito + you shared one-time link incognito + chat list item description + you: you: @@ -2438,7 +2690,7 @@ SimpleX servers cannot see your profile.
- +
@@ -2470,7 +2722,7 @@ SimpleX servers cannot see your profile.
- +
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/contents.json b/apps/ios/SimpleX Localizations/en.xcloc/contents.json index 3e0830ec05..4429d3639d 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/en.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "en", "toolInfo" : { - "toolBuildNumber" : "13E113", + "toolBuildNumber" : "13F100", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "13.3" + "toolVersion" : "13.4.1" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 474183cc26..d0ddbd1bed 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2,9 +2,16 @@
- +
+ + + + + + No comment provided by engineer. + @@ -165,6 +172,16 @@ Новый контакт notification title + + A random profile will be sent to the contact that you received this link from + Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль + No comment provided by engineer. + + + A random profile will be sent to your contact + Вашему контакту будет отправлен случайный профиль + No comment provided by engineer. + About SimpleX О SimpleX @@ -175,6 +192,11 @@ Информация о SimpleX Chat No comment provided by engineer. + + Accent color + Основной цвет + No comment provided by engineer. + Accept Принять @@ -191,6 +213,11 @@ Принять запрос на соединение от %@? notification body + + Accept incognito + Принять инкогнито + No comment provided by engineer. + Add contact to start a new chat Добавьте контакт, чтобы начать разговор @@ -261,6 +288,16 @@ Звонок уже завершен! No comment provided by engineer. + + Can't invite contact! + Нельзя пригласить контакт! + No comment provided by engineer. + + + Can't invite contacts! + Нельзя пригласить контакты! + No comment provided by engineer. + Cancel Отменить @@ -336,6 +373,11 @@ Очистить разговор? No comment provided by engineer. + + Colors + Цвета + No comment provided by engineer. + Configure SMP servers Настройка SMP серверов @@ -449,7 +491,7 @@ Copy Скопировать - No comment provided by engineer. + chat item action Create @@ -491,6 +533,11 @@ Максимальный размер файла - %@. No comment provided by engineer. + + Dark + Тёмная + No comment provided by engineer. + Database ID ID базы данных @@ -514,7 +561,7 @@ Delete Удалить - No comment provided by engineer. + chat item action Delete Contact @@ -659,7 +706,7 @@ Edit Редактировать - No comment provided by engineer. + chat item action Edit group profile @@ -701,6 +748,11 @@ Ошибка при доступе к данным чата No comment provided by engineer. + + Error adding member(s) + Ошибка при добавлении членов группы + No comment provided by engineer. + Error creating group Ошибка при создании группы @@ -961,6 +1013,26 @@ При встрече или в видеозвонке – самый безопасный способ установить соединение No comment provided by engineer. + + Incognito + Инкогнито + No comment provided by engineer. + + + Incognito mode + Режим Инкогнито + No comment provided by engineer. + + + Incognito mode is not supported here - your main profile will be sent to group members + Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы + No comment provided by engineer. + + + Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created. + Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль. + No comment provided by engineer. + Incoming audio call Входящий аудиозвонок @@ -1006,6 +1078,11 @@ Пригласить в группу No comment provided by engineer. + + It allows having many anonymous connections without any shared data between them in a single chat profile. + Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя. + No comment provided by engineer. + It can happen when: 1. The messages expire on the server if they were not received for 30 days, @@ -1041,6 +1118,11 @@ We will be adding server redundancy to prevent lost messages. Вступить в группу No comment provided by engineer. + + Join incognito + Вступить инкогнито + No comment provided by engineer. + Joining group Вступление в группу @@ -1066,6 +1148,11 @@ We will be adding server redundancy to prevent lost messages. Выйти из группы? No comment provided by engineer. + + Light + Светлая + No comment provided by engineer. + Limitations Ограничения @@ -1091,6 +1178,11 @@ We will be adding server redundancy to prevent lost messages. Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?* No comment provided by engineer. + + Mark read + Прочитано + No comment provided by engineer. + Markdown in messages Форматирование сообщений @@ -1141,6 +1233,11 @@ We will be adding server redundancy to prevent lost messages. Скорее всего, этот контакт удалил соединение с вами. No comment provided by engineer. + + Mute + Без звука + No comment provided by engineer. + Network & servers Сеть & серверы @@ -1181,6 +1278,11 @@ We will be adding server redundancy to prevent lost messages. Новое сообщение notification + + No + Нет + No comment provided by engineer. + No contacts selected Контакты не выбраны @@ -1236,6 +1338,21 @@ We will be adding server redundancy to prevent lost messages. Одноразовая ссылка No comment provided by engineer. + + Onion hosts will be required for connection. Requires enabling VPN. + Подключаться только к onion хостам. Требуется включенный VPN. + No comment provided by engineer. + + + Onion hosts will be used when available. Requires enabling VPN. + Onion хосты используются, если возможно. Требуется включенный VPN. + No comment provided by engineer. + + + Onion hosts will not be used. + Onion хосты не используются. + No comment provided by engineer. + Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**. Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием** @@ -1376,6 +1493,16 @@ We will be adding server redundancy to prevent lost messages. Отклонить запрос No comment provided by engineer. + + Relay server is only used if necessary. Another party can observe your IP address. + Relay сервер используется только при необходимости. Другая сторона может видеть ваш IP адрес. + No comment provided by engineer. + + + Relay server protects your IP address, but it can observe the duration of the call. + Relay сервер защищает ваш IP адрес, но может отслеживать продолжительность звонка. + No comment provided by engineer. + Remove Удалить @@ -1394,6 +1521,16 @@ We will be adding server redundancy to prevent lost messages. Reply Ответить + chat item action + + + Required + Обязательно + No comment provided by engineer. + + + Reset colors + Сбросить цвета No comment provided by engineer. @@ -1434,7 +1571,7 @@ We will be adding server redundancy to prevent lost messages. Save Сохранить - No comment provided by engineer. + chat item action Save (and notify contacts) @@ -1466,6 +1603,16 @@ We will be adding server redundancy to prevent lost messages. Сосканировать QR код контакта No comment provided by engineer. + + Search + Поиск + No comment provided by engineer. + + + Send direct message + Отправить сообщение + No comment provided by engineer. + Send link previews Отправлять картинки ссылок @@ -1496,6 +1643,11 @@ We will be adding server redundancy to prevent lost messages. Серверы No comment provided by engineer. + + Set contact name… + Имя контакта… + No comment provided by engineer. + Set timeouts for proxy/VPN Установить таймауты для прокси/VPN @@ -1509,7 +1661,7 @@ We will be adding server redundancy to prevent lost messages. Share Поделиться - No comment provided by engineer. + chat item action Share invitation link @@ -1586,6 +1738,11 @@ We will be adding server redundancy to prevent lost messages. Остановить чат? No comment provided by engineer. + + System + Системная + No comment provided by engineer. + TCP connection timeout Таймаут TCP соединения @@ -1621,6 +1778,11 @@ We will be adding server redundancy to prevent lost messages. Нажмите, чтобы вступить No comment provided by engineer. + + Tap to join incognito + Нажмите, чтобы вступить инкогнито + No comment provided by engineer. + Thank you for installing SimpleX Chat! Спасибо, что установили SimpleX Chat! @@ -1681,6 +1843,11 @@ We will be adding server redundancy to prevent lost messages. Отправитель не будет уведомлён No comment provided by engineer. + + Theme + Тема + No comment provided by engineer. + This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost. Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны. @@ -1701,6 +1868,11 @@ We will be adding server redundancy to prevent lost messages. Чтобы задать вопросы и получать уведомления о новых версиях, No comment provided by engineer. + + To find the profile used for an incognito connection, tap the contact or group name on top of the chat. + Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата. + No comment provided by engineer. + To make a new connection Чтобы соединиться @@ -1780,6 +1952,16 @@ To connect, please ask your contact to create another connection link and check Разблокировать authentication reason + + Unmute + Уведомлять + No comment provided by engineer. + + + Update .onion hosts setting? + Обновить настройки .onion хостов? + No comment provided by engineer. + Update network settings? Обновить настройки сети? @@ -1787,7 +1969,17 @@ To connect, please ask your contact to create another connection link and check Updating settings will re-connect the client to all servers. - Обновление настроек приведет к переподключению клиента ко всем серверам. + Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами. + No comment provided by engineer. + + + Updating this setting will re-connect the client to all servers. + Обновление этих настроек приведет к сбросу и установке нового соединения со всеми серверами. + No comment provided by engineer. + + + Use .onion hosts + Использовать .onion хосты No comment provided by engineer. @@ -1800,11 +1992,21 @@ To connect, please ask your contact to create another connection link and check Использовать чат No comment provided by engineer. + + Using .onion hosts requires compatible VPN provider. + Для использования .onion хостов требуется совместимый VPN провайдер. + No comment provided by engineer. + Using SimpleX Chat servers. Используются серверы, предоставленные SimpleX Chat. No comment provided by engineer. + + Video call + Видеозвонок + No comment provided by engineer. + Waiting for file Ожидается прием файла @@ -1820,6 +2022,16 @@ To connect, please ask your contact to create another connection link and check Здравствуйте %@! No comment provided by engineer. + + When available + Когда возможно + No comment provided by engineer. + + + When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. + Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом. + No comment provided by engineer. + You Вы @@ -1845,8 +2057,8 @@ To connect, please ask your contact to create another connection link and check Вы приглашены в группу No comment provided by engineer. - - You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button + + You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button. Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**. No comment provided by engineer. @@ -1935,6 +2147,16 @@ To connect, please ask your contact to create another connection link and check Вы перестанете получать сообщения от этой группы. История чата будет сохранена. No comment provided by engineer. + + You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile + Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль + No comment provided by engineer. + + + You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed + Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено + No comment provided by engineer. + Your SMP servers Ваши SMP серверы @@ -1965,6 +2187,11 @@ To connect, please ask your contact to create another connection link and check Ваш профиль No comment provided by engineer. + + Your chat profile will be sent to group members + Ваш профиль чата будет отправлен членам группы + No comment provided by engineer. + Your chat profile will be sent to your contact Ваш профиль будет отправлен вашему контакту @@ -1975,9 +2202,9 @@ To connect, please ask your contact to create another connection link and check Ваши чаты No comment provided by engineer. - - Your contact can scan it from the app - Ваш контакт может сосканировать QR в приложении + + Your contact can scan it from the app. + Ваш контакт может сосканировать QR код в приложении No comment provided by engineer. @@ -2019,6 +2246,11 @@ SimpleX серверы не могут получить доступ к ваше Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве. No comment provided by engineer. + + Your random profile + Ваш случайный профиль + No comment provided by engineer. + Your settings Настройки @@ -2219,6 +2451,16 @@ SimpleX серверы не могут получить доступ к ваше профиль группы обновлен snd group event chat item + + incognito via contact address link + инкогнито через ссылку-контакт + chat list item description + + + incognito via one-time link + инкогнито через одноразовую ссылку + chat list item description + indirect (%d) непрямое (%d) @@ -2249,6 +2491,11 @@ SimpleX серверы не могут получить доступ к ваше курсив No comment provided by engineer. + + join as %@ + вступить как %@ + No comment provided by engineer. + left покинул(а) группу @@ -2424,6 +2671,11 @@ SimpleX серверы не могут получить доступ к ваше вы создали ссылку chat list item description + + you shared one-time link incognito + вы создали ссылку инкогнито + chat list item description + you: вы: @@ -2438,7 +2690,7 @@ SimpleX серверы не могут получить доступ к ваше
- +
@@ -2470,7 +2722,7 @@ SimpleX серверы не могут получить доступ к ваше
- +
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json index 50ec87db4c..ab6a47f150 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "ru", "toolInfo" : { - "toolBuildNumber" : "13E113", + "toolBuildNumber" : "13F100", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "13.3" + "toolVersion" : "13.4.1" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index be039a65e5..969645e45f 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -160,6 +160,7 @@ func startChat() -> User? { let justStarted = try apiStartChat() if justStarted { try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + try apiSetIncognito(incognito: incognitoGroupDefault.get()) chatLastStartGroupDefault.set(Date.now) Task { await receiveMessages() } } @@ -248,6 +249,12 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } +func apiSetIncognito(incognito: Bool) throws { + let r = sendSimpleXCmd(.setIncognito(incognito: incognito)) + if case .cmdOk = r { return } + throw r +} + func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 254da99fd9..060d05d5f9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -12,6 +12,12 @@ 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; + 5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00164328A26FBC0094D739 /* ContextMenu.swift */; }; + 5C00166A28C119300094D739 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166528C119300094D739 /* libgmp.a */; }; + 5C00166B28C119300094D739 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166628C119300094D739 /* libffi.a */; }; + 5C00166C28C119300094D739 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166728C119300094D739 /* libgmpxx.a */; }; + 5C00166D28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */; }; + 5C00166E28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */; }; 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; }; 5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; }; 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; }; @@ -52,11 +58,6 @@ 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; }; - 5C9C2D9F28929B6900CC63B1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9A28929B6900CC63B1 /* libffi.a */; }; - 5C9C2DA028929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */; }; - 5C9C2DA128929B6900CC63B1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9C28929B6900CC63B1 /* libgmp.a */; }; - 5C9C2DA228929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */; }; - 5C9C2DA328929B6900CC63B1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */; }; 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */; }; 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */; }; 5C9C2DA92899DA6F00CC63B1 /* NetworkAndServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */; }; @@ -130,6 +131,7 @@ 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; + 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -194,6 +196,12 @@ 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; + 5C00164328A26FBC0094D739 /* ContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenu.swift; sourceTree = ""; }; + 5C00166528C119300094D739 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C00166628C119300094D739 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C00166728C119300094D739 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a"; sourceTree = ""; }; + 5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a"; sourceTree = ""; }; 5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = ""; }; 5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = ""; }; 5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = ""; }; @@ -236,11 +244,6 @@ 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = ""; }; - 5C9C2D9A28929B6900CC63B1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a"; sourceTree = ""; }; - 5C9C2D9C28929B6900CC63B1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a"; sourceTree = ""; }; - 5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C9C2DA42894777E00CC63B1 /* GroupProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupProfileView.swift; sourceTree = ""; }; 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedNetworkSettings.swift; sourceTree = ""; }; 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAndServers.swift; sourceTree = ""; }; @@ -315,6 +318,7 @@ 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; + 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -347,13 +351,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C9C2DA028929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a in Frameworks */, - 5C9C2DA128929B6900CC63B1 /* libgmp.a in Frameworks */, + 5C00166E28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C9C2DA228929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a in Frameworks */, - 5C9C2D9F28929B6900CC63B1 /* libffi.a in Frameworks */, + 5C00166C28C119300094D739 /* libgmpxx.a in Frameworks */, + 5C00166A28C119300094D739 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C9C2DA328929B6900CC63B1 /* libgmpxx.a in Frameworks */, + 5C00166B28C119300094D739 /* libffi.a in Frameworks */, + 5C00166D28C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -408,11 +412,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C9C2D9A28929B6900CC63B1 /* libffi.a */, - 5C9C2D9C28929B6900CC63B1 /* libgmp.a */, - 5C9C2D9E28929B6900CC63B1 /* libgmpxx.a */, - 5C9C2D9D28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw-ghc8.10.7.a */, - 5C9C2D9B28929B6900CC63B1 /* libHSsimplex-chat-3.1.0-FNUbBjLYHjnDjt6ldpTolw.a */, + 5C00166628C119300094D739 /* libffi.a */, + 5C00166528C119300094D739 /* libgmp.a */, + 5C00166728C119300094D739 /* libgmpxx.a */, + 5C00166828C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme-ghc8.10.7.a */, + 5C00166928C119300094D739 /* libHSsimplex-chat-3.2.1-DtA3whUOI1LFNbOU0tXQme.a */, ); path = Libraries; sourceTree = ""; @@ -452,6 +456,7 @@ 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */, 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */, 5C6BA666289BD954009B8ECC /* DismissSheets.swift */, + 5C00164328A26FBC0094D739 /* ContextMenu.swift */, ); path = Helpers; sourceTree = ""; @@ -550,6 +555,7 @@ 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, 640F50E227CF991C001E05C2 /* SMPServers.swift */, 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */, + 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */, ); path = UserSettings; sourceTree = ""; @@ -785,6 +791,7 @@ mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */, + 5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, ); productRefGroup = 5CA059CB279559F40002BEB4 /* Products */; projectDirPath = ""; @@ -861,9 +868,11 @@ 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + 5C00164428A26FBC0094D739 /* ContextMenu.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */, + 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */, 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, @@ -1155,7 +1164,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 70; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1176,7 +1185,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1197,7 +1206,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 70; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1218,7 +1227,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1276,7 +1285,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 70; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1289,7 +1298,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1306,7 +1315,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 70; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1319,7 +1328,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1337,7 +1346,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 70; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1361,7 +1370,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1383,7 +1392,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 68; + CURRENT_PROJECT_VERSION = 70; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1407,7 +1416,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1475,6 +1484,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 5C00163E28A1B87B0094D739 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.4; + }; + }; 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/twostraws/CodeScanner"; diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3f1d1790ff..ded924d1c1 100644 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -8,6 +8,15 @@ "revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", "version" : "2.1.1" } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect", + "state" : { + "revision" : "f2616860a41f9d9932da412a8978fec79c06fe24", + "version" : "0.1.4" + } } ], "version" : 2 diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 77c2a476b1..2299bd899a 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -20,11 +20,12 @@ public enum ChatCommand { case apiActivateChat case apiSuspendChat(timeoutMicroseconds: Int) case setFilesFolder(filesFolder: String) + case setIncognito(incognito: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage case apiGetChats - case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination) + case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) @@ -45,6 +46,7 @@ public enum ChatCommand { case setUserSMPServers(smpServers: [String]) case apiSetNetworkConfig(networkConfig: NetCfg) case apiGetNetworkConfig + case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) case apiContactInfo(contactId: Int64) case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case addContact @@ -53,6 +55,7 @@ public enum ChatCommand { case apiClearChat(type: ChatType, id: Int64) case listContacts case apiUpdateProfile(profile: Profile) + case apiSetContactAlias(contactId: Int64, localAlias: String) case createMyAddress case deleteMyAddress case showMyAddress @@ -81,11 +84,13 @@ public enum ChatCommand { case .apiActivateChat: return "/_app activate" case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" + case let .setIncognito(incognito): return "/incognito \(incognito ? "on" : "off")" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" case .apiGetChats: return "/_get chats pcc=on" - case let .apiGetChat(type, id, pagination): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + + (search == "" ? "" : " search=\(search)") case let .apiSendMessage(type, id, file, quotedItemId, mc): let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) return "/_send \(ref(type, id)) json \(msg)" @@ -107,6 +112,7 @@ public enum ChatCommand { case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case .apiGetNetworkConfig: return "/network" + case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case .addContact: return "/connect" @@ -115,6 +121,7 @@ public enum ChatCommand { case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case .listContacts: return "/contacts" case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" case .createMyAddress: return "/address" case .deleteMyAddress: return "/delete_address" case .showMyAddress: return "/show_address" @@ -145,6 +152,7 @@ public enum ChatCommand { case .apiActivateChat: return "apiActivateChat" case .apiSuspendChat: return "apiSuspendChat" case .setFilesFolder: return "setFilesFolder" + case .setIncognito: return "setIncognito" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -169,6 +177,7 @@ public enum ChatCommand { case .setUserSMPServers: return "setUserSMPServers" case .apiSetNetworkConfig: return "apiSetNetworkConfig" case .apiGetNetworkConfig: return "apiGetNetworkConfig" + case .apiSetChatSettings: return "apiSetChatSettings" case .apiContactInfo: return "apiContactInfo" case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .addContact: return "addContact" @@ -177,6 +186,7 @@ public enum ChatCommand { case .apiClearChat: return "apiClearChat" case .listContacts: return "listContacts" case .apiUpdateProfile: return "apiUpdateProfile" + case .apiSetContactAlias: return "apiSetContactAlias" case .createMyAddress: return "createMyAddress" case .deleteMyAddress: return "deleteMyAddress" case .showMyAddress: return "showMyAddress" @@ -221,7 +231,7 @@ public enum ChatResponse: Decodable, Error { case apiChat(chat: ChatData) case userSMPServers(smpServers: [String]) case networkConfig(networkConfig: NetCfg) - case contactInfo(contact: Contact, connectionStats: ConnectionStats) + case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) case invitation(connReqInvitation: String) case sentConfirmation @@ -231,6 +241,7 @@ public enum ChatResponse: Decodable, Error { case chatCleared(chatInfo: ChatInfo) case userProfileNoChange case userProfileUpdated(fromProfile: Profile, toProfile: Profile) + case contactAliasUpdated(toContact: Contact) case userContactLink(connReqContact: String) case userContactLinkCreated(connReqContact: String) case userContactLinkDeleted @@ -255,7 +266,7 @@ public enum ChatResponse: Decodable, Error { case contactsList(contacts: [Contact]) // group events case groupCreated(groupInfo: GroupInfo) - case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact) + case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(groupInfo: GroupInfo) case userDeletedMember(groupInfo: GroupInfo, member: GroupMember) case leftMemberUser(groupInfo: GroupInfo) @@ -268,11 +279,11 @@ public enum ChatResponse: Decodable, Error { case leftMember(groupInfo: GroupInfo, member: GroupMember) case groupDeleted(groupInfo: GroupInfo, member: GroupMember) case contactsMerged(intoContact: Contact, mergedContact: Contact) - case groupInvitation(groupInfo: GroupInfo) + case groupInvitation(groupInfo: GroupInfo) // unused case userJoinedGroup(groupInfo: GroupInfo) case joinedGroupMember(groupInfo: GroupInfo, member: GroupMember) case connectedToGroupMember(groupInfo: GroupInfo, member: GroupMember) - case groupRemoved(groupInfo: GroupInfo) + case groupRemoved(groupInfo: GroupInfo) // unused case groupUpdated(toGroup: GroupInfo) // receiving file events case rcvFileAccepted(chatItem: AChatItem) @@ -322,6 +333,7 @@ public enum ChatResponse: Decodable, Error { case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" + case .contactAliasUpdated: return "contactAliasUpdated" case .userContactLink: return "userContactLink" case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" @@ -403,8 +415,8 @@ public enum ChatResponse: Decodable, Error { case let .apiChat(chat): return String(describing: chat) case let .userSMPServers(smpServers): return String(describing: smpServers) case let .networkConfig(networkConfig): return String(describing: networkConfig) - case let .contactInfo(contact, connectionStats): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))" - case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\\nconnectionStats_: \(String(describing: connectionStats_))" + case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))" + case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))" case let .invitation(connReqInvitation): return connReqInvitation case .sentConfirmation: return noDetails case .sentInvitation: return noDetails @@ -413,6 +425,7 @@ public enum ChatResponse: Decodable, Error { case let .chatCleared(chatInfo): return String(describing: chatInfo) case .userProfileNoChange: return noDetails case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) + case let .contactAliasUpdated(toContact): return String(describing: toContact) case let .userContactLink(connReq): return connReq case let .userContactLinkCreated(connReq): return connReq case .userContactLinkDeleted: return noDetails @@ -436,7 +449,7 @@ public enum ChatResponse: Decodable, Error { case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" case let .contactsList(contacts): return String(describing: contacts) case let .groupCreated(groupInfo): return String(describing: groupInfo) - case let .sentGroupInvitation(groupInfo, contact): return "groupInfo: \(groupInfo)\ncontact: \(contact)" + case let .sentGroupInvitation(groupInfo, contact, member): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)" case let .userAcceptedGroupSent(groupInfo): return String(describing: groupInfo) case let .userDeletedMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" case let .leftMemberUser(groupInfo): return String(describing: groupInfo) @@ -516,6 +529,8 @@ public struct ArchiveConfig: Encodable { public struct NetCfg: Codable, Equatable { public var socksProxy: String? = nil + public var hostMode: HostMode = .publicHost + public var requiredHostMode = true public var tcpConnectTimeout: Int // microseconds public var tcpTimeout: Int // microseconds public var tcpKeepAlive: KeepAliveOpts? @@ -540,6 +555,46 @@ public struct NetCfg: Codable, Equatable { public var enableKeepAlive: Bool { tcpKeepAlive != nil } } +public enum HostMode: String, Codable { + case onionViaSocks + case onionHost = "onion" + case publicHost = "public" +} + +public enum OnionHosts: String, Identifiable { + case no + case prefer + case require + + public var text: LocalizedStringKey { + switch self { + case .no: return "No" + case .prefer: return "When available" + case .require: return "Required" + } + } + + public var hostMode: (HostMode, Bool) { + switch self { + case .no: return (.publicHost, true) + case .prefer: return (.onionHost, false) + case .require: return (.onionHost, true) + } + } + + public init(netCfg: NetCfg) { + switch netCfg.hostMode { + case .onionViaSocks: self = .no + case .onionHost: self = netCfg.requiredHostMode ? .require : .prefer + case .publicHost: self = .no + } + } + + public var id: OnionHosts { self } + + public static let values: [OnionHosts] = [.no, .prefer, .require] +} + public struct KeepAliveOpts: Codable, Equatable { public var keepIdle: Int // seconds public var keepIntvl: Int // seconds @@ -548,6 +603,16 @@ public struct KeepAliveOpts: Codable, Equatable { public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4) } +public struct ChatSettings: Codable { + public var enableNtfs: Bool + + public init(enableNtfs: Bool) { + self.enableNtfs = enableNtfs + } + + public static let defaults: ChatSettings = ChatSettings(enableNtfs: true) +} + public struct ConnectionStats: Codable { public var rcvServers: [String]? public var sndServers: [String]? diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index b63db62154..d428c00832 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -15,6 +15,7 @@ public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount" +let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout" let GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL = "networkSMPPingInterval" @@ -22,6 +23,7 @@ let GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE = "networkEnableKeepAlive" let GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE = "networkTCPKeepIdle" let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl" let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt" +let GROUP_DEFAULT_INCOGNITO = "incognito" let APP_GROUP_NAME = "group.chat.simplex.app" @@ -29,13 +31,15 @@ public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)! public func registerGroupDefaults() { groupDefaults.register(defaults: [ + GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout, GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval, GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE: NetCfg.defaults.enableKeepAlive, GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle, GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl, - GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt + GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt, + GROUP_DEFAULT_INCOGNITO: false ]) } @@ -80,10 +84,18 @@ public let ntfPreviewModeGroupDefault = EnumDefault( withDefault: .message ) +public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO) + public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT) +public let networkUseOnionHostsGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS, + withDefault: .no +) + public class DateDefault { var defaults: UserDefaults var key: String @@ -157,6 +169,8 @@ public class Default { } public func getNetCfg() -> NetCfg { + let onionHosts = networkUseOnionHostsGroupDefault.get() + let (hostMode, requiredHostMode) = onionHosts.hostMode let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) let smpPingInterval = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL) @@ -171,6 +185,8 @@ public func getNetCfg() -> NetCfg { tcpKeepAlive = nil } return NetCfg( + hostMode: hostMode, + requiredHostMode: requiredHostMode, tcpConnectTimeout: tcpConnectTimeout, tcpTimeout: tcpTimeout, tcpKeepAlive: tcpKeepAlive, @@ -179,6 +195,7 @@ public func getNetCfg() -> NetCfg { } public func setNetCfg(_ cfg: NetCfg) { + networkUseOnionHostsGroupDefault.set(OnionHosts(netCfg: cfg)) groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) groupDefaults.set(cfg.smpPingInterval, forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index fcb886bf85..1808c0d5e7 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -13,18 +13,19 @@ public struct User: Decodable, NamedChat { var userId: Int64 var userContactId: Int64 var localDisplayName: ContactName - public var profile: Profile + public var profile: LocalProfile var activeUser: Bool public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } + public var localAlias: String { get { "" } } public static let sampleData = User( userId: 1, userContactId: 1, localDisplayName: "alice", - profile: Profile.sampleData, + profile: LocalProfile.sampleData, activeUser: true ) } @@ -43,6 +44,7 @@ public struct Profile: Codable, NamedChat { public var displayName: String public var fullName: String public var image: String? + public var localAlias: String { get { "" } } var profileViewName: String { (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" @@ -54,6 +56,43 @@ public struct Profile: Codable, NamedChat { ) } +public struct LocalProfile: Codable, NamedChat { + public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, localAlias: String) { + self.profileId = profileId + self.displayName = displayName + self.fullName = fullName + self.image = image + self.localAlias = localAlias + } + + public var profileId: Int64 + public var displayName: String + public var fullName: String + public var image: String? + public var localAlias: String + + var profileViewName: String { + localAlias == "" + ? (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" + : localAlias + } + + static let sampleData = LocalProfile( + profileId: 1, + displayName: "alice", + fullName: "Alice", + localAlias: "" + ) +} + +public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile { + LocalProfile(profileId: profileId, displayName: profile.displayName, fullName: profile.fullName, image: profile.image, localAlias: localAlias) +} + +public func fromLocalProfile (_ profile: LocalProfile) -> Profile { + Profile(displayName: profile.displayName, fullName: profile.fullName, image: profile.image) +} + public enum ChatType: String { case direct = "@" case group = "#" @@ -65,11 +104,14 @@ public protocol NamedChat { var displayName: String { get } var fullName: String { get } var image: String? { get } + var localAlias: String { get } } extension NamedChat { public var chatViewName: String { - get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } + localAlias == "" + ? displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") + : localAlias } } @@ -125,6 +167,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } + public var localAlias: String { + get { + switch self { + case let .direct(contact): return contact.localAlias + case let .group(groupInfo): return groupInfo.localAlias + case let .contactRequest(contactRequest): return contactRequest.localAlias + case let .contactConnection(contactConnection): return contactConnection.localAlias + } + } + } + public var id: ChatId { get { switch self { @@ -180,6 +233,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } + public var incognito: Bool { + get { + switch self { + case let .direct(contact): return contact.contactConnIncognito + case let .group(groupInfo): return groupInfo.membership.memberIncognito + case .contactRequest: return false + case let .contactConnection(contactConnection): return contactConnection.incognito + } + } + } + public var contact: Contact? { get { switch self { @@ -189,6 +253,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } + public var ntfsEnabled: Bool { + switch self { + case let .direct(contact): return contact.chatSettings.enableNtfs + case let .group(groupInfo): return groupInfo.chatSettings.enableNtfs + default: return false + } + } + var createdAt: Date { switch self { case let .direct(contact): return contact.createdAt @@ -239,11 +311,12 @@ public struct ChatStats: Decodable { } public struct Contact: Identifiable, Decodable, NamedChat { - var contactId: Int64 + public var contactId: Int64 var localDisplayName: ContactName - public var profile: Profile + public var profile: LocalProfile public var activeConn: Connection public var viaGroup: Int64? + public var chatSettings: ChatSettings var createdAt: Date var updatedAt: Date @@ -251,19 +324,25 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn.connStatus == .ready } } public var sendMsgEnabled: Bool { get { true } } - public var displayName: String { get { profile.displayName } } + public var displayName: String { localAlias == "" ? profile.displayName : localAlias } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } + public var localAlias: String { profile.localAlias } - public func isIndirectContact() -> Bool { - return activeConn.connLevel > 0 || viaGroup != nil + public var isIndirectContact: Bool { + activeConn.connLevel > 0 || viaGroup != nil + } + + public var contactConnIncognito: Bool { + activeConn.customUserProfileId != nil } public static let sampleData = Contact( contactId: 1, localDisplayName: "alice", - profile: Profile.sampleData, + profile: LocalProfile.sampleData, activeConn: Connection.sampleData, + chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now ) @@ -285,6 +364,7 @@ public struct Connection: Decodable { var connId: Int64 var connStatus: ConnStatus public var connLevel: Int + public var customUserProfileId: Int64? public var id: ChatId { get { ":\(connId)" } } @@ -326,6 +406,7 @@ public struct UserContactRequest: Decodable, NamedChat { public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } + public var localAlias: String { "" } public static let sampleData = UserContactRequest( contactRequestId: 1, @@ -342,6 +423,7 @@ public struct PendingContactConnection: Decodable, NamedChat { var pccAgentConnId: String var pccConnStatus: ConnStatus public var viaContactUri: Bool + public var customUserProfileId: Int64? var createdAt: Date public var updatedAt: Date @@ -366,16 +448,37 @@ public struct PendingContactConnection: Decodable, NamedChat { } public var fullName: String { get { "" } } public var image: String? { get { nil } } + public var localAlias: String { "" } public var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } + public var incognito: Bool { + customUserProfileId != nil + } + public var description: String { get { if let initiated = pccConnStatus.initiated { - return initiated && !viaContactUri - ? NSLocalizedString("you shared one-time link", comment: "chat list item description") - : viaContactUri - ? NSLocalizedString("via contact address link", comment: "chat list item description") - : NSLocalizedString("via one-time link", comment: "chat list item description") + var desc: String + if initiated && !viaContactUri { + if incognito { + desc = NSLocalizedString("you shared one-time link incognito", comment: "chat list item description") + } else { + desc = NSLocalizedString("you shared one-time link", comment: "chat list item description") + } + } else if viaContactUri { + if incognito { + desc = NSLocalizedString("incognito via contact address link", comment: "chat list item description") + } else { + desc = NSLocalizedString("via contact address link", comment: "chat list item description") + } + } else { + if incognito { + desc = NSLocalizedString("incognito via one-time link", comment: "chat list item description") + } else { + desc = NSLocalizedString("via one-time link", comment: "chat list item description") + } + } + return desc } else { return "" } @@ -433,6 +536,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { var localDisplayName: GroupName public var groupProfile: GroupProfile public var membership: GroupMember + public var hostConnCustomUserProfileId: Int64? + public var chatSettings: ChatSettings var createdAt: Date var updatedAt: Date @@ -443,6 +548,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { public var displayName: String { get { groupProfile.displayName } } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } + public var localAlias: String { "" } public var canEdit: Bool { return membership.memberRole == .owner && membership.memberCurrent @@ -461,6 +567,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { localDisplayName: "team", groupProfile: GroupProfile.sampleData, membership: GroupMember.sampleData, + hostConnCustomUserProfileId: nil, + chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now ) @@ -476,6 +584,7 @@ public struct GroupProfile: Codable, NamedChat { public var displayName: String public var fullName: String public var image: String? + public var localAlias: String { "" } public static let sampleData = GroupProfile( displayName: "team", @@ -492,12 +601,18 @@ public struct GroupMember: Identifiable, Decodable { public var memberStatus: GroupMemberStatus public var invitedBy: InvitedBy public var localDisplayName: ContactName - public var memberProfile: Profile + public var memberProfile: LocalProfile public var memberContactId: Int64? + public var memberContactProfileId: Int64 public var activeConn: Connection? public var id: String { "#\(groupId) @\(groupMemberId)" } - public var displayName: String { get { memberProfile.displayName } } + public var displayName: String { + get { + let p = memberProfile + return p.localAlias == "" ? p.displayName : p.localAlias + } + } public var fullName: String { get { memberProfile.fullName } } public var image: String? { get { memberProfile.image } } @@ -514,7 +629,9 @@ public struct GroupMember: Identifiable, Decodable { public var chatViewName: String { get { let p = memberProfile - return p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + return p.localAlias == "" + ? p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + : p.localAlias } } @@ -556,6 +673,10 @@ public struct GroupMember: Identifiable, Decodable { && userRole >= .admin && userRole >= memberRole && membership.memberCurrent } + public var memberIncognito: Bool { + memberProfile.profileId != memberContactProfileId + } + public static let sampleData = GroupMember( groupMemberId: 1, groupId: 1, @@ -565,8 +686,9 @@ public struct GroupMember: Identifiable, Decodable { memberStatus: .memComplete, invitedBy: .user, localDisplayName: "alice", - memberProfile: Profile.sampleData, + memberProfile: LocalProfile.sampleData, memberContactId: 1, + memberContactProfileId: 1, activeConn: Connection.sampleData ) } @@ -710,7 +832,7 @@ public struct ChatItem: Identifiable, Decodable { self.quotedItem = quotedItem self.file = file } - + public var chatDir: CIDirection public var meta: CIMeta public var content: CIContent @@ -718,9 +840,17 @@ public struct ChatItem: Identifiable, Decodable { public var quotedItem: CIQuote? public var file: CIFile? - public var id: Int64 { get { meta.itemId } } + public var viewTimestamp = Date.now - public var timestampText: Text { get { meta.timestampText } } + private enum CodingKeys: String, CodingKey { + case chatDir, meta, content, formattedText, quotedItem, file + } + + public var id: Int64 { meta.itemId } + + public var viewId: String { "\(meta.itemId) \(viewTimestamp.timeIntervalSince1970)" } + + public var timestampText: Text { meta.timestampText } public var text: String { get { @@ -763,7 +893,7 @@ public struct ChatItem: Identifiable, Decodable { public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { - return groupMember.memberProfile.displayName + return groupMember.displayName } else { return nil } @@ -855,6 +985,7 @@ public struct CIMeta: Decodable { var itemText: String public var itemStatus: CIStatus var createdAt: Date + var updatedAt: Date public var itemDeleted: Bool public var itemEdited: Bool public var editable: Bool @@ -868,6 +999,7 @@ public struct CIMeta: Decodable { itemText: text, itemStatus: status, createdAt: ts, + updatedAt: ts, itemDeleted: itemDeleted, itemEdited: itemEdited, editable: editable @@ -892,6 +1024,17 @@ public enum CIStatus: Decodable { case sndError(agentError: AgentErrorType) case rcvNew case rcvRead + + var id: String { + switch self { + case .sndNew: return "sndNew" + case .sndSent: return "sndSent" + case .sndErrorAuth: return "sndErrorAuth" + case .sndError: return "sndError" + case .rcvNew: return "rcvNew" + case .rcvRead: return "rcvRead" + } + } } public enum CIDeleteMode: String, Decodable { diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 6688e09b47..bed8f2d341 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1,3 +1,6 @@ +/* No comment provided by engineer. */ +"\n" = "\n"; + /* No comment provided by engineer. */ " " = " "; @@ -106,6 +109,12 @@ /* notification title */ "A new contact" = "Новый контакт"; +/* No comment provided by engineer. */ +"A random profile will be sent to the contact that you received this link from" = "Контакту, от которого вы получили эту ссылку, будет отправлен случайный профиль"; + +/* No comment provided by engineer. */ +"A random profile will be sent to your contact" = "Вашему контакту будет отправлен случайный профиль"; + /* No comment provided by engineer. */ "About SimpleX" = "О SimpleX"; @@ -115,6 +124,9 @@ /* No comment provided by engineer. */ "above, then choose:" = "наверху, затем выберите:"; +/* No comment provided by engineer. */ +"Accent color" = "Основной цвет"; + /* accept contact request via notification accept incoming call via notification */ "Accept" = "Принять"; @@ -125,6 +137,9 @@ /* notification body */ "Accept contact request from %@?" = "Принять запрос на соединение от %@?"; +/* No comment provided by engineer. */ +"Accept incognito" = "Принять инкогнито"; + /* call status */ "accepted call" = " принятый звонок"; @@ -194,6 +209,12 @@ /* call status */ "calling…" = "входящий звонок…"; +/* No comment provided by engineer. */ +"Can't invite contact!" = "Нельзя пригласить контакт!"; + +/* No comment provided by engineer. */ +"Can't invite contacts!" = "Нельзя пригласить контакты!"; + /* No comment provided by engineer. */ "Cancel" = "Отменить"; @@ -221,7 +242,7 @@ /* No comment provided by engineer. */ "Chat with the developers" = "Соединиться с разработчиками"; -/* back button to return to chats list */ +/* No comment provided by engineer. */ "Chats" = "Чаты"; /* No comment provided by engineer. */ @@ -242,6 +263,9 @@ /* No comment provided by engineer. */ "colored" = "цвет"; +/* No comment provided by engineer. */ +"Colors" = "Цвета"; + /* No comment provided by engineer. */ "complete" = "соединение завершено"; @@ -350,7 +374,7 @@ /* No comment provided by engineer. */ "Contact name" = "Имена контактов"; -/* No comment provided by engineer. */ +/* chat item action */ "Copy" = "Скопировать"; /* No comment provided by engineer. */ @@ -380,6 +404,9 @@ /* No comment provided by engineer. */ "Currently maximum supported file size is %@." = "Максимальный размер файла - %@."; +/* No comment provided by engineer. */ +"Dark" = "Тёмная"; + /* No comment provided by engineer. */ "Database export & import" = "Экспорт и импорт архива чата"; @@ -392,7 +419,7 @@ /* No comment provided by engineer. */ "Decentralized" = "Децентрализованный"; -/* No comment provided by engineer. */ +/* chat item action */ "Delete" = "Удалить"; /* No comment provided by engineer. */ @@ -494,7 +521,7 @@ /* No comment provided by engineer. */ "e2e encrypted" = "e2e зашифровано"; -/* No comment provided by engineer. */ +/* chat item action */ "Edit" = "Редактировать"; /* No comment provided by engineer. */ @@ -530,6 +557,9 @@ /* No comment provided by engineer. */ "Error accessing database file" = "Ошибка при доступе к данным чата"; +/* No comment provided by engineer. */ +"Error adding member(s)" = "Ошибка при добавлении членов группы"; + /* No comment provided by engineer. */ "Error creating group" = "Ошибка при создании группы"; @@ -692,6 +722,24 @@ /* No comment provided by engineer. */ "In person or via a video call – the most secure way to connect." = "При встрече или в видеозвонке – самый безопасный способ установить соединение"; +/* No comment provided by engineer. */ +"Incognito" = "Инкогнито"; + +/* No comment provided by engineer. */ +"Incognito mode" = "Режим Инкогнито"; + +/* No comment provided by engineer. */ +"Incognito mode is not supported here - your main profile will be sent to group members" = "Режим Инкогнито здесь не поддерживается - ваш основной профиль будет отправлен членам группы"; + +/* No comment provided by engineer. */ +"Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created." = "Режим Инкогнито защищает конфиденциальность имени и изображения вашего основного профиля — для каждого нового контакта создается новый случайный профиль."; + +/* chat list item description */ +"incognito via contact address link" = "инкогнито через ссылку-контакт"; + +/* chat list item description */ +"incognito via one-time link" = "инкогнито через одноразовую ссылку"; + /* notification */ "Incoming audio call" = "Входящий аудиозвонок"; @@ -734,6 +782,9 @@ /* chat list item title */ "invited to connect" = "приглашение"; +/* No comment provided by engineer. */ +"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Это позволяет иметь много анонимных соединений без общих данных между ними в одном профиле пользователя."; + /* No comment provided by engineer. */ "It can happen when:\n1. The messages expire on the server if they were not received for 30 days,\n2. The server you use to receive the messages from this contact was updated and restarted.\n3. The connection is compromised.\nPlease connect to the developers via Settings to receive the updates about the servers.\nWe will be adding server redundancy to prevent lost messages." = "Это может случится, когда:\n1. Сервер удалил сообщения, если они не были доставлены в течение 30 дней.\n2. Сервер, через который вы получаете сообщения от контакта, был обновлён и перезапущен.\n3. Соединение компроментировано.\nПожалуйста, соединитесь с девелоперами через Настройки, чтобы получать уведомления о серверах.\nМы планируем добавить избыточную доставку сообщений, чтобы не терять сообщения."; @@ -749,9 +800,15 @@ /* No comment provided by engineer. */ "Join" = "Вступить"; +/* No comment provided by engineer. */ +"join as %@" = "вступить как %@"; + /* No comment provided by engineer. */ "Join group" = "Вступить в группу"; +/* No comment provided by engineer. */ +"Join incognito" = "Вступить инкогнито"; + /* No comment provided by engineer. */ "Joining group" = "Вступление в группу"; @@ -770,6 +827,9 @@ /* rcv group event chat item */ "left" = "покинул(а) группу"; +/* No comment provided by engineer. */ +"Light" = "Светлая"; + /* No comment provided by engineer. */ "Limitations" = "Ограничения"; @@ -785,6 +845,9 @@ /* No comment provided by engineer. */ "Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*" = "Много пользователей спросили: *как SimpleX доставляет сообщения без идентификаторов пользователей?*"; +/* No comment provided by engineer. */ +"Mark read" = "Прочитано"; + /* No comment provided by engineer. */ "Markdown in messages" = "Форматирование сообщений"; @@ -827,6 +890,9 @@ /* No comment provided by engineer. */ "Most likely this contact has deleted the connection with you." = "Скорее всего, этот контакт удалил соединение с вами."; +/* No comment provided by engineer. */ +"Mute" = "Без звука"; + /* No comment provided by engineer. */ "Network & servers" = "Сеть & серверы"; @@ -854,6 +920,9 @@ /* notification */ "New message" = "Новое сообщение"; +/* No comment provided by engineer. */ +"No" = "Нет"; + /* No comment provided by engineer. */ "No contacts selected" = "Контакты не выбраны"; @@ -890,6 +959,15 @@ /* No comment provided by engineer. */ "One-time invitation link" = "Одноразовая ссылка"; +/* No comment provided by engineer. */ +"Onion hosts will be required for connection. Requires enabling VPN." = "Подключаться только к onion хостам. Требуется включенный VPN."; + +/* No comment provided by engineer. */ +"Onion hosts will be used when available. Requires enabling VPN." = "Onion хосты используются, если возможно. Требуется включенный VPN."; + +/* No comment provided by engineer. */ +"Onion hosts will not be used." = "Onion хосты не используются."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." = "Только пользовательские устройства хранят контакты, группы и сообщения, которые отправляются **с двухуровневым end-to-end шифрованием**"; @@ -992,6 +1070,12 @@ /* call status */ "rejected call" = "отклонённый звонок"; +/* No comment provided by engineer. */ +"Relay server is only used if necessary. Another party can observe your IP address." = "Relay сервер используется только при необходимости. Другая сторона может видеть ваш IP адрес."; + +/* No comment provided by engineer. */ +"Relay server protects your IP address, but it can observe the duration of the call." = "Relay сервер защищает ваш IP адрес, но может отслеживать продолжительность звонка."; + /* No comment provided by engineer. */ "Remove" = "Удалить"; @@ -1010,9 +1094,15 @@ /* rcv group event chat item */ "removed you" = "удалил(а) вас из группы"; -/* No comment provided by engineer. */ +/* chat item action */ "Reply" = "Ответить"; +/* No comment provided by engineer. */ +"Required" = "Обязательно"; + +/* No comment provided by engineer. */ +"Reset colors" = "Сбросить цвета"; + /* No comment provided by engineer. */ "Reset to defaults" = "Сбросить настройки"; @@ -1028,7 +1118,7 @@ /* No comment provided by engineer. */ "Run chat" = "Запустить chat"; -/* No comment provided by engineer. */ +/* chat item action */ "Save" = "Сохранить"; /* No comment provided by engineer. */ @@ -1049,12 +1139,18 @@ /* No comment provided by engineer. */ "Scan QR code" = "Сканировать QR код"; +/* No comment provided by engineer. */ +"Search" = "Поиск"; + /* network option */ "sec" = "сек"; /* No comment provided by engineer. */ "secret" = "секрет"; +/* No comment provided by engineer. */ +"Send direct message" = "Отправить сообщение"; + /* No comment provided by engineer. */ "Send link previews" = "Отправлять картинки ссылок"; @@ -1073,13 +1169,16 @@ /* No comment provided by engineer. */ "Servers" = "Серверы"; +/* No comment provided by engineer. */ +"Set contact name…" = "Имя контакта…"; + /* No comment provided by engineer. */ "Set timeouts for proxy/VPN" = "Установить таймауты для прокси/VPN"; /* No comment provided by engineer. */ "Settings" = "Настройки"; -/* No comment provided by engineer. */ +/* chat item action */ "Share" = "Поделиться"; /* No comment provided by engineer. */ @@ -1139,6 +1238,9 @@ /* No comment provided by engineer. */ "strike" = "зачеркнуть"; +/* No comment provided by engineer. */ +"System" = "Системная"; + /* No comment provided by engineer. */ "Take picture" = "Сделать фото"; @@ -1148,6 +1250,9 @@ /* No comment provided by engineer. */ "Tap to join" = "Нажмите, чтобы вступить"; +/* No comment provided by engineer. */ +"Tap to join incognito" = "Нажмите, чтобы вступить инкогнито"; + /* No comment provided by engineer. */ "TCP connection timeout" = "Таймаут TCP соединения"; @@ -1196,6 +1301,9 @@ /* No comment provided by engineer. */ "The sender will NOT be notified" = "Отправитель не будет уведомлён"; +/* No comment provided by engineer. */ +"Theme" = "Тема"; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Это действие нельзя отменить — ваш профиль, контакты, сообщения и файлы будут безвозвратно утеряны."; @@ -1211,6 +1319,9 @@ /* No comment provided by engineer. */ "To ask any questions and to receive updates:" = "Чтобы задать вопросы и получать уведомления о новых версиях,"; +/* No comment provided by engineer. */ +"To find the profile used for an incognito connection, tap the contact or group name on top of the chat." = "Чтобы найти инкогнито профиль, используемый в разговоре, нажмите на имя контакта или группы в верхней части чата."; + /* No comment provided by engineer. */ "To make a new connection" = "Чтобы соединиться"; @@ -1259,6 +1370,12 @@ /* authentication reason */ "Unlock" = "Разблокировать"; +/* No comment provided by engineer. */ +"Unmute" = "Уведомлять"; + +/* No comment provided by engineer. */ +"Update .onion hosts setting?" = "Обновить настройки .onion хостов?"; + /* No comment provided by engineer. */ "Update network settings?" = "Обновить настройки сети?"; @@ -1266,7 +1383,13 @@ "updated group profile" = "обновил(а) профиль группы"; /* No comment provided by engineer. */ -"Updating settings will re-connect the client to all servers." = "Обновление настроек приведет к переподключению клиента ко всем серверам."; +"Updating settings will re-connect the client to all servers." = "Обновление настроек приведет к сбросу и установке нового соединения со всеми серверами."; + +/* No comment provided by engineer. */ +"Updating this setting will re-connect the client to all servers." = "Обновление этих настроек приведет к сбросу и установке нового соединения со всеми серверами."; + +/* No comment provided by engineer. */ +"Use .onion hosts" = "Использовать .onion хосты"; /* No comment provided by engineer. */ "Use chat" = "Использовать чат"; @@ -1274,6 +1397,9 @@ /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; +/* No comment provided by engineer. */ +"Using .onion hosts requires compatible VPN provider." = "Для использования .onion хостов требуется совместимый VPN провайдер."; + /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "Используются серверы, предоставленные SimpleX Chat."; @@ -1289,6 +1415,9 @@ /* No comment provided by engineer. */ "via relay" = "через relay сервер"; +/* No comment provided by engineer. */ +"Video call" = "Видеозвонок"; + /* No comment provided by engineer. */ "video call (not e2e encrypted)" = "видеозвонок (не e2e зашифрованный)"; @@ -1310,6 +1439,12 @@ /* No comment provided by engineer. */ "Welcome %@!" = "Здравствуйте %@!"; +/* No comment provided by engineer. */ +"When available" = "Когда возможно"; + +/* No comment provided by engineer. */ +"When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Когда вы соединены с контактом инкогнито, тот же самый инкогнито профиль будет использоваться для групп с этим контактом."; + /* No comment provided by engineer. */ "You" = "Вы"; @@ -1329,7 +1464,7 @@ "You are invited to group" = "Вы приглашены в группу"; /* No comment provided by engineer. */ -"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button" = "Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**."; +"You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button." = "Вы также можете соединиться, открыв ссылку. Если ссылка откроется в браузере, нажмите кнопку **Open in mobile app**."; /* notification body */ "You can now send messages to %@" = "Вы теперь можете отправлять сообщения %@"; @@ -1379,6 +1514,9 @@ /* chat list item description */ "you shared one-time link" = "вы создали ссылку"; +/* chat list item description */ +"you shared one-time link incognito" = "вы создали ссылку инкогнито"; + /* No comment provided by engineer. */ "You will be connected when your connection request is accepted, please wait or check later!" = "Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!"; @@ -1394,6 +1532,12 @@ /* No comment provided by engineer. */ "you: " = "вы: "; +/* No comment provided by engineer. */ +"You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile" = "Вы пытаетесь пригласить инкогнито контакт в группу, где вы используете свой основной профиль"; + +/* No comment provided by engineer. */ +"You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Вы используете инкогнито профиль для этой группы - чтобы предотвратить раскрытие вашего основного профиля, приглашать контакты не разрешено"; + /* No comment provided by engineer. */ "Your calls" = "Ваши звонки"; @@ -1406,6 +1550,9 @@ /* No comment provided by engineer. */ "Your chat profile" = "Ваш профиль"; +/* No comment provided by engineer. */ +"Your chat profile will be sent to group members" = "Ваш профиль чата будет отправлен членам группы"; + /* No comment provided by engineer. */ "Your chat profile will be sent to your contact" = "Ваш профиль будет отправлен вашему контакту"; @@ -1413,7 +1560,7 @@ "Your chats" = "Ваши чаты"; /* No comment provided by engineer. */ -"Your contact can scan it from the app" = "Ваш контакт может сосканировать QR в приложении"; +"Your contact can scan it from the app." = "Ваш контакт может сосканировать QR код в приложении"; /* No comment provided by engineer. */ "Your contact needs to be online for the connection to complete.\nYou can cancel this connection and remove the contact (and try later with a new link)." = "Ваш контакт должен быть в сети чтобы установить соединение.\nВы можете отменить соединение и удалить контакт (и попробовать позже с другой ссылкой)."; @@ -1436,6 +1583,9 @@ /* No comment provided by engineer. */ "Your profile, contacts and delivered messages are stored on your device." = "Ваш профиль, контакты и доставленные сообщения хранятся на вашем устройстве."; +/* No comment provided by engineer. */ +"Your random profile" = "Ваш случайный профиль"; + /* No comment provided by engineer. */ "Your settings" = "Настройки"; diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 6b31a62dce..f5acebe96a 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -1,7 +1,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GADTs #-} -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -40,7 +39,7 @@ mySquaringBot _user cc = do race_ (forever $ void getLine) . forever $ do (_, resp) <- atomically . readTBQueue $ outputQ cc case resp of - CRContactConnected contact -> do + CRContactConnected contact _ -> do contactConnected contact void . sendMsg contact $ "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square" CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do diff --git a/blog/20220901-simplex-chat-v3.2-incognito-mode.md b/blog/20220901-simplex-chat-v3.2-incognito-mode.md new file mode 100644 index 0000000000..662051c0f0 --- /dev/null +++ b/blog/20220901-simplex-chat-v3.2-incognito-mode.md @@ -0,0 +1,98 @@ +# SimpleX Chat v3.2 is released + +**Published:** Sep 1, 2022 + +## What's new + +- [Incognito mode](#incognito-mode) +- [assign names to your contacts](#assign-names-to-your-contacts) +- [use .onion server addresses with Tor](#using-onion-server-addresses-with-tor) +- [endless scrolling and search in chats](#endless-scrolling-and-search-in-chats) +- [choose accent color and dark mode](#choose-accent-color-and-dark-mode) +- disable notifications per contact / group +- on Android: + - swipe to reply + - reduced APK size for direct download and in F-Droid repo from 200 to 50Mb! + +[Implementation audit is arranged for October](#we-ask-you-to-help-us-pay-for-3rd-party-security-audit)! + +### Incognito mode + + + +_SimpleX is already private, so why do we need an incognito mode_, you may ask. + +You indeed can choose a pseudonym as your main profile name, but there are several problems: + +- many users want to have their real name as their main profile, so that their friends recognise them. SimpleX objective is to provide anonymity from the network operators, but not necessarily from your contacts. +- even if you choose a pseudonym, it would be used for all your contacts. And if two of them meet, while they cannot prove they are talking to the same person, as they use different addresses in SimpleX network to send you the messages, they could _suspect it_. +- any pseudonym you manually choose leaks some information about you, as it's not really random. + +You could also use multiple chat profiles - currently you can only switch between them via export/import, we will make it easier very soon! But there are problems with multiple profiles too: + +- if you make many anonymous connections, each in its own user profile, you would end up having too many profiles - it is very inconvenient to manage. +- sometimes, as your relationship with your contact evolves, you may want to share your main profile with them and have them among your friends - multiple profiles don't make it possible. + +So, the new Incognito mode allows having a new random name shared with each new contact, while having them all in the same user profile, and without the hassle of managing it manually. It's like a private mode in the browsers, where you can temporarily enable it when you connect to somebody you don't trust, and then disable it when connecting to the friend who knows you. It can be turned on via the app settings - see the pictures. + +I don't know any other messenger with this feature, and I always wanted to have this mode, so we are really looking forward to your feedback about it! + +### Assign names to your contacts + +You can now change the name under which your contacts appear in the chats. This is particularly useful when somebody connected to you using a random name – you can change it to be related to the context of the connection. + +### Using .onion server addresses with Tor + +   + +We have released support for using SOCKS proxy to access messaging servers via Tor, but previously the servers were still available via their public Internet addresses. It means that while your IP address was protected from the server, the whole Tor circuit could have been observed by some actors, and for some communication scenarios it is not desirable. + +This release adds support for servers with multiple hostnames - all servers provided by SimpleX Chat now have dual addresses (one public and one .onion), and you can have your own servers available via two addresses as well - all you have to do is to install Tor client on your server and register its address with Tor. If you server has both public and .onion address, it is not really hidden, so you should enable HiddenServiceSingleHopMode to reduce the latency of connection - it protects anonymity of the people who connect to the server, but not of the server itself. The server address would include both its public and onion address, as you can see in the server addresses in the app (in the contacts pages) - you should use the same format for the addresses of your servers. + +Both android and iOS app allow managing whether .onion addresses are used, and you can also enforce using .onion addresses - in this case the app will not connect to the server unless one of its hostname is .onion address. On Android, .onion addresses are used by default when SOCKS proxy is enabled. + +### Endless scrolling and search in chats + +Now you can access the full chat history via the app - it's embarrassing how long it took us to add it! And you can search the messages as well. + +### Choose accent color and dark mode + +Many of you said that blue is the worst possible color, so you can now make the app buttons and links look like you want! My favourite colours are green and orange. + +And you can choose dark or light mode independently of the system settings. + +## SimpleX platform + +Some links to answer the most common questions: + +[How can SimpleX deliver messages without user identifiers](./20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers). + +[What are the risks to have identifiers assigned to the users](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md#why-having-users-identifiers-is-bad-for-the-users). + +[Technical details and limitations](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md#privacy-technical-details-and-limitations). + +[How SimpleX is different from Session, Matrix, Signal, etc.](../README.md#frequently-asked-questions). + +## We ask you to help us pay for 3rd party security audit + +Our great news is that we have already signed the agreement and paid for the security audit! + +It is planned in October, and if there are no major issues we will publish this report straight away, otherwise - once we fix them. + +This is a major expense for use - over $20,000 - I would really appreciate if you could help us cover some part of this cost with the donations. + +Our promise to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We will be establishing a legal framework this year to ensure that it doesn't change if the ownership of SimpleX Chat Ltd changes at any future point. + +Please consider making a donation - it will help us to raise more funds. Donating any amount, even the price of the cup of coffee, would make a huge difference for us. + +It is possible to donate via: + +- [GitHub](https://github.com/sponsors/simplex-chat): it is commission-free for us. +- [OpenCollective](https://opencollective.com/simplex-chat): it also accepts donations in crypto-currencies, but charges a commission. +- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt + +Thank you, + +Evgeny + +SimpleX Chat founder diff --git a/blog/README.md b/blog/README.md index fd6bbdb853..b862e3074f 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,24 +1,32 @@ # Blog -Aug 8, 2022 [SimpleX Chat v3.1 released](./20220808-simplex-chat-v3.1-chat-groups.md) +Sep 1, 2022 [v3.2: Incognito mode](./20220901-simplex-chat-v3.2-incognito-mode.md) -- finally, secret chat groups! +- Incognito mode - use a new random profile name for each contact +- use .onion server addresses with Tor +- endless scrolling and search +- choose accent color and dark mode +- reduced APK size for direct download and in F-Droid repo from 200 to 46Mb! + +Implementation audit is arranged for October! + +Aug 8, 2022 [v3.1: chat groups](./20220808-simplex-chat-v3.1-chat-groups.md) + +- finally, secret chat groups - nobody but members know they exist! - access to messaging servers via Tor on all platforms - advanced network settings to optimize traffic usage - published chat protocol - new app icons -Jul 23, 2022 [SimpleX Chat v3.1-beta released](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md) +Jul 23, 2022 [v3.1-beta: access servers via Tor](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md) - terminal app: access to messaging servers via SOCKS5 proxy (e.g., Tor). - mobile apps: join and leave chat groups. - optimized battery and traffic usage - up to 90x reduction! - two docker configurations for self-hosted SMP servers. -Jul 11, 2022 [SimpleX Chat v3 released](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md): +Jul 11, 2022 [v3: instant push notifications for iOS and audio/video calls](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md): -- instant push notifications for iOS -- e2e encrypted WebRTC audio/video calls - chat database export and import - protocol privacy and performance improvements diff --git a/blog/images/20220901-incognito1.png b/blog/images/20220901-incognito1.png new file mode 100644 index 0000000000..71bb817dc0 Binary files /dev/null and b/blog/images/20220901-incognito1.png differ diff --git a/blog/images/20220901-incognito2.png b/blog/images/20220901-incognito2.png new file mode 100644 index 0000000000..2999db3fda Binary files /dev/null and b/blog/images/20220901-incognito2.png differ diff --git a/blog/images/20220901-incognito3.png b/blog/images/20220901-incognito3.png new file mode 100644 index 0000000000..ef6c0db6cc Binary files /dev/null and b/blog/images/20220901-incognito3.png differ diff --git a/blog/images/20220901-onion1.png b/blog/images/20220901-onion1.png new file mode 100644 index 0000000000..9ab4cc5a4d Binary files /dev/null and b/blog/images/20220901-onion1.png differ diff --git a/blog/images/20220901-onion2.png b/blog/images/20220901-onion2.png new file mode 100644 index 0000000000..e746381666 Binary files /dev/null and b/blog/images/20220901-onion2.png differ diff --git a/cabal.project b/cabal.project index 1b7635644a..78e56eb4fe 100644 --- a/cabal.project +++ b/cabal.project @@ -5,7 +5,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 7d99c4b35cf2dc531219bc83146b714c9bae429c + tag: f2c1455a2755e1275983dc154321fc0a5c0d7b17 source-repository-package type: git diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 379969bb59..a0c6ec5706 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -82,7 +82,7 @@ "memberId": {"ref": "base64url"}, "metadata": { "comment": "memberId must be present in all group message references, both for sent and received" - } + } } }, "fileInvitation": { @@ -156,7 +156,7 @@ "rtcExtraInfo": { "properties": { "rtcIceCandidates": {"type": "string"} - } + } } } }, @@ -210,7 +210,7 @@ "profile": {"ref": "profile"} } } - } + } }, "x.info.probe": { "properties": { diff --git a/docs/rfcs/2022-08-10-incognito-connections.md b/docs/rfcs/2022-08-10-incognito-connections.md new file mode 100644 index 0000000000..e25093cda4 --- /dev/null +++ b/docs/rfcs/2022-08-10-incognito-connections.md @@ -0,0 +1,51 @@ +# Incognito connections + +## Problems + +Allow users to connect with incognito profile using the same user account without exposing main profile - either with randomly generated per connection profile / no profile, or custom profile created by user per connection. The latter option involves designing complex UI for creating profile on connection and seems to detract from UX, so we consider first two options. + +## Proposal + +Add incognito mode determining whether newly created connections are incognito, and a switch on connection pages affecting current connection. + +Add API to turn incognito mode on/off - it is saved as part of ChatController state to allow terminal users to set it. We can save preference on mobile and set it on chat start. We can also persist it to database to carry across terminal sessions, but it seems unnecessary. + +Parameterize `AddContact`, `Connect` and `ConnectSimplex` API - create connection as incognito based on incognito mode and parameter, parameter is given preference. + +### Option 1 - random profile + +Add nullable field `custom_user_profile_id` to `connections` table - `incognitoProfile: Maybe Profile` in `Connection` type; when connection is created as incognito on API call, random profile is created and saved to `profiles` table. + +Incognito profile is created only with a display name, it can be: + +- Some prefix followed by sequence of random character/digits +- Passphrase-like (2-4 random words) +- A name from a dictionary(ies)? +- One of above chosen randomly. + +We could generate other parts of profile (picture?) but it's not necessary for MVP. + +When user initiates connection as incognito, incognito profile is sent as part of XInfo upon receiving CONF from joining user. + +### Option 2 - no profile + +Add `incognito` flag to `connections` table - `incognito: Bool` in `Connection` type. + +Instead of XInfo both in `Connect` API and on receiving CONF when initiating, send a message that doesn't contain profile, e.g. XOk. + +When saving connection profile (`saveConnInfo`) or processing contact request on receiving XOk / other message, contact generates a random profile for the user to distinguish from other connections. He is also able to mark this connection as incognito. + +### Considerations + +- Don't broadcast user profile updates to contacts with whom the user has established incognito connections. +- Add indication on chat info page that connection was established as incognito, show profile name so the user knows how the contact sees him. +- While profile names generated in option 1 may be distinguishable as incognito depending on generator, technically the fact that connection was established as incognito is not explicitly leaked, which is clear with option 2. +- We could offer same random profile generator on creating profiles, which would blend users with such profile as permanent and users who chose to connect with incognito profile to an observer (i.e. the fact that user chooses to be incognito for this specific connection is no longer leaked, just that he chose to be incognito generally). +- There's a use case for custom incognito profile created by user for a given connection in case user wants to hide the fact of incognito connection (leaked by distinguishable pattern in profile name or lack of profile), but it may better be solved by multi-profile. +- Send incognito profile when accepting contact requests in incognito mode? Parameterize API and give option in dialog? + +### Groups + +- If host used user's incognito connection when inviting, save same field marking group as incognito in `groups` table? +- Use incognito profile in XGrpMemInfo +- Allow host to create group in incognito profile - all connections with members are created as incognito? diff --git a/package.yaml b/package.yaml index e0e8dad315..5a86bdba40 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 3.1.0 +version: 3.2.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme @@ -31,6 +31,7 @@ dependencies: - network >= 3.1.2.7 && < 3.2 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* + - random >= 1.1 && < 1.3 - simple-logger == 0.1.* - simplexmq >= 3.0 - socks == 0.6.* diff --git a/scripts/android/build-android-bundle.sh b/scripts/android/build-android-bundle.sh new file mode 100755 index 0000000000..18aa03145a --- /dev/null +++ b/scripts/android/build-android-bundle.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh +# Safety measures +[ -n "$1" ] || exit 1 +set -eu + +tmp=$(mktemp -d -t) +libsim=$(cat "$1" | grep libsimplex) +libsup=$(cat "$1" | grep libsupport) +commit="${2:-nix-android}" + +# Clone simplex +git clone https://github.com/simplex-chat/simplex-chat "$tmp/simplex-chat" + +# Switch to nix-android branch +git -C "$tmp/simplex-chat" checkout "$commit" + +# Create missing folders +mkdir -p "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a" + +curl -sSf "$libsim" -o "$tmp/libsimplex.zip" +unzip -o "$tmp/libsimplex.zip" -d "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a" + +curl -sSf "$libsup" -o "$tmp/libsupport.zip" +unzip -o "$tmp/libsupport.zip" -d "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a" + +gradle -p "$tmp/simplex-chat/apps/android/" clean build +cp "$tmp/simplex-chat/apps/android/app/build/outputs/apk/release/app-release-unsigned.apk" "$PWD/simplex-chat.apk" diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 4edd9d42db..8db48eb551 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -4,8 +4,9 @@ set -eu u="$USER" tmp=$(mktemp -d -t) -commit="${1:-nix-android}" -commands="nix git gradle unzip curl" +source="github:simplex-chat/simplex-chat" +commit="$1" +commands="nix git curl gradle zip unzip zipalign" nix_install() { # Pre-setup nix @@ -69,18 +70,26 @@ checks() { build() { # Build simplex lib - nix build "$tmp/simplex-chat/#packages.x86_64-linux.aarch64-android:lib:simplex-chat" + nix build "$source/$commit#hydraJobs.aarch64-android:lib:simplex-chat.x86_64-linux" unzip -o "$PWD/result/pkg-aarch64-android-libsimplex.zip" -d "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a" # Build android suppprt lib - nix build "$tmp/simplex-chat/#packages.x86_64-linux.aarch64-android:lib:support" + nix build "$source/$commit#hydraJobs.aarch64-android:lib:support.x86_64-linux" unzip -o "$PWD/result/pkg-aarch64-android-libsupport.zip" -d "$tmp/simplex-chat/apps/android/app/src/main/cpp/libs/arm64-v8a" - gradle -p "$tmp/simplex-chat/apps/android/" clean build + sed -i.bak 's/${extract_native_libs}/true/' "$tmp/simplex-chat/apps/android/app/src/main/AndroidManifest.xml" + + gradle -p "$tmp/simplex-chat/apps/android/" clean build assembleRelease + + mkdir -p "$tmp/android" + unzip -oqd "$tmp/android/" "$tmp/simplex-chat/apps/android/app/build/outputs/apk/release/app-release-unsigned.apk" + + (cd "$tmp/android" && zip -rq5 "$tmp/simplex-chat.apk" . && zip -rq0 "$tmp/simplex-chat.apk" resources.arsc res) + + zipalign -p -f 4 "$tmp/simplex-chat.apk" "$PWD/simplex-chat.apk" } final() { - cp "$tmp/simplex-chat/apps/android/app/build/outputs/apk/release/app-release-unsigned.apk" "$PWD/simplex-chat.apk" printf "Simplex-chat was successfully compiled: %s/simplex-chat.apk\nDelete nix and gradle caches with 'rm -rf /nix && rm \$HOME/.nix* && \$HOME/.gradle/caches' in case if no longer needed.\n" "$PWD" } diff --git a/scripts/android/compress-and-sign-apk.sh b/scripts/android/compress-and-sign-apk.sh new file mode 100755 index 0000000000..1bc904af01 --- /dev/null +++ b/scripts/android/compress-and-sign-apk.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Fail fast in case any command fails +set -e + +level=$1 +apk_parent_dir=$2 +sdk_dir=$3 + +store_file=$4 +store_password=$5 +key_alias=$6 +key_password=$7 + +if [ -z ${7} ]; then echo "You didn't enter all required params: +compress-and-sign-apk.sh level apk_parent_dir sdk_dir store_file store_password key_alias key_password" +fi + +cd $apk_parent_dir + +ORIG_NAME=$(echo app*.apk) +unzip -o -q -d apk $ORIG_NAME + +rm $ORIG_NAME + +(cd apk && zip -r -q -$level ../$ORIG_NAME .) +# Shouldn't be compressed because of Android requirement +(cd apk && zip -r -q -0 ../$ORIG_NAME resources.arsc) +(cd apk && zip -r -q -0 ../$ORIG_NAME res) +#(cd apk && 7z a -r -mx=$level -tzip -x!resources.arsc ../$ORIG_NAME .) +#(cd apk && 7z a -r -mx=0 -tzip ../$ORIG_NAME resources.arsc) + +ALL_TOOLS=($sdk_dir/build-tools/*/) +BIN_DIR="${ALL_TOOLS[1]}" + +$BIN_DIR/zipalign -p -f 4 $ORIG_NAME $ORIG_NAME-2 + +mv $ORIG_NAME{-2,} + +$BIN_DIR/apksigner sign \ + --ks "$store_file" --ks-key-alias "$key_alias" --ks-pass "pass:$store_password" \ + --key-pass "pass:$key_password" $ORIG_NAME + +# cleanup +rm -rf apk || true +rm ${ORIG_NAME}.idsig 2> /dev/null || true diff --git a/scripts/android/lib.txt b/scripts/android/lib.txt new file mode 100644 index 0000000000..ae196262c9 --- /dev/null +++ b/scripts/android/lib.txt @@ -0,0 +1,2 @@ +https://ci.zw3rk.com/build/494539/download/1/pkg-aarch64-android-libsimplex.zip +https://ci.zw3rk.com/build/482524/download/1/pkg-aarch64-android-libsupport.zip diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 64d384c741..21d0d67735 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."7d99c4b35cf2dc531219bc83146b714c9bae429c" = "037a0p7cdi4lrsbh21b4gldwdcj1sk8wz4wjsph6bnv2jyid11gk"; + "https://github.com/simplex-chat/simplexmq.git"."f2c1455a2755e1275983dc154321fc0a5c0d7b17" = "10l74d751jmgsr0ifyprglsvqdpcir86qs1vkwc4dn4n4q503p5q"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f88141d07c..c64ea90a6e 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: 3.1.0 +version: 3.2.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -42,8 +42,15 @@ library 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.Mobile Simplex.Chat.Options + Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Store Simplex.Chat.Styled @@ -79,6 +86,7 @@ library , network >=3.1.2.7 && <3.2 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* + , random >=1.1 && <1.3 , simple-logger ==0.1.* , simplexmq >=3.0 , socks ==0.6.* @@ -119,6 +127,7 @@ executable simplex-bot , network >=3.1.2.7 && <3.2 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* + , random >=1.1 && <1.3 , simple-logger ==0.1.* , simplex-chat , simplexmq >=3.0 @@ -160,6 +169,7 @@ executable simplex-bot-advanced , network >=3.1.2.7 && <3.2 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* + , random >=1.1 && <1.3 , simple-logger ==0.1.* , simplex-chat , simplexmq >=3.0 @@ -202,6 +212,7 @@ executable simplex-chat , network ==3.1.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* + , random >=1.1 && <1.3 , simple-logger ==0.1.* , simplex-chat , simplexmq >=3.0 @@ -253,6 +264,7 @@ test-suite simplex-chat-test , network ==3.1.* , optparse-applicative >=0.15 && <0.17 , process ==1.6.* + , random >=1.1 && <1.3 , simple-logger ==0.1.* , simplex-chat , simplexmq >=3.0 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 676ab347fe..94d67e3f22 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -31,13 +31,12 @@ import Data.Either (fromRight) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, isSuffixOf, sortBy, sortOn) +import Data.List (find, isSuffixOf, sortOn) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) -import Data.Ord (comparing) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds) @@ -51,6 +50,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Markdown import Simplex.Chat.Messages import Simplex.Chat.Options +import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Types @@ -99,19 +99,20 @@ defaultChatConfig = fileChunkSize = 15780, subscriptionConcurrency = 16, subscriptionEvents = False, + hostEvents = False, testView = False } _defaultSMPServers :: NonEmpty SMPServer _defaultSMPServers = L.fromList - [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im", - "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im", - "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im" + [ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion", + "smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im,jssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion", + "smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im,rb2pbttocvnbrngnwziclp2f4ckjq65kebafws6g4hy22cdaiv5dwjqd.onion" ] _defaultNtfServers :: [NtfServer] -_defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im"] +_defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"] maxImageSize :: Integer maxImageSize = 236700 @@ -123,9 +124,9 @@ logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController -newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, smpServers, networkConfig, logConnections} sendToast = do +newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, defaultServers} ChatOpts {dbFilePrefix, smpServers, networkConfig, logConnections, logServerHosts} sendToast = do let f = chatStoreFile dbFilePrefix - config = cfg {subscriptionEvents = logConnections} + config = cfg {subscriptionEvents = logConnections, hostEvents = logServerHosts} sendNotification = fromMaybe (const $ pure ()) sendToast activeTo <- newTVarIO ActiveNone firstTime <- not <$> doesFileExist f @@ -142,8 +143,9 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize, de rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty filesFolder <- newTVarIO Nothing + incognitoMode <- newTVarIO False chatStoreChanged <- newTVarIO False - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder} where resolveServers :: InitialAgentServers -> IO InitialAgentServers resolveServers ss@InitialAgentServers {smp = defaultSMPServers} = case nonEmpty smpServers of @@ -232,13 +234,17 @@ processChatCommand = \case ff <- asks filesFolder atomically . writeTVar ff $ Just filesFolder' pure CRCmdOk + SetIncognito onOff -> do + incognito <- asks incognitoMode + atomically . writeTVar incognito $ onOff + pure CRCmdOk APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk APIImportArchive cfg -> checkChatStopped $ importArchive cfg >> setStoreChanged $> CRCmdOk APIDeleteStorage -> checkChatStopped $ deleteStorage >> setStoreChanged $> CRCmdOk APIGetChats withPCC -> CRApiChats <$> withUser (\user -> withStore' $ \db -> getChatPreviews db user withPCC) - APIGetChat (ChatRef cType cId) pagination -> withUser $ \user -> case cType of - CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\db -> getDirectChat db user cId pagination) - CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination) + APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of + CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\db -> getDirectChat db user cId pagination search) + CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search) CTContactRequest -> pure $ chatCmdError "not implemented" CTContactConnection -> pure $ chatCmdError "not supported" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" @@ -253,16 +259,14 @@ processChatCommand = \case pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci where setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd)) - setupSndFileTransfer ct = case file_ of - Nothing -> pure Nothing - Just file -> do - (fileSize, chSize) <- checkSndFile file - (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) - let fileName = takeFileName file - fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq} - fileId <- withStore' $ \db -> createSndFileTransfer db userId ct file fileInvitation agentConnId chSize - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} - pure $ Just (fileInvitation, ciFile) + setupSndFileTransfer ct = forM file_ $ \file -> do + (fileSize, chSize) <- checkSndFile file + (agentConnId, fileConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation + let fileName = takeFileName file + fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq} + fileId <- withStore' $ \db -> createSndFileTransfer db userId ct file fileInvitation agentConnId chSize + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} + pure (fileInvitation, ciFile) prepareMsg :: Maybe FileInvitation -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) prepareMsg fileInvitation_ = case quotedItemId_ of Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) @@ -290,15 +294,13 @@ processChatCommand = \case pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci where setupSndFileTransfer :: GroupInfo -> m (Maybe (FileInvitation, CIFile 'MDSnd)) - setupSndFileTransfer gInfo = case file_ of - Nothing -> pure Nothing - Just file -> do - (fileSize, chSize) <- checkSndFile file - let fileName = takeFileName file - fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} - fileId <- withStore' $ \db -> createSndGroupFileTransfer db userId gInfo file fileInvitation chSize - let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} - pure $ Just (fileInvitation, ciFile) + setupSndFileTransfer gInfo = forM file_ $ \file -> do + (fileSize, chSize) <- checkSndFile file + let fileName = takeFileName file + fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} + fileId <- withStore' $ \db -> createSndGroupFileTransfer db userId gInfo file fileInvitation chSize + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} + pure (fileInvitation, ciFile) prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) prepareMsg fileInvitation_ membership = case quotedItemId_ of Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) @@ -507,8 +509,8 @@ processChatCommand = \case forM_ call_ $ \call -> updateCallItemStatus userId ct call WCSDisconnected Nothing toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci pure CRCmdOk - SendCallInvitation cName callType -> withUser $ \User {userId} -> do - contactId <- withStore $ \db -> getContactIdByName db userId cName + SendCallInvitation cName callType -> withUser $ \user -> do + contactId <- withStore $ \db -> getContactIdByName db user cName processChatCommand $ APISendCallInvitation contactId callType APIRejectCall contactId -> -- party accepting call @@ -576,6 +578,11 @@ processChatCommand = \case withCurrentCall contactId $ \userId ct call -> updateCallItemStatus userId ct call receivedStatus Nothing $> Just call APIUpdateProfile profile -> withUser (`updateProfile` profile) + APISetContactAlias contactId localAlias -> withUser $ \User {userId} -> do + ct' <- withStore $ \db -> do + ct <- getContact db userId contactId + liftIO $ updateContactAlias db userId ct localAlias + pure $ CRContactAliasUpdated ct' APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIGetNtfToken -> withUser $ \_ -> crNtfToken <$> withAgent getNtfToken APIRegisterToken token mode -> CRNtfTokenStatus <$> withUser (\_ -> withAgent $ \a -> registerNtfToken a token mode) @@ -595,14 +602,41 @@ processChatCommand = \case pure CRCmdOk APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig) + APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user@User {userId} -> case cType of + CTDirect -> do + ct <- withStore $ \db -> do + ct <- getContact db userId chatId + liftIO $ updateContactSettings db user chatId chatSettings + pure ct + withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings) + pure CRCmdOk + CTGroup -> do + ms <- withStore $ \db -> do + Group _ ms <- getGroup db user chatId + liftIO $ updateGroupSettings db user chatId chatSettings + pure ms + forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> + withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError) + pure CRCmdOk + _ -> pure $ chatCmdError "not supported" APIContactInfo contactId -> withUser $ \User {userId} -> do - ct <- withStore $ \db -> getContact db userId contactId - CRContactInfo ct <$> withAgent (`getConnectionServers` contactConnId ct) + -- [incognito] print user's incognito profile for this contact + ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db userId contactId + incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + connectionStats <- withAgent (`getConnectionServers` contactConnId ct) + pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile) APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId - CRGroupMemberInfo g m <$> mapM (withAgent . flip getConnectionServers) (memberConnId m) - ContactInfo cName -> withUser $ \User {userId} -> do - contactId <- withStore $ \db -> getContactIdByName db userId cName + connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) + pure $ CRGroupMemberInfo g m connectionStats + ShowMessages (ChatName cType name) ntfOn -> withUser $ \user -> do + chatId <- case cType of + CTDirect -> withStore $ \db -> getContactIdByName db user name + CTGroup -> withStore $ \db -> getGroupIdByName db user name + _ -> throwChatError $ CECommandError "not supported" + processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ ChatSettings ntfOn + ContactInfo cName -> withUser $ \user -> do + contactId <- withStore $ \db -> getContactIdByName db user cName processChatCommand $ APIContactInfo contactId GroupMemberInfo gName mName -> withUser $ \user -> do (gId, mId) <- withStore $ \db -> getGroupIdByName db user gName >>= \gId -> (gId,) <$> getGroupMemberIdByName db user gId mName @@ -610,29 +644,38 @@ processChatCommand = \case ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do - (connId, cReq) <- withAgent (`createConnection` SCMInvitation) - conn <- withStore' $ \db -> createDirectConnection db userId connId ConnNew + -- [incognito] generate profile for connection + incognito <- readTVarIO =<< asks incognitoMode + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + (connId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation + conn <- withStore' $ \db -> createDirectConnection db userId connId ConnNew incognitoProfile toView $ CRNewContactConnection conn pure $ CRInvitation cReq Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \User {userId, profile} -> withChatLock . procCmd $ do - connId <- withAgent $ \a -> joinConnection a cReq . directMessage $ XInfo profile - conn <- withStore' $ \db -> createDirectConnection db userId connId ConnJoined + -- [incognito] generate profile to send + incognito <- readTVarIO =<< asks incognitoMode + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile + connId <- withAgent $ \a -> joinConnection a True cReq . directMessage $ XInfo profileToSend + conn <- withStore' $ \db -> createDirectConnection db userId connId ConnJoined incognitoProfile toView $ CRNewContactConnection conn pure CRSentConfirmation Connect (Just (ACR SCMContact cReq)) -> withUser $ \User {userId, profile} -> - connectViaContact userId cReq profile + -- [incognito] generate profile to send + connectViaContact userId cReq $ fromLocalProfile profile Connect Nothing -> throwChatError CEInvalidConnReq ConnectSimplex -> withUser $ \User {userId, profile} -> - connectViaContact userId adminContactReq profile - DeleteContact cName -> withUser $ \User {userId} -> do - contactId <- withStore $ \db -> getContactIdByName db userId cName + -- [incognito] generate profile to send + connectViaContact userId adminContactReq $ fromLocalProfile profile + DeleteContact cName -> withUser $ \user -> do + contactId <- withStore $ \db -> getContactIdByName db user cName processChatCommand $ APIDeleteChat (ChatRef CTDirect contactId) - ClearContact cName -> withUser $ \User {userId} -> do - contactId <- withStore $ \db -> getContactIdByName db userId cName + ClearContact cName -> withUser $ \user -> do + contactId <- withStore $ \db -> getContactIdByName db user cName processChatCommand $ APIClearChat (ChatRef CTDirect contactId) ListContacts -> withUser $ \user -> CRContactsList <$> withStore' (`getUserContacts` user) CreateMyAddress -> withUser $ \User {userId} -> withChatLock . procCmd $ do - (connId, cReq) <- withAgent (`createConnection` SCMContact) + (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact withStore $ \db -> createUserContactLink db userId connId cReq pure $ CRUserContactLinkCreated cReq DeleteMyAddress -> withUser $ \user -> withChatLock $ do @@ -669,8 +712,8 @@ processChatCommand = \case ) `catchError` (toView . CRChatError) CRBroadcastSent mc (length cts) <$> liftIO getZonedTime - SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \User {userId} -> do - contactId <- withStore $ \db -> getContactIdByName db userId cName + SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do + contactId <- withStore $ \db -> getContactIdByName db user cName quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg processChatCommand . APISendMessage (ChatRef CTDirect contactId) $ ComposedMessage Nothing (Just quotedItemId) mc @@ -685,40 +728,45 @@ processChatCommand = \case processChatCommand $ APIUpdateChatItem chatRef editedItemId mc NewGroup gProfile -> withUser $ \user -> do gVar <- asks idsDrg - CRGroupCreated <$> withStore (\db -> createNewGroup db gVar user gProfile) + groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile) + pure $ CRGroupCreated groupInfo APIAddMember groupId contactId memRole -> withUser $ \user@User {userId} -> withChatLock $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db userId contactId let Group gInfo@GroupInfo {localDisplayName, groupProfile, membership} members = group GroupMember {memberRole = userRole, memberId = userMemberId} = membership Contact {localDisplayName = cName} = contact + -- [incognito] forbid to invite contact to whom user is connected incognito + when (contactConnIncognito contact) $ throwChatError CEContactIncognitoCantInvite + -- [incognito] forbid to invite contacts if user joined the group using an incognito profile + when (memberIncognito membership) $ throwChatError CEGroupIncognitoCantInvite when (userRole < GRAdmin || userRole < memRole) $ throwChatError CEGroupUserRole when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined gInfo) unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - let sendInvitation groupMemberId memberId cReq = do + let sendInvitation member@GroupMember {groupMemberId, memberId} cReq = do let groupInv = GroupInvitation (MemberIdRole userMemberId userRole) (MemberIdRole memberId memRole) cReq groupProfile msg <- sendDirectContactMessage contact $ XGrpInv groupInv let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd contact) msg content Nothing Nothing toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci setActive $ ActiveG localDisplayName - pure $ CRSentGroupInvitation gInfo contact + pure $ CRSentGroupInvitation gInfo contact member case contactMember contact members of Nothing -> do gVar <- asks idsDrg - (agentConnId, cReq) <- withAgent (`createConnection` SCMInvitation) - GroupMember {memberId, groupMemberId} <- withStore $ \db -> createContactMember db gVar user groupId contact memRole agentConnId cReq - sendInvitation groupMemberId memberId cReq - Just GroupMember {groupMemberId, memberId, memberStatus} + (agentConnId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation + member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq + sendInvitation member cReq + Just member@GroupMember {groupMemberId, memberStatus} | memberStatus == GSMemInvited -> withStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case - Just cReq -> sendInvitation groupMemberId memberId cReq + Just cReq -> sendInvitation member cReq Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId withChatLock . procCmd $ do - agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership :: GroupMember) + agentConnId <- withAgent $ \a -> joinConnection a True connRequest . directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId updateGroupMemberStatus db userId fromMember GSMemAccepted @@ -749,7 +797,7 @@ processChatCommand = \case withStore' $ \db -> deleteGroupMember db user m _ -> do msg <- sendGroupMessage gInfo members $ XGrpMemDel mId - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId memberProfile) Nothing Nothing + ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) Nothing Nothing toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci deleteMemberConnection m withStore' $ \db -> updateGroupMemberStatus db userId m GSMemRemoved @@ -765,8 +813,8 @@ processChatCommand = \case withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser gInfo {membership = membership {memberStatus = GSMemLeft}} APIListMembers groupId -> CRGroupMembers <$> withUser (\user -> withStore (\db -> getGroup db user groupId)) - AddMember gName cName memRole -> withUser $ \user@User {userId} -> do - (groupId, contactId) <- withStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db userId cName + AddMember gName cName memRole -> withUser $ \user -> do + (groupId, contactId) <- withStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole JoinGroup gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName @@ -812,7 +860,7 @@ processChatCommand = \case processChatCommand . APISendMessage (ChatRef CTGroup groupId) $ ComposedMessage Nothing (Just quotedItemId) mc LastMessages (Just chatName) count -> withUser $ \user -> do chatRef <- getChatRef user chatName - CRLastMessages . aChatItems . chat <$> (processChatCommand . APIGetChat chatRef $ CPLast count) + CRLastMessages . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) Nothing) LastMessages Nothing count -> withUser $ \user -> withStore $ \db -> CRLastMessages <$> getAllChatItems db user (CPLast count) SendFile chatName f -> withUser $ \user -> do @@ -860,12 +908,12 @@ processChatCommand = \case pure $ CRRcvFileCancelled ftr FileStatus fileId -> CRFileTransferStatus <$> withUser (\user -> withStore $ \db -> getFileTransferProgress db user fileId) - ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile + ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile (fromLocalProfile profile) UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do - let p = (profile :: Profile) {displayName = displayName, fullName = fullName} + let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName} updateProfile user p UpdateProfileImage image -> withUser $ \user@User {profile} -> do - let p = (profile :: Profile) {image} + let p = (fromLocalProfile profile :: Profile) {image} updateProfile user p QuitChat -> liftIO exitSuccess ShowVersion -> pure $ CRVersionInfo versionNumber @@ -887,9 +935,9 @@ processChatCommand = \case procCmd :: m ChatResponse -> m ChatResponse procCmd = id getChatRef :: User -> ChatName -> m ChatRef - getChatRef user@User {userId} (ChatName cType name) = + getChatRef user (ChatName cType name) = ChatRef cType <$> case cType of - CTDirect -> withStore $ \db -> getContactIdByName db userId name + CTDirect -> withStore $ \db -> getContactIdByName db user name CTGroup -> withStore $ \db -> getGroupIdByName db user name _ -> throwChatError $ CECommandError "not supported" checkChatStopped :: m ChatResponse -> m ChatResponse @@ -909,10 +957,18 @@ processChatCommand = \case (_, xContactId_) -> procCmd $ do let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) xContactId <- maybe randomXContactId pure xContactId_ - connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) - conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId + -- [incognito] generate profile to send + -- if user makes a contact request using main profile, then turns on incognito mode and repeats the request, + -- an incognito profile will be sent even though the address holder will have user's main profile received as well; + -- we ignore this edge case as we already allow profile updates on repeat contact requests; + -- alternatively we can re-send the main profile even if incognito mode is enabled + incognito <- readTVarIO =<< asks incognitoMode + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + let profileToSend = fromMaybe profile incognitoProfile + connId <- withAgent $ \a -> joinConnection a True cReq $ directMessage (XContact profileToSend $ Just xContactId) + conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile toView $ CRNewContactConnection conn - pure CRSentInvitation + pure $ CRSentInvitation incognitoProfile contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -923,17 +979,20 @@ processChatCommand = \case unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f (,) <$> getFileSize fsFilePath <*> asks (fileChunkSize . config) updateProfile :: User -> Profile -> m ChatResponse - updateProfile user@User {profile = p} p'@Profile {displayName} - | p' == p = pure CRUserProfileNoChange + updateProfile user@User {profile = p@LocalProfile {profileId, localAlias}} p'@Profile {displayName} + | p' == fromLocalProfile p = pure CRUserProfileNoChange | otherwise = do withStore $ \db -> updateUserProfile db user p' - let user' = (user :: User) {localDisplayName = displayName, profile = p'} + let user' = (user :: User) {localDisplayName = displayName, profile = toLocalProfile profileId p' localAlias} asks currentUser >>= atomically . (`writeTVar` Just user') - contacts <- filter isReady <$> withStore' (`getUserContacts` user) + -- [incognito] filter out contacts with whom user has incognito connections + contacts <- + filter (\ct -> isReady ct && not (contactConnIncognito ct)) + <$> withStore' (`getUserContacts` user) withChatLock . procCmd $ do forM_ contacts $ \ct -> void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError) - pure $ CRUserProfileUpdated p p' + pure $ CRUserProfileUpdated (fromLocalProfile p) p' isReady :: Contact -> Bool isReady ct = let s = connStatus $ activeConn (ct :: Contact) @@ -1055,7 +1114,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F case fileConnReq of -- direct file protocol Just connReq -> - tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fName) >>= \case + tryError (withAgent $ \a -> joinConnection a True connReq . directMessage $ XFileAcpt fName) >>= \case Right agentConnId -> do filePath <- getRcvFilePath filePath_ fName withStore $ \db -> acceptRcvFileTransfer db user fileId agentConnId ConnJoined filePath @@ -1069,7 +1128,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F case activeConn of Just conn -> do sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) + (agentConnId, fileInvConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation filePath <- getRcvFilePath filePath_ fName ci <- withStore $ \db -> acceptRcvFileTransfer db user fileId agentConnId ConnNew filePath void $ sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) @@ -1116,8 +1175,12 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} = do - connId <- withAgent $ \a -> acceptContact a invId . directMessage $ XInfo profile - withStore' $ \db -> createAcceptedContact db userId connId cName profileId p userContactLinkId xContactId + -- [incognito] generate profile to send, create connection with incognito profile + incognito <- readTVarIO =<< asks incognitoMode + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + let profileToSend = fromMaybe (fromLocalProfile profile) incognitoProfile + connId <- withAgent $ \a -> acceptContact a True invId . directMessage $ XInfo profileToSend + withStore' $ \db -> createAcceptedContact db userId connId cName profileId p userContactLinkId xContactId incognitoProfile agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () agentSubscriber = do @@ -1191,7 +1254,7 @@ subscribeUserConnections agentBatchSubscribe user = do groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () groupSubsToView rs gs ms ce = do mapM_ groupSub $ - sortBy (comparing $ \(Group GroupInfo {localDisplayName = g} _) -> g) gs + sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs toView . CRMemberSubSummary $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms @@ -1247,15 +1310,18 @@ subscribeUserConnections agentBatchSubscribe user = do processAgentMessage :: forall m. ChatMonad m => Maybe User -> ConnId -> ACommand 'Agent -> m () processAgentMessage Nothing _ _ = throwChatError CENoActiveUser processAgentMessage (Just User {userId}) "" agentMessage = case agentMessage of + CONNECT p h -> hostEvent $ CRHostConnected p h + DISCONNECT p h -> hostEvent $ CRHostDisconnected p h DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" SUSPENDED -> toView CRChatSuspended _ -> pure () where - serverEvent srv@(SMPServer host port _) conns event str = do + hostEvent = whenM (asks $ hostEvents . config) . toView + serverEvent srv@(SMPServer host _ _) conns event str = do cs <- withStore' $ \db -> getConnectionsContacts db userId conns toView $ event srv cs - showToast ("server " <> str) (safeDecodeUtf8 . strEncode $ SrvLoc host port) + showToast ("server " <> str) (safeDecodeUtf8 $ strEncode host) processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage = (withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus) >>= \case RcvDirectMsgConnection conn contact_ -> @@ -1289,11 +1355,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> Nothing processDirectMessage :: ACommand 'Agent -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg conn@Connection {connId, viaUserContactLink} = \case + processDirectMessage agentMsg conn@Connection {connId, viaUserContactLink, customUserProfileId} = \case Nothing -> case agentMsg of CONF confId _ connInfo -> do + -- [incognito] send saved profile + incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + let profileToSend = fromLocalProfile $ fromMaybe profile incognitoProfile saveConnInfo conn connInfo - allowAgentConnection conn confId $ XInfo profile + allowAgentConnection conn confId $ XInfo profileToSend INFO connInfo -> saveConnInfo conn connInfo MSG meta _msgFlags msgBody -> do @@ -1354,7 +1423,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage CON -> withStore' (\db -> getViaGroupMember db user ct) >>= \case Nothing -> do - toView $ CRContactConnected ct + -- [incognito] print incognito profile used for this contact + incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + toView $ CRContactConnected ct (fmap fromLocalProfile incognitoProfile) setActive $ ActiveC c showToast (c <> "> ") "connected" forM_ viaUserContactLink $ \userContactLinkId -> do @@ -1364,10 +1435,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) Nothing Nothing toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci _ -> pure () - Just (gInfo, m@GroupMember {activeConn}) -> do + Just (gInfo@GroupInfo {membership}, m@GroupMember {activeConn}) -> do when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m - when (memberCategory m == GCPreMember) $ probeMatchingContacts ct + let connectedIncognito = contactConnIncognito ct || memberIncognito membership + when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito SENT msgId -> do sentMsgDeliveryEvent conn msgId withStore' (\db -> getDirectChatItemByAgentMsgId db userId contactId connId msgId) >>= \case @@ -1382,17 +1454,15 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- TODO print errors MERR msgId err -> do chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId - case chatItemId_ of - Nothing -> pure () - Just chatItemId -> do - chatItem <- withStore $ \db -> updateDirectChatItemStatus db userId contactId chatItemId (agentErrToItemStatus err) - toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + forM_ chatItemId_ $ \chatItemId -> do + chatItem <- withStore $ \db -> updateDirectChatItemStatus db userId contactId chatItemId (agentErrToItemStatus err) + toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) ERR err -> toView . CRChatError $ ChatErrorAgent err -- TODO add debugging output _ -> pure () processGroupMessage :: ACommand 'Agent -> Connection -> GroupInfo -> GroupMember -> m () - processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership} m = case agentMsg of + processGroupMessage agentMsg conn gInfo@GroupInfo {groupId, localDisplayName = gName, membership, chatSettings} m = case agentMsg of CONF confId _ connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case memberCategory m of @@ -1400,7 +1470,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage case chatMsgEvent of XGrpAcpt memId | sameMemberId memId m -> do - withStore' $ \db -> updateGroupMemberStatus db userId m GSMemAccepted + withStore $ \db -> liftIO $ updateGroupMemberStatus db userId m GSMemAccepted allowAgentConnection conn confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" _ -> messageError "CONF from invited member must have x.grp.acpt" @@ -1409,7 +1479,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XGrpMemInfo memId _memProfile | sameMemberId memId m -> do -- TODO update member profile - allowAgentConnection conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) profile + allowAgentConnection conn confId $ XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership) | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" _ -> messageError "CONF from member must have x.grp.mem.info" INFO connInfo -> do @@ -1430,6 +1500,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage unless (memberActive membership) $ updateGroupMemberStatus db userId membership GSMemConnected sendPendingGroupMessages m conn + unless (enableNtfs chatSettings) . withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) False case memberCategory m of GCHostMember -> do memberConnectedChatItem gInfo m @@ -1456,7 +1527,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage Just ct@Contact {activeConn = Connection {connStatus}} -> when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m - when (memberCategory m == GCPreMember) $ probeMatchingContacts ct + let connectedIncognito = contactConnIncognito ct || memberIncognito membership + when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito MSG msgMeta _msgFlags msgBody -> do msg@RcvMessage {chatMsgEvent} <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody withAckMessage agentConnId msgMeta $ @@ -1643,14 +1715,17 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" - probeMatchingContacts :: Contact -> m () - probeMatchingContacts ct = do + probeMatchingContacts :: Contact -> Bool -> m () + probeMatchingContacts ct connectedIncognito = do gVar <- asks idsDrg (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId ct void . sendDirectContactMessage ct $ XInfoProbe probe - cs <- withStore' $ \db -> getMatchingContacts db userId ct - let probeHash = ProbeHash $ C.sha256Hash (unProbe probe) - forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ()) + if connectedIncognito + then withStore' $ \db -> deleteSentProbe db userId probeId + else do + cs <- withStore' $ \db -> getMatchingContacts db userId ct + let probeHash = ProbeHash $ C.sha256Hash (unProbe probe) + forM_ cs $ \c -> sendProbeHash c probeHash probeId `catchError` const (pure ()) where sendProbeHash :: Contact -> ProbeHash -> Int64 -> m () sendProbeHash c probeHash probeId = do @@ -1664,24 +1739,23 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage messageError = toView . CRMessageError "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newContentMessage ct@Contact {localDisplayName = c} mc msg msgMeta = do + newContentMessage ct@Contact {localDisplayName = c, chatSettings} mc msg msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc ciFile_ <- processFileInvitation fileInvitation_ $ \fi chSize -> withStore' $ \db -> createRcvFileTransfer db userId ct fi chSize ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) ciFile_ toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - showMsgToast (c <> "> ") content formattedText + when (enableNtfs chatSettings) $ showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c processFileInvitation :: Maybe FileInvitation -> (FileInvitation -> Integer -> m RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv)) - processFileInvitation fileInvitation_ createRcvFileTransferF = case fileInvitation_ of - Nothing -> pure Nothing - Just fileInvitation@FileInvitation {fileName, fileSize} -> do + processFileInvitation fileInvitation_ createRcvFileTransferF = + forM fileInvitation_ $ \fileInvitation@FileInvitation {fileName, fileSize} -> do chSize <- asks $ fileChunkSize . config RcvFileTransfer {fileId} <- createRcvFileTransferF fileInvitation chSize let ciFile = CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} - pure $ Just ciFile + pure ciFile messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta = do @@ -1692,9 +1766,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage -- This patches initial sharedMsgId into chat item when locally deleted chat item -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... - ci@ChatItem {formattedText} <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta (CIRcvMsgContent mc) Nothing + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta (CIRcvMsgContent mc) Nothing toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - showMsgToast (c <> "> ") mc formattedText setActive $ ActiveC c _ -> throwError e where @@ -1721,14 +1794,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do + newGroupContentMessage gInfo@GroupInfo {chatSettings} m@GroupMember {localDisplayName = c} mc msg msgMeta = do let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc ciFile_ <- processFileInvitation fileInvitation_ $ \fi chSize -> withStore' $ \db -> createRcvGroupFileTransfer db userId m fi chSize ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) ciFile_ groupMsgToView gInfo m ci msgMeta let g = groupName' gInfo - showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText + when (enableNtfs chatSettings) $ showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText setActive $ ActiveG g groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> m () @@ -1814,7 +1887,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage unless cancelled $ if fName == fileName then - tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case + tryError (withAgent $ \a -> joinConnection a True fileConnReq . directMessage $ XOk) >>= \case Right acId -> withStore' $ \db -> createSndGroupFileTransferConnection db userId fileId acId m Left e -> throwError e @@ -1826,11 +1899,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () - processGroupInvitation ct@Contact {localDisplayName = c} inv@(GroupInvitation (MemberIdRole fromMemId fromRole) (MemberIdRole memId memRole) _ _) msg msgMeta = do + processGroupInvitation ct@Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole)} msg msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId - gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv + -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile + gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = GroupMember {groupMemberId}} <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content Nothing withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) @@ -1853,23 +1927,27 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView $ CRNewChatItem $ AChatItem (chatTypeI @c) SMDRcv (toChatInfo cd) ci xInfo :: Contact -> Profile -> m () - xInfo c@Contact {profile = p} p' = unless (p == p') $ do + xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do c' <- withStore $ \db -> updateContactProfile db userId c p' toView $ CRContactUpdated c c' xInfoProbe :: Contact -> Probe -> m () - xInfoProbe c2 probe = do - r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe - forM_ r $ \c1 -> probeMatch c1 c2 probe + xInfoProbe c2 probe = + -- [incognito] unless connected incognito + unless (contactConnIncognito c2) $ do + r <- withStore' $ \db -> matchReceivedProbe db userId c2 probe + forM_ r $ \c1 -> probeMatch c1 c2 probe xInfoProbeCheck :: Contact -> ProbeHash -> m () - xInfoProbeCheck c1 probeHash = do - r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash - forM_ r . uncurry $ probeMatch c1 + xInfoProbeCheck c1 probeHash = + -- [incognito] unless connected incognito + unless (contactConnIncognito c1) $ do + r <- withStore' $ \db -> matchReceivedProbeHash db userId c1 probeHash + forM_ r . uncurry $ probeMatch c1 probeMatch :: Contact -> Contact -> Probe -> m () probeMatch c1@Contact {profile = p1} c2@Contact {profile = p2} probe = - when (p1 == p2) $ do + when (fromLocalProfile p1 == fromLocalProfile p2) $ do void . sendDirectContactMessage c1 $ XInfoProbeOk probe mergeContacts c1 c2 @@ -2005,16 +2083,18 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage toView $ CRJoinedGroupMemberConnecting gInfo m newMember xGrpMemIntro :: Connection -> GroupInfo -> GroupMember -> MemberInfo -> m () - xGrpMemIntro conn gInfo@GroupInfo {groupId} m memInfo@(MemberInfo memId _ _) = do + xGrpMemIntro conn gInfo@GroupInfo {groupId, membership} m memInfo@(MemberInfo memId _ _) = do case memberCategory m of GCHostMember -> do members <- withStore' $ \db -> getGroupMembers db user gInfo if isMember memId gInfo members then messageWarning "x.grp.mem.intro ignored: member already exists" else do - (groupConnId, groupConnReq) <- withAgent (`createConnection` SCMInvitation) - (directConnId, directConnReq) <- withAgent (`createConnection` SCMInvitation) - newMember <- withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnId directConnId + (groupConnId, groupConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation + (directConnId, directConnReq) <- withAgent $ \a -> createConnection a True SCMInvitation + -- [incognito] direct connection with member has to be established using the same incognito profile [that was known to host and used for group membership] + let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing + newMember <- withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnId directConnId customUserProfileId let msg = XGrpMemInv memId IntroInvitation {groupConnReq, directConnReq} void $ sendDirectMessage conn msg (GroupId groupId) withStore' $ \db -> updateGroupMemberStatus db userId newMember GSMemIntroInvited @@ -2043,10 +2123,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage Nothing -> withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced Just m' -> pure m' withStore' $ \db -> saveMemberInvitation db toMember introInv - let msg = XGrpMemInfo (memberId (membership :: GroupMember)) profile - groupConnId <- withAgent $ \a -> joinConnection a groupConnReq $ directMessage msg - directConnId <- withAgent $ \a -> joinConnection a directConnReq $ directMessage msg - withStore' $ \db -> createIntroToMemberContact db userId m toMember groupConnId directConnId + -- [incognito] send membership incognito profile, create direct connection as incognito + let msg = XGrpMemInfo (memberId (membership :: GroupMember)) (fromLocalProfile $ memberProfile membership) + groupConnId <- withAgent $ \a -> joinConnection a True groupConnReq $ directMessage msg + directConnId <- withAgent $ \a -> joinConnection a True directConnReq $ directMessage msg + let customUserProfileId = if memberIncognito membership then Just (localProfileId $ memberProfile membership) else Nothing + withStore' $ \db -> createIntroToMemberContact db userId m toMember groupConnId directConnId customUserProfileId xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> RcvMessage -> MsgMeta -> m () xGrpMemDel gInfo@GroupInfo {membership} m memId msg msgMeta = do @@ -2067,7 +2149,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage else do deleteMemberConnection member withStore' $ \db -> updateGroupMemberStatus db userId member GSMemRemoved - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberDeleted groupMemberId memberProfile) Nothing + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile)) Nothing groupMsgToView gInfo m ci msgMeta toView $ CRDeletedMember gInfo m member {memberStatus = GSMemRemoved} @@ -2384,7 +2466,7 @@ getCreateActiveUser st = do withTransaction st (`setActiveUser` userId user) pure user userStr :: User -> String - userStr User {localDisplayName, profile = Profile {fullName}} = + userStr User {localDisplayName, profile = LocalProfile {fullName}} = T.unpack $ localDisplayName <> if T.null fullName || localDisplayName == fullName then "" else " (" <> fullName <> ")" getContactName :: IO ContactName getContactName = do @@ -2446,7 +2528,9 @@ withStore action = do chatCommandP :: Parser ChatCommand chatCommandP = A.choice - [ ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile), + [ "/mute " *> ((`ShowMessages` False) <$> chatNameP'), + "/unmute " *> ((`ShowMessages` True) <$> chatNameP'), + ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile), ("/user" <|> "/u") $> ShowActiveUser, "/_start subscribe=" *> (StartChat <$> ("on" $> True <|> "off" $> False)), "/_start" $> StartChat True, @@ -2459,7 +2543,7 @@ chatCommandP = "/_db import " *> (APIImportArchive <$> jsonP), "/_db delete" $> APIDeleteStorage, "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), - "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP), + "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional searchP), "/_get items count=" *> (APIGetChatItems <$> A.decimal), "/_send " *> (APISendMessage <$> chatRefP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> msgContentP), @@ -2479,6 +2563,7 @@ chatCommandP = "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, "/_profile " *> (APIUpdateProfile <$> jsonP), + "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString), "/_ntf get" $> APIGetNtfToken, "/_ntf register " *> (APIRegisterToken <$> strP_ <*> strP), @@ -2496,6 +2581,7 @@ chatCommandP = "/_network " *> (APISetNetworkConfig <$> jsonP), ("/network " <|> "/net ") *> (APISetNetworkConfig <$> netCfgP), ("/network" <|> "/net") $> APIGetNetworkConfig, + "/_settings " *> (APISetChatSettings <$> chatRefP <* A.space <*> jsonP), "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* optional (A.char '@') <*> displayName), @@ -2508,9 +2594,9 @@ chatCommandP = ("/help" <|> "/h") $> ChatHelp HSMain, ("/group #" <|> "/group " <|> "/g #" <|> "/g ") *> (NewGroup <$> groupProfile), "/_group " *> (NewGroup <$> jsonP), - ("/add #" <|> "/add " <|> "/a #" <|> "/a ") *> (AddMember <$> displayName <* A.space <*> displayName <*> memberRole), + ("/add #" <|> "/add " <|> "/a #" <|> "/a ") *> (AddMember <$> displayName <* A.space <* optional (A.char '@') <*> displayName <*> memberRole), ("/join #" <|> "/join " <|> "/j #" <|> "/j ") *> (JoinGroup <$> displayName), - ("/remove #" <|> "/remove " <|> "/rm #" <|> "/rm ") *> (RemoveMember <$> displayName <* A.space <*> displayName), + ("/remove #" <|> "/remove " <|> "/rm #" <|> "/rm ") *> (RemoveMember <$> displayName <* A.space <* optional (A.char '@') <*> displayName), ("/leave #" <|> "/leave " <|> "/l #" <|> "/l ") *> (LeaveGroup <$> displayName), ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName), ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName), @@ -2525,7 +2611,7 @@ chatCommandP = ("/contacts" <|> "/cs") $> ListContacts, ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)), ("/connect" <|> "/c") $> AddContact, - (SendMessage <$> chatNameP <* A.space <*> A.takeByteString), + SendMessage <$> chatNameP <* A.space <*> A.takeByteString, (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd), ("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> A.takeByteString), @@ -2552,6 +2638,7 @@ chatCommandP = "/profile_image" $> UpdateProfileImage Nothing, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames), ("/profile" <|> "/p") $> ShowProfile, + "/incognito " *> (SetIncognito <$> onOffP), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion ] @@ -2587,7 +2674,9 @@ chatCommandP = fullNameP name = do n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n + textP = safeDecodeUtf8 <$> A.takeByteString filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString + searchP = T.unpack . safeDecodeUtf8 <$> (" search=" *> A.takeByteString) memberRole = (" owner" $> GROwner) <|> (" admin" $> GRAdmin) diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index ef055143a6..0fb2812f5d 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -24,7 +24,7 @@ chatBotRepl welcome answer _user cc = do race_ (forever $ void getLine) . forever $ do (_, resp) <- atomically . readTBQueue $ outputQ cc case resp of - CRContactConnected contact -> do + CRContactConnected contact _ -> do contactConnected contact void $ sendMsg contact welcome CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do @@ -40,7 +40,7 @@ initializeBotAddress cc = do sendChatCmd cc "/show_address" >>= \case CRUserContactLink uri _ _ -> showBotAddress uri CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do - putStrLn $ "No bot address, creating..." + putStrLn "No bot address, creating..." sendChatCmd cc "/address" >>= \case CRUserContactLinkCreated uri -> showBotAddress uri _ -> putStrLn "can't create bot address" >> exitFailure diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 50ec12982c..c2881811c1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -40,8 +40,9 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, sumTypeJSON) -import Simplex.Messaging.Protocol (CorrId, MsgFlags) +import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags) import Simplex.Messaging.TMap (TMap) +import Simplex.Messaging.Transport.Client (TransportHost) import System.IO (Handle) import UnliftIO.STM @@ -62,6 +63,7 @@ data ChatConfig = ChatConfig fileChunkSize :: Integer, subscriptionConcurrency :: Int, subscriptionEvents :: Bool, + hostEvents :: Bool, testView :: Bool } @@ -86,7 +88,8 @@ data ChatController = ChatController rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, config :: ChatConfig, - filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps + filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, + incognitoMode :: TVar Bool } data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages | HSSettings @@ -105,11 +108,12 @@ data ChatCommand | APISuspendChat {suspendTimeout :: Int} | ResubscribeAllConnections | SetFilesFolder FilePath + | SetIncognito Bool | APIExportArchive ArchiveConfig | APIImportArchive ArchiveConfig | APIDeleteStorage | APIGetChats {pendingConnections :: Bool} - | APIGetChat ChatRef ChatPagination + | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems Int | APISendMessage ChatRef ComposedMessage | APIUpdateChatItem ChatRef ChatItemId MsgContent @@ -129,6 +133,7 @@ data ChatCommand | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus | APIUpdateProfile Profile + | APISetContactAlias ContactId LocalAlias | APIParseMarkdown Text | APIGetNtfToken | APIRegisterToken DeviceToken NotificationsMode @@ -146,8 +151,10 @@ data ChatCommand | SetUserSMPServers [SMPServer] | APISetNetworkConfig NetworkConfig | APIGetNetworkConfig + | APISetChatSettings ChatRef ChatSettings | APIContactInfo ContactId | APIGroupMemberInfo GroupId GroupMemberId + | ShowMessages ChatName Bool | ContactInfo ContactName | GroupMemberInfo GroupName ContactName | ChatHelp HelpSection @@ -208,7 +215,7 @@ data ChatResponse | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRUserSMPServers {smpServers :: [SMPServer]} | CRNetworkConfig {networkConfig :: NetworkConfig} - | CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats} + | CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} | CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} | CRNewChatItem {chatItem :: AChatItem} | CRChatItemStatusUpdated {chatItem :: AChatItem} @@ -230,14 +237,14 @@ data ChatResponse | CRUserAcceptedGroupSent {groupInfo :: GroupInfo} | CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember} | CRGroupsList {groups :: [GroupInfo]} - | CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact} + | CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRUserProfile {profile :: Profile} | CRUserProfileNoChange | CRVersionInfo {version :: String} | CRInvitation {connReqInvitation :: ConnReqInvitation} | CRSentConfirmation - | CRSentInvitation + | CRSentInvitation {customUserProfile :: Maybe Profile} | CRContactUpdated {fromContact :: Contact, toContact :: Contact} | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} | CRContactDeleted {contact :: Contact} @@ -262,13 +269,16 @@ data ChatResponse | CRSndFileRcvCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} + | CRContactAliasUpdated {toContact :: Contact} | CRContactConnecting {contact :: Contact} - | CRContactConnected {contact :: Contact} + | CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile} | CRContactAnotherClient {contact :: Contact} | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} | CRContactSubError {contact :: Contact, chatError :: ChatError} | CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]} + | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} + | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRGroupInvitation {groupInfo :: GroupInfo} | CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} | CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember} @@ -379,6 +389,8 @@ data ChatErrorType | CEContactNotReady {contact :: Contact} | CEContactGroups {contact :: Contact, groupNames :: [GroupName]} | CEGroupUserRole + | CEContactIncognitoCantInvite + | CEGroupIncognitoCantInvite | CEGroupContactRole {contactName :: ContactName} | CEGroupDuplicateMember {contactName :: ContactName} | CEGroupDuplicateMemberId diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index f35cd39ec7..ef4ed4991f 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -18,7 +18,7 @@ import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Markdown import Simplex.Chat.Styled -import Simplex.Chat.Types (Profile (..), User (..)) +import Simplex.Chat.Types (LocalProfile (..), User (..)) import System.Console.ANSI.Types highlight :: Text -> Markdown @@ -55,7 +55,7 @@ chatWelcome user = "Type " <> highlight "/help" <> " for usage info, " <> highlight "/welcome" <> " to show this message" ] where - User {profile = Profile {displayName, fullName}} = user + User {profile = LocalProfile {displayName, fullName}} = user userName = if T.null fullName then displayName else fullName chatHelpInfo :: [StyledString] @@ -195,5 +195,7 @@ settingsInfo = indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/smp_servers " <> " - show / set custom SMP servers", indent <> highlight "/info " <> " - information about contact connection", - indent <> highlight "/info # " <> " - information about member connection" + indent <> highlight "/info # " <> " - information about member connection", + indent <> highlight "/(un)mute " <> " - (un)mute contact, the last messages can be printed with /tail command", + indent <> highlight "/(un)mute # " <> " - (un)mute group" ] diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5be8deb6c5..07f77478fb 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -501,24 +501,26 @@ ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayN rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText = \case - RGEMemberAdded _ p -> "added " <> memberProfileToText p + RGEMemberAdded _ p -> "added " <> profileToText p RGEMemberConnected -> "connected" RGEMemberLeft -> "left" - RGEMemberDeleted _ p -> "removed " <> memberProfileToText p + RGEMemberDeleted _ p -> "removed " <> profileToText p RGEUserDeleted -> "removed you" RGEGroupDeleted -> "deleted group" RGEGroupUpdated _ -> "group profile updated" sndGroupEventToText :: SndGroupEvent -> Text sndGroupEventToText = \case - SGEMemberDeleted _ p -> "removed " <> memberProfileToText p + SGEMemberDeleted _ p -> "removed " <> profileToText p SGEUserLeft -> "left" SGEGroupUpdated _ -> "group profile updated" -memberProfileToText :: Profile -> Text -memberProfileToText Profile {displayName, fullName} = displayName <> optionalFullName displayName fullName +profileToText :: Profile -> Text +profileToText Profile {displayName, fullName} = displayName <> optionalFullName displayName fullName -- 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 +-- ! to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent data CIContent (d :: MsgDirection) where CISndMsgContent :: MsgContent -> CIContent 'MDSnd CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv @@ -531,6 +533,9 @@ data CIContent (d :: MsgDirection) where CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd CIRcvGroupEvent :: RcvGroupEvent -> CIContent 'MDRcv CISndGroupEvent :: SndGroupEvent -> CIContent 'MDSnd +-- ^ 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 +-- ! ^ to avoid breaking cross-platform compatibility, see RcvGroupEvent and SndGroupEvent deriving instance Show (CIContent d) @@ -551,6 +556,15 @@ instance ToJSON RcvGroupEvent where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RGE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RGE" +newtype DBRcvGroupEvent = RGE RcvGroupEvent + +instance FromJSON DBRcvGroupEvent where + parseJSON v = RGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RGE") v + +instance ToJSON DBRcvGroupEvent where + toJSON (RGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RGE") v + toEncoding (RGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RGE") v + data SndGroupEvent = SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember | SGEUserLeft -- CRLeftMemberUser @@ -564,6 +578,15 @@ instance ToJSON SndGroupEvent where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "SGE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "SGE" +newtype DBSndGroupEvent = SGE SndGroupEvent + +instance FromJSON DBSndGroupEvent where + parseJSON v = SGE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "SGE") v + +instance ToJSON DBSndGroupEvent where + toJSON (SGE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SGE") v + toEncoding (SGE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SGE") v + data CIGroupInvitation = CIGroupInvitation { groupId :: GroupId, groupMemberId :: GroupMemberId, @@ -574,8 +597,8 @@ data CIGroupInvitation = CIGroupInvitation deriving (Eq, Show, Generic, FromJSON) instance ToJSON CIGroupInvitation where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data CIGroupInvitationStatus = CIGISPending @@ -700,8 +723,8 @@ data DBJSONCIContent | DBJCIRcvIntegrityError {msgError :: MsgErrorType} | DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} - | DBJCIRcvGroupEvent {rcvGroupEvent :: RcvGroupEvent} - | DBJCISndGroupEvent {sndGroupEvent :: SndGroupEvent} + | DBJCIRcvGroupEvent {rcvGroupEvent :: DBRcvGroupEvent} + | DBJCISndGroupEvent {sndGroupEvent :: DBSndGroupEvent} deriving (Generic) instance FromJSON DBJSONCIContent where @@ -722,8 +745,8 @@ dbJsonCIContent = \case CIRcvIntegrityError err -> DBJCIRcvIntegrityError err CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole} - CIRcvGroupEvent rcvGroupEvent -> DBJCIRcvGroupEvent {rcvGroupEvent} - CISndGroupEvent sndGroupEvent -> DBJCISndGroupEvent {sndGroupEvent} + CIRcvGroupEvent rge -> DBJCIRcvGroupEvent $ RGE rge + CISndGroupEvent sge -> DBJCISndGroupEvent $ SGE sge aciContentDBJSON :: DBJSONCIContent -> ACIContent aciContentDBJSON = \case @@ -736,8 +759,8 @@ aciContentDBJSON = \case DBJCIRcvIntegrityError err -> ACIContent SMDRcv $ CIRcvIntegrityError err DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole - DBJCIRcvGroupEvent {rcvGroupEvent} -> ACIContent SMDRcv $ CIRcvGroupEvent rcvGroupEvent - DBJCISndGroupEvent {sndGroupEvent} -> ACIContent SMDSnd $ CISndGroupEvent sndGroupEvent + DBJCIRcvGroupEvent (RGE rge) -> ACIContent SMDRcv $ CIRcvGroupEvent rge + DBJCISndGroupEvent (SGE sge) -> ACIContent SMDSnd $ CISndGroupEvent sge data CICallStatus = CISCallPending diff --git a/src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs b/src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs new file mode 100644 index 0000000000..a43617d439 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220811_chat_items_indices.hs @@ -0,0 +1,13 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220811_chat_items_indices where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220811_chat_items_indices :: Query +m20220811_chat_items_indices = + [sql| +CREATE INDEX idx_chat_items_groups ON chat_items(user_id, group_id, item_ts, chat_item_id); +CREATE INDEX idx_chat_items_contacts ON chat_items(user_id, contact_id, chat_item_id); +|] diff --git a/src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs b/src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs new file mode 100644 index 0000000000..e03eda2358 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220812_incognito_profiles.hs @@ -0,0 +1,16 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220812_incognito_profiles where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220812_incognito_profiles :: Query +m20220812_incognito_profiles = + [sql| +ALTER TABLE connections ADD COLUMN custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- only set for direct connections + +ALTER TABLE group_members ADD COLUMN member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- member profile id if incognito profile was saved for member (used when invitation is received via incognito direct connection with host) + +ALTER TABLE contact_profiles ADD COLUMN incognito INTEGER; -- 1 for incognito +|] diff --git a/src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs b/src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs new file mode 100644 index 0000000000..ffb2b15967 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220818_chat_notifications.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220818_chat_notifications where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220818_chat_notifications :: Query +m20220818_chat_notifications = + [sql| +ALTER TABLE contacts ADD COLUMN enable_ntfs INTEGER; + +ALTER TABLE groups ADD COLUMN enable_ntfs INTEGER; +|] diff --git a/src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs b/src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs new file mode 100644 index 0000000000..bbadbd5524 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220822_groups_host_conn_custom_user_profile_id.hs @@ -0,0 +1,12 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220822_groups_host_conn_custom_user_profile_id where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220822_groups_host_conn_custom_user_profile_id :: Query +m20220822_groups_host_conn_custom_user_profile_id = + [sql| +ALTER TABLE groups ADD COLUMN host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL; -- id of custom user profile used in direct connection with host +|] diff --git a/src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs b/src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs new file mode 100644 index 0000000000..40657f3421 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220823_delete_broken_group_event_chat_items.hs @@ -0,0 +1,13 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220823_delete_broken_group_event_chat_items where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220823_delete_broken_group_event_chat_items :: Query +m20220823_delete_broken_group_event_chat_items = + [sql| +DELETE FROM chat_items WHERE item_content LIKE '%{"rcvGroupEvent":{"rcvGroupEvent":{%'; +DELETE FROM chat_items WHERE item_content LIKE '%{"sndGroupEvent":{"sndGroupEvent":{%'; +|] diff --git a/src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs b/src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs new file mode 100644 index 0000000000..f0b0ca8385 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220824_profiles_local_alias.hs @@ -0,0 +1,17 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220824_profiles_local_alias where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220824_profiles_local_alias :: Query +m20220824_profiles_local_alias = + [sql| +PRAGMA ignore_check_constraints=ON; + +ALTER TABLE contact_profiles ADD COLUMN local_alias TEXT DEFAULT '' CHECK (local_alias NOT NULL); +UPDATE contact_profiles SET local_alias = ''; + +PRAGMA ignore_check_constraints=OFF; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 33b5441efa..55bd57cec5 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -13,7 +13,9 @@ CREATE TABLE contact_profiles( created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL), image TEXT, - user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE + user_id INTEGER DEFAULT NULL REFERENCES users ON DELETE CASCADE, + incognito INTEGER, + local_alias TEXT DEFAULT '' CHECK(local_alias NOT NULL) ); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, @@ -53,6 +55,7 @@ is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT CHECK(updated_at NOT NULL), xcontact_id BLOB, + enable_ntfs INTEGER, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -118,7 +121,9 @@ CREATE TABLE groups( inv_queue_info BLOB, created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL), - chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL, -- received + chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE SET NULL, + enable_ntfs INTEGER, + host_conn_custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -145,6 +150,7 @@ CREATE TABLE group_members( contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL), + member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -236,6 +242,7 @@ CREATE TABLE connections( xcontact_id BLOB, via_user_contact_link INTEGER DEFAULT NULL REFERENCES user_contact_links(user_contact_link_id) ON DELETE SET NULL, + custom_user_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -382,3 +389,14 @@ CREATE TABLE calls( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) ); +CREATE INDEX idx_chat_items_groups ON chat_items( + user_id, + group_id, + item_ts, + chat_item_id +); +CREATE INDEX idx_chat_items_contacts ON chat_items( + user_id, + contact_id, + chat_item_id +); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 714734c747..5e862f315c 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -70,6 +70,7 @@ mobileChatOpts = smpServers = [], networkConfig = defaultNetworkConfig, logConnections = False, + logServerHosts = True, logAgent = False, chatCmd = "", chatCmdDelay = 3, diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index cbd35e7dd9..24f809a4a1 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -28,6 +28,7 @@ data ChatOpts = ChatOpts smpServers :: [SMPServer], networkConfig :: NetworkConfig, logConnections :: Bool, + logServerHosts :: Bool, logAgent :: Bool, chatCmd :: String, chatCmdDelay :: Int, @@ -52,16 +53,16 @@ chatOpts appDir defaultDbFileName = do ( long "server" <> short 's' <> metavar "SERVER" - <> help "Comma separated list of SMP server(s) to use" + <> help "Semicolon-separated list of SMP server(s) to use (each server can have more than one hostname)" <> value [] ) socksProxy <- - flag' (Just defaultSocksProxy) (short 'x' <> help "use local SOCKS5 proxy at :9050") + flag' (Just defaultSocksProxy) (short 'x' <> help "Use local SOCKS5 proxy at :9050") <|> option parseSocksProxy ( long "socks-proxy" <> metavar "SOCKS5" - <> help "`ipv4:port` or `:port` of SOCKS5 proxy" + <> help "Use SOCKS5 proxy at `ipv4:port` or `:port`" <> value Nothing ) t <- @@ -78,10 +79,15 @@ chatOpts appDir defaultDbFileName = do <> short 'c' <> help "Log every contact and group connection on start" ) + logServerHosts <- + switch + ( long "log-hosts" + <> short 'l' + <> help "Log connections to servers" + ) logAgent <- switch ( long "log-agent" - <> short 'l' <> help "Enable logs from SMP agent" ) chatCmd <- @@ -123,6 +129,7 @@ chatOpts appDir defaultDbFileName = do smpServers, networkConfig = fullNetworkConfig socksProxy $ useTcpTimeout socksProxy t, logConnections, + logServerHosts, logAgent, chatCmd, chatCmdDelay, @@ -151,7 +158,7 @@ serverPortP :: A.Parser (Maybe String) serverPortP = Just . B.unpack <$> A.takeWhile A.isDigit smpServersP :: A.Parser [SMPServer] -smpServersP = strP `A.sepBy1` A.char ',' +smpServersP = strP `A.sepBy1` A.char ';' getChatOpts :: FilePath -> FilePath -> IO ChatOpts getChatOpts appDir defaultDbFileName = diff --git a/src/Simplex/Chat/ProfileGenerator.hs b/src/Simplex/Chat/ProfileGenerator.hs new file mode 100644 index 0000000000..c42cd6ab66 --- /dev/null +++ b/src/Simplex/Chat/ProfileGenerator.hs @@ -0,0 +1,3140 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.ProfileGenerator where + +import Data.Text (Text) +import Simplex.Chat.Types (Profile (..)) +import System.Random (randomRIO) + +generateRandomProfile :: IO Profile +generateRandomProfile = do + adjective <- pick adjectives + noun <- pickNoun adjective 2 + pure $ Profile {displayName = adjective <> noun, fullName = "", image = Nothing} + where + pick :: [a] -> IO a + pick xs = (xs !!) <$> randomRIO (0, length xs - 1) + pickNoun :: Text -> Int -> IO Text + pickNoun adjective n + | n == 0 = pick nouns + | otherwise = do + noun <- pick nouns + if noun == adjective + then pickNoun adjective (n - 1) + else pure noun + +adjectives :: [Text] +adjectives = + [ "Abatic", + "Abducent", + "Abecedarian", + "Aberrant", + "Abeyant", + "Abject", + "Ablative", + "Ablaze", + "Able", + "Abloom", + "Ablutionary", + "Abolishable", + "Abolitionary", + "Aboriginal", + "Aboulic", + "Abounding", + "About", + "Aboveboard", + "Aboveground", + "Abranchial", + "Abranchiate", + "Abranchious", + "Abrasive", + "Abrupt", + "Abscessed", + "Absent", + "Absolute", + "Absolved", + "Absorbable", + "Absorbed", + "Absorbing", + "Absorptive", + "Abstinent", + "Abstracted", + "Abstractionist", + "Abundant", + "Abysmal", + "Abyssal", + "Academic", + "Acceptable", + "Accessible", + "Acclaimed", + "Accommodating", + "Accomplished", + "Accordant", + "Accurate", + "Acknowledged", + "Acrobatic", + "Active", + "Actual", + "Adamant", + "Adaptable", + "Adept", + "Adequate", + "Adhesive", + "Adhoc", + "Adjusted", + "Admirable", + "Admired", + "Admissible", + "Adorable", + "Adored", + "Adroit", + "Advantaged", + "Adventuresome", + "Adventurous", + "Aesthetic", + "Aesthetical", + "Affable", + "Affecting", + "Affectionate", + "Affiliated", + "Affluent", + "Aged", + "Ageless", + "Agile", + "Agitated", + "Agonizing", + "Agreeable", + "Ahead", + "Aholic", + "Alarmed", + "Alarming", + "Alert", + "Alienated", + "Alike", + "Alive", + "Alleged", + "Allied", + "Alright", + "Amative", + "Amatory", + "Ambiguous", + "Ambitious", + "Amelioratory", + "Amenable", + "Amicable", + "Ample", + "Amused", + "Amusing", + "Anchored", + "Ancient", + "Angelic", + "Animated", + "Annual", + "Antsy", + "Appealing", + "Appetent", + "Apposite", + "Apprehensible", + "Apprehensive", + "Approachable", + "Apropos", + "Apt", + "Aquatic", + "Arctic", + "Arid", + "Aromatic", + "Arousing", + "Arrogant", + "Articulate", + "Artistic", + "Aspirant", + "Assertive", + "Assiduous", + "Assistant", + "Associate", + "Associative", + "Assured", + "Assuring", + "Astonishing", + "Astounding", + "Astute", + "Athletic", + "Attached", + "Attainable", + "Attendant", + "Attentive", + "Attractive", + "August", + "Auspicious", + "Authentic", + "Automatic", + "Autonomous", + "Available", + "Awaited", + "Awake", + "Aware", + "Awash", + "Awesome", + "Babyish", + "Back", + "Baggy", + "Balanced", + "Bare", + "Barren", + "Basic", + "Beaming", + "Beatific", + "Beauteous", + "Beautified", + "Beautiful", + "Becoming", + "Beefy", + "Belated", + "Believable", + "Beloved", + "Benedictory", + "Benefic", + "Beneficent", + "Beneficial", + "Beneficiary", + "Benevolent", + "Benign", + "Benignant", + "Best", + "Better", + "Bewitched", + "Big", + "Biggest", + "Bijou", + "Bitter", + "Blameless", + "Bland", + "Blank", + "Blaring", + "Blazing", + "Blessed", + "Blissful", + "Blithe", + "Blond", + "Blooming", + "Blue", + "Blushing", + "Bodacious", + "Bogus", + "Boiling", + "Boisterous", + "Bold", + "Bonafide", + "Bonny", + "Bony", + "Bonzer", + "Boring", + "Boss", + "Bossy", + "Both", + "Bouncy", + "Bound", + "Bounteous", + "Bountiful", + "Bowed", + "Brainy", + "Brave", + "Brawny", + "Breakable", + "Breezy", + "Brief", + "Bright", + "Brill", + "Brilliant", + "Brimming", + "Brisk", + "Broadminded", + "Broken", + "Bronze", + "Bruised", + "Bubbly", + "Budding", + "Buff", + "Bulky", + "Bullish", + "Bumpy", + "Buoyant", + "Burdensome", + "Burly", + "Bustling", + "Busy", + "Buttery", + "Buzzing", + "Calm", + "Calmative", + "Calming", + "Candescent", + "Canny", + "Canty", + "Capable", + "Capital", + "Captivating", + "Carefree", + "Careful", + "Caring", + "Casual", + "Causative", + "Celebrated", + "Celestial", + "Centered", + "Central", + "Cerebral", + "Certain", + "Champion", + "Changeable", + "Changeless", + "Charismatic", + "Charitable", + "Charming", + "Cheerful", + "Cherished", + "Cherry", + "Chic", + "Childlike", + "Chipper", + "Chirpy", + "Chosen", + "Chummy", + "Civic", + "Civil", + "Civilized", + "Clairvoyant", + "Classic", + "Classical", + "Classy", + "Clean", + "Clear", + "Clearheaded", + "Clement", + "Clever", + "Close", + "Clubby", + "Coadjutant", + "Coequal", + "Cogent", + "Cognizant", + "Coherent", + "Collected", + "Comfortable", + "Comforting", + "Comic", + "Comical", + "Commanding", + "Commendable", + "Commendatory", + "Commending", + "Commiserative", + "Committed", + "Commodious", + "Commonsensical", + "Communicative", + "Commutual", + "Companionable", + "Compassionate", + "Compatible", + "Compelling", + "Competent", + "Complete", + "Completed", + "Complimentary", + "Composed", + "Comprehensive", + "Concentrated", + "Concise", + "Conclusive", + "Concordant", + "Concrete", + "Condolatory", + "Confederate", + "Conferrable", + "Confident", + "Congenial", + "Consentient", + "Consequential", + "Considerable", + "Considerate", + "Consistent", + "Consonant", + "Conspicuous", + "Constant", + "Constitutional", + "Constructive", + "Contemplative", + "Contemporary", + "Content", + "Contributive", + "Convenient", + "Conversant", + "Convictive", + "Convincing", + "Convivial", + "Cool", + "Cooperative", + "Coordinated", + "Copacetic", + "Copious", + "Cordial", + "Correct", + "Coruscant", + "Cosmic", + "Courageous", + "Courteous", + "Courtly", + "Cozy", + "Crackerjack", + "Creative", + "Credible", + "Creditable", + "Crisp", + "Crucial", + "Crystal", + "Cuddly", + "Cultivated", + "Cultured", + "Cunning", + "Curious", + "Current", + "Cushy", + "Cute", + "Dainty", + "Dandy", + "Dapper", + "Daring", + "Dark", + "Darling", + "Dashing", + "Dauntless", + "Dazzling", + "Dear", + "Dearest", + "Debonair", + "Decent", + "Deciding", + "Decimal", + "Decisive", + "Decorous", + "Dedicated", + "Deep", + "Defensive", + "Definite", + "Definitive", + "Delectable", + "Deliberate", + "Delicate", + "Delicious", + "Delighted", + "Delightful", + "Deluxe", + "Demanding", + "Demonstrative", + "Dental", + "Dependable", + "Dependent", + "Descriptive", + "Deserving", + "Designer", + "Desirable", + "Desired", + "Desirous", + "Destined", + "Detailed", + "Determined", + "Developed", + "Developing", + "Devoted", + "Devotional", + "Devout", + "Dexterous", + "Didactic", + "Different", + "Digital", + "Dignified", + "Diligent", + "Dim", + "Dimpled", + "Diplomatic", + "Direct", + "Disarming", + "Discerning", + "Disciplined", + "Discreet", + "Discrete", + "Disguised", + "Distinct", + "Distinctive", + "Distinguished", + "Distinguishing", + "Diverse", + "Diverting", + "Divine", + "Dominant", + "Doting", + "Doubtless", + "Doughty", + "Downright", + "Drafty", + "Dramatic", + "Dreamy", + "Driven", + "Driving", + "Dry", + "Dual", + "Durable", + "Dutiful", + "Dynamic", + "Dynamite", + "Eager", + "Early", + "Earnest", + "Earthly", + "Earthy", + "Easy", + "Easygoing", + "Ebullient", + "Eclectic", + "Economic", + "Economical", + "Ecstatic", + "Ecumenical", + "Edible", + "Edified", + "Educated", + "Educational", + "Effective", + "Effectual", + "Effervescent", + "Efficient", + "Effortless", + "Elaborate", + "Elastic", + "Elated", + "Elating", + "Elder", + "Elderly", + "Electric", + "Electrifying", + "Eleemosynary", + "Elegant", + "Elemental", + "Elementary", + "Eligible", + "Elliptical", + "Eloquent", + "Embellished", + "Emerging", + "Eminent", + "Emotional", + "Empathetic", + "Empowered", + "Enamored", + "Enchanted", + "Enchanting", + "Encouraged", + "Encouraging", + "Endearing", + "Enduring", + "Energetic", + "Energizing", + "Engaging", + "Enhanced", + "Enjoyable", + "Enlightened", + "Enlightening", + "Enlivened", + "Enlivening", + "Enormous", + "Enough", + "Enriching", + "Enterprising", + "Entertaining", + "Enthralling", + "Enthusiastic", + "Enticing", + "Entire", + "Entrancing", + "Entrepreneurial", + "Epicurean", + "Epideictic", + "Equable", + "Equal", + "Equatorial", + "Equiponderant", + "Equipped", + "Equitable", + "Equivalent", + "Erudite", + "Especial", + "Essential", + "Established", + "Esteemed", + "Esthetic", + "Esthetical", + "Eternal", + "Ethical", + "Euphoric", + "Even", + "Eventful", + "Evergreen", + "Everlasting", + "Evident", + "Evocative", + "Exact", + "Exalted", + "Exceeding", + "Excellent", + "Exceptional", + "Excitable", + "Excited", + "Exciting", + "Executive", + "Exemplary", + "Exhilarating", + "Exotic", + "Expansive", + "Expectant", + "Expeditious", + "Expeditive", + "Expensive", + "Experienced", + "Expert", + "Explorative", + "Expressive", + "Exquisite", + "Extraneous", + "Extraordinary", + "Extroverted", + "Exuberant", + "Exultant", + "Fab", + "Fabulous", + "Facile", + "Factual", + "Facultative", + "Fain", + "Faint", + "Fair", + "Faithful", + "Famed", + "Familial", + "Familiar", + "Family", + "Famous", + "Fancy", + "Fantastic", + "Far", + "Faraway", + "Fascinating", + "Fashionable", + "Fast", + "Fatal", + "Fatherly", + "Faultless", + "Favorable", + "Favored", + "Favorite", + "Fearful", + "Fearless", + "Feasible", + "Fecund", + "Feisty", + "Felicitous", + "Feline", + "Fervent", + "Festal", + "Festive", + "Fetching", + "Fickle", + "Fiery", + "Fine", + "Finer", + "Finest", + "Finished", + "Firm", + "First", + "Firsthand", + "Fit", + "Fitting", + "Fixed", + "Flamboyant", + "Flash", + "Flashy", + "Flat", + "Flavorful", + "Flawed", + "Flawless", + "Fleet", + "Flexible", + "Flickering", + "Flimsy", + "Flippant", + "Flourishing", + "Flowery", + "Fluent", + "Fluffy", + "Fluid", + "Flustered", + "Flying", + "Focused", + "Fond", + "Foremost", + "Foresighted", + "Forgiving", + "Forked", + "Formal", + "Formidable", + "Forthcoming", + "Forthright", + "Fortified", + "Fortuitous", + "Fortunate", + "Forward", + "Foundational", + "Foxy", + "Fragrant", + "Frail", + "Frank", + "Fraternal", + "Frayed", + "Freely", + "Frequent", + "Fresh", + "Friendly", + "Frilly", + "Frisky", + "Front", + "Frosty", + "Frozen", + "Fruitful", + "Fulfilled", + "Fulfilling", + "Full", + "Fumbling", + "Fun", + "Functional", + "Funny", + "Fussy", + "Futuristic", + "Fuzzy", + "Gabby", + "Gainful", + "Gallant", + "Galore", + "Game", + "Gamesome", + "Gamy", + "Gaping", + "Gaseous", + "Gaudy", + "General", + "Generous", + "Genial", + "Genteel", + "Gentle", + "Genuine", + "Germane", + "Gettable", + "Giant", + "Giddy", + "Gifted", + "Gigantic", + "Giving", + "Glad", + "Glamorous", + "Glaring", + "Glass", + "Gleaming", + "Gleeful", + "Glib", + "Glistening", + "Glittering", + "Glorious", + "Glossy", + "Glowing", + "Glum", + "Gnarly", + "Godly", + "Golden", + "Good", + "Goodhearted", + "Goodly", + "Goofy", + "Gorgeous", + "Graced", + "Graceful", + "Gracile", + "Gracious", + "Gradely", + "Graithly", + "Grand", + "Grandiose", + "Granular", + "Grateful", + "Gratified", + "Gratifying", + "Gray", + "Greasy", + "Great", + "Greatest", + "Greathearted", + "Green", + "Gregarious", + "Grey", + "Gripping", + "Groovy", + "Grounded", + "Growing", + "Grown", + "Grubby", + "Grumpy", + "Guaranteed", + "Guarded", + "Gubernatorial", + "Guided", + "Guiding", + "Guileless", + "Guiltless", + "Guilty", + "Gullible", + "Gummy", + "Gumptious", + "Gustatory", + "Gusty", + "Gutsy", + "Gymnastic", + "Hairy", + "Halcyon", + "Hale", + "Half", + "Hallowed", + "Handmade", + "Handsome", + "Handy", + "Happening", + "Happy", + "Hardy", + "Harmless", + "Harmonious", + "Harsh", + "Hasty", + "Head", + "Healing", + "Healthful", + "Healthy", + "Heartfelt", + "Hearty", + "Heavenly", + "Heavy", + "Heedful", + "Hefty", + "Hegemonic", + "Helpful", + "Hep", + "Heralded", + "Heroic", + "Heteroclite", + "Heuristic", + "High", + "Highest", + "Hilarious", + "Hip", + "Holy", + "Homely", + "Honest", + "Honeyed", + "Honorable", + "Honorary", + "Honored", + "Hopeful", + "Hortative", + "Hospitable", + "Hot", + "Hotshot", + "Huge", + "Humane", + "Humanitarian", + "Humble", + "Humming", + "Humongous", + "Humorous", + "Hungry", + "Husky", + "Hygienic", + "Icy", + "Ideal", + "Idealistic", + "Identical", + "Idiosyncratic", + "Idolized", + "Ignorant", + "Illimitable", + "Illuminated", + "Illuminating", + "Illustrious", + "Imaginary", + "Imaginative", + "Imitable", + "Immaculate", + "Immaterial", + "Immeasurable", + "Immediate", + "Immense", + "Immortal", + "Immune", + "Impartial", + "Impassioned", + "Impeccable", + "Impeccant", + "Imperturbable", + "Important", + "Impossible", + "Impractical", + "Impressionable", + "Impressive", + "Improbable", + "Improved", + "Improving", + "Improvisational", + "Inborn", + "Incisive", + "Included", + "Inclusive", + "Incomparable", + "Inconsequential", + "Incontestable", + "Incontrovertible", + "Incredible", + "Inculpable", + "Indefatigable", + "Indelible", + "Independent", + "Indestructible", + "Indispensable", + "Indisputable", + "Individual", + "Individualistic", + "Indivisible", + "Indomitable", + "Indubitable", + "Industrious", + "Inerrant", + "Inexhaustible", + "Infallible", + "Infamous", + "Infant", + "Infantile", + "Infatuated", + "Inferior", + "Infinite", + "Influential", + "Informal", + "Informative", + "Informed", + "Ingenious", + "Inimitable", + "Initiate", + "Initiative", + "Innocent", + "Innovative", + "Innoxious", + "Inquisitive", + "Insightful", + "Insignificant", + "Insistent", + "Inspired", + "Inspiring", + "Inspiriting", + "Instantaneous", + "Instinctive", + "Instructive", + "Instrumental", + "Integral", + "Integrated", + "Intellectual", + "Intelligent", + "Intense", + "Intent", + "Intentional", + "Interactive", + "Interconnected", + "Interested", + "Interesting", + "Internal", + "International", + "Intertwined", + "Intimate", + "Intoxicating", + "Intrepid", + "Intriguing", + "Introducer", + "Inventive", + "Invigorated", + "Invigorating", + "Invincible", + "Inviolable", + "Inviting", + "Irrefragable", + "Irrefutable", + "Irreplaceable", + "Irrepressible", + "Irreproachable", + "Irresistible", + "Jaculable", + "Jaded", + "Jaunty", + "Jazzed", + "Jazzy", + "Jessant", + "Jestful", + "Jesting", + "Jeweled", + "Jiggish", + "Jigjog", + "Jimp", + "Jittery", + "Jobbing", + "Jocose", + "Jocoserious", + "Jocular", + "Joculatory", + "Jocund", + "Joint", + "Jointed", + "Jolly", + "Jovial", + "Joyful", + "Joyous", + "Joysome", + "Jubilant", + "Judicious", + "Juicy", + "Julie", + "Jumbled", + "Jumbo", + "Jump", + "Jumpy", + "Junior", + "Just", + "Justified", + "Juvenile", + "Kaleidoscopic", + "Keen", + "Kempt", + "Key", + "Kind", + "Kindhearted", + "Kindly", + "Kindred", + "Kinetic", + "Kingly", + "Knightly", + "Knobby", + "Knotty", + "Knowable", + "Knowing", + "Knowledgeable", + "Known", + "Kooky", + "Kosher", + "Ladylike", + "Large", + "Last", + "Lasting", + "Latitudinarian", + "Laudable", + "Laureate", + "Lavish", + "Lawful", + "Leading", + "Leafy", + "Learned", + "Legal", + "Legendary", + "Legible", + "Legit", + "Legitimate", + "Leisured", + "Leisurely", + "Lenien", + "Leonine", + "Lepid", + "Lettered", + "Liberal", + "Liberated", + "Liberating", + "Light", + "Lighthearted", + "Lightly", + "Likable", + "Like", + "Liked", + "Likely", + "Limber", + "Limited", + "Linear", + "Lined", + "Lionhearted", + "Liquid", + "Literary", + "Literate", + "Lithe", + "Lithesome", + "Little", + "Live", + "Lively", + "Livid", + "Logical", + "Long", + "Lordly", + "Loud", + "Lovable", + "Loved", + "Lovely", + "Loving", + "Low", + "Loyal", + "Lucent", + "Lucid", + "Lucky", + "Lucrative", + "Lumbering", + "Luminous", + "Luscious", + "Lush", + "Lustrous", + "Luxuriant", + "Luxurious", + "Made", + "Magical", + "Magnanimous", + "Magnetic", + "Magnificent", + "Main", + "Majestic", + "Major", + "Malleable", + "Manageable", + "Managerial", + "Manifest", + "Mannerly", + "Marked", + "Marvelous", + "Massive", + "Master", + "Masterful", + "Masterly", + "Matchless", + "Maternal", + "Mature", + "Maturing", + "Maximal", + "Meager", + "Mealy", + "Meaningful", + "Measly", + "Mediate", + "Medical", + "Meditative", + "Medium", + "Mellow", + "Melodic", + "Melodious", + "Memorable", + "Merciful", + "Meritable", + "Meritorious", + "Merry", + "Mesmerizing", + "Metallic", + "Metaphysical", + "Meteoric", + "Methodical", + "Meticulous", + "Mettlesome", + "Mighty", + "Mild", + "Milky", + "Mindful", + "Mindless", + "Miniature", + "Minikin", + "Ministerial", + "Mint", + "Minty", + "Miraculous", + "Mirthful", + "Misty", + "Mitigative", + "Mitigatory", + "Mixed", + "Model", + "Modern", + "Modernistic", + "Modest", + "Moist", + "Momentous", + "Moneyed", + "Monthly", + "Monumental", + "Moral", + "Mortified", + "Motherly", + "Motionless", + "Motivated", + "Motivating", + "Motivational", + "Motor", + "Mountainous", + "Moving", + "Multicolored", + "Multidimensional", + "Multidisciplined", + "Multifaceted", + "Mundane", + "Munificent", + "Muscular", + "Musical", + "Mutual", + "Mysterious", + "Narrow", + "National", + "Nationwide", + "Native", + "Natty", + "Natural", + "Nautical", + "Near", + "Nearby", + "Neat", + "Necessary", + "Needed", + "Negligible", + "Neighboring", + "Neighborly", + "Neoteric", + "Nestling", + "New", + "Newborn", + "Next", + "Nice", + "Nifty", + "Nimble", + "Nippy", + "Noble", + "Nocturnal", + "Noetic", + "Nonchalant", + "Nonpareil", + "Nonstop", + "Normal", + "Notable", + "Noted", + "Noteworthy", + "Noticeable", + "Nourished", + "Nourishing", + "Novel", + "Nubile", + "Nutrimental", + "Nutritious", + "Obedient", + "Objective", + "Obliging", + "Oblong", + "Observant", + "Obtainable", + "Obvious", + "Occasional", + "Oecumenical", + "Official", + "Okay", + "Olympian", + "Onward", + "Open", + "Operative", + "Opportune", + "Optimal", + "Optimistic", + "Optimum", + "Opulent", + "Orange", + "Orderly", + "Ordinary", + "Organic", + "Organized", + "Oriented", + "Original", + "Ornamental", + "Ornate", + "Ornery", + "Outgoing", + "Outlandish", + "Outlying", + "Outrageous", + "Outstanding", + "Oval", + "Overflowing", + "Overjoyed", + "Overriding", + "Overt", + "Palatable", + "Pally", + "Palpable", + "Paradisiac", + "Paradisiacal", + "Parallel", + "Paramount", + "Parched", + "Parental", + "Parnassian", + "Partial", + "Participant", + "Participative", + "Particular", + "Partisan", + "Passionate", + "Pastel", + "Paternal", + "Patient", + "Peaceable", + "Peaceful", + "Peachy", + "Peerless", + "Penetrating", + "Peppery", + "Peppy", + "Perceptive", + "Perfect", + "Perfumed", + "Periodic", + "Perky", + "Permanent", + "Permissive", + "Perseverant", + "Persevering", + "Persistent", + "Personable", + "Personal", + "Perspective", + "Perspicacious", + "Perspicuous", + "Persuasive", + "Pert", + "Pertinent", + "Petite", + "Phenomenal", + "Philanthropic", + "Philoprogenitive", + "Philosophical", + "Physical", + "Picked", + "Picturesque", + "Piercing", + "Pierian", + "Pilot", + "Pink", + "Pioneering", + "Pious", + "Piquant", + "Pithy", + "Pivotal", + "Placid", + "Plain", + "Plaintive", + "Plastic", + "Plausible", + "Playful", + "Pleasant", + "Pleased", + "Pleasing", + "Pleasurable", + "Plenary", + "Plenteous", + "Plentiful", + "Pliable", + "Plucky", + "Plummy", + "Plump", + "Plush", + "Poetic", + "Poignant", + "Pointed", + "Poised", + "Polished", + "Polite", + "Political", + "Popular", + "Portly", + "Posh", + "Positive", + "Possible", + "Potable", + "Potent", + "Potential", + "Powerful", + "Practicable", + "Practical", + "Practised", + "Pragmatic", + "Praiseworthy", + "Prayerful", + "Precious", + "Precise", + "Predominant", + "Preeminent", + "Preferable", + "Preferred", + "Premier", + "Premium", + "Prepared", + "Preponderant", + "Prepotent", + "Present", + "Prestigious", + "Pretty", + "Prevailing", + "Prevalent", + "Prevenient", + "Previous", + "Primal", + "Primary", + "Prime", + "Primed", + "Primo", + "Princely", + "Principled", + "Pristine", + "Private", + "Privileged", + "Prize", + "Prized", + "Prizewinning", + "Pro", + "Proactive", + "Probable", + "Probative", + "Procurable", + "Prodigious", + "Productive", + "Professional", + "Proficient", + "Profitable", + "Profound", + "Profuse", + "Progressive", + "Prolific", + "Prominent", + "Promising", + "Prompt", + "Proper", + "Propertied", + "Prophetic", + "Propitious", + "Prospective", + "Prosperous", + "Protean", + "Protective", + "Proud", + "Provocative", + "Prudent", + "Puissant", + "Pulchritudinous", + "Punchy", + "Punctilious", + "Punctual", + "Pungent", + "Pure", + "Purple", + "Purposeful", + "Quaint", + "Qualified", + "Qualitative", + "Quality", + "Quantifiable", + "Quarterly", + "Queenly", + "Questionable", + "Quick", + "Quiet", + "Quietsome", + "Quintessential", + "Quirky", + "Quiver", + "Quixotic", + "Quizzical", + "Quotable", + "Racy", + "Rad", + "Radiant", + "Rapid", + "Rapturous", + "Rare", + "Rational", + "Raw", + "Reachable", + "Ready", + "Real", + "Realistic", + "Realizable", + "Reasonable", + "Reassuring", + "Recent", + "Receptive", + "Recherche", + "Recipient", + "Reciprocal", + "Recognizable", + "Recognized", + "Recommendable", + "Rectangular", + "Recuperative", + "Red", + "Refined", + "Reflecting", + "Reflective", + "Refreshing", + "Refulgent", + "Regal", + "Regnant", + "Regular", + "Rejuvenescent", + "Relaxed", + "Relevant", + "Reliable", + "Relieved", + "Remarkable", + "Remissive", + "Remote", + "Renowned", + "Repentant", + "Reputable", + "Required", + "Resilient", + "Resolute", + "Resolved", + "Resounding", + "Resourceful", + "Respectable", + "Respectful", + "Resplendent", + "Responsible", + "Responsive", + "Restful", + "Restorative", + "Retentive", + "Revealing", + "Revered", + "Reverent", + "Revitalizing", + "Revolutionary", + "Revolving", + "Rewardable", + "Rewarding", + "Rhapsodic", + "Rich", + "Right", + "Righteous", + "Rightful", + "Ringed", + "Ripe", + "Risible", + "Robust", + "Rollicking", + "Romantic", + "Rooted", + "Rosy", + "Rotating", + "Round", + "Rounded", + "Rousing", + "Royal", + "Rugged", + "Ruling", + "Runny", + "Rural", + "Saccharine", + "Sacred", + "Sacrosanct", + "Safe", + "Sagacious", + "Sage", + "Saintly", + "Salient", + "Salubrious", + "Salutary", + "Salutiferous", + "Sanctified", + "Sanctimonious", + "Sanctioned", + "Sandy", + "Sane", + "Sanguine", + "Sapid", + "Sapient", + "Sapoforic", + "Sassy", + "Satisfactory", + "Satisfied", + "Satisfying", + "Saucy", + "Saving", + "Savory", + "Savvy", + "Scenic", + "Scented", + "Scholarly", + "Scientific", + "Scintillating", + "Scrumptious", + "Scrupulous", + "Seamless", + "Seasonal", + "Seasoned", + "Second", + "Secondary", + "Secret", + "Secure", + "Sedulous", + "Seemly", + "Select", + "Selfless", + "Sensational", + "Sensible", + "Sensitive", + "Sensuous", + "Sentimental", + "Separate", + "Sequacious", + "Serendipitous", + "Serene", + "Serious", + "Service", + "Settled", + "Several", + "Severe", + "Shabby", + "Shadowy", + "Shapely", + "Sharp", + "Shatterproof", + "Sheen", + "Shimmering", + "Shining", + "Shiny", + "Shipshape", + "Shocked", + "Short", + "Showy", + "Shrewd", + "Sightly", + "Significant", + "Silent", + "Silken", + "Silky", + "Silver", + "Silvery", + "Similar", + "Simple", + "Simplistic", + "Sincere", + "Sinewy", + "Single", + "Singular", + "Sisterly", + "Sizable", + "Sizzling", + "Skeletal", + "Skilled", + "Skillful", + "Sleek", + "Slick", + "Slight", + "Slim", + "Slinky", + "Slippery", + "Slow", + "Smacking", + "Small", + "Smart", + "Smashing", + "Smiley", + "Smooth", + "Snap", + "Snappy", + "Snazzy", + "Snod", + "Snoopy", + "Snug", + "Soaring", + "Sociable", + "Social", + "Societal", + "Soft", + "Soigne", + "Solicitous", + "Solid", + "Sonsy", + "Sooth", + "Soothing", + "Sophisticated", + "Soulful", + "Sound", + "Soupy", + "Sour", + "Sovereign", + "Spacious", + "Spangly", + "Spanking", + "Sparkling", + "Sparkly", + "Special", + "Specific", + "Spectacular", + "Specular", + "Speedy", + "Spellbinding", + "Spherical", + "Spicy", + "Spiffy", + "Spirited", + "Spiritual", + "Splendid", + "Splendiferous", + "Spontaneous", + "Sport", + "Sporting", + "Sportive", + "Sporty", + "Spotless", + "Sprightly", + "Spruce", + "Spry", + "Spunky", + "Square", + "Stable", + "Stacked", + "Stainless", + "Stalwart", + "Staminal", + "Standard", + "Standing", + "Star", + "Starchy", + "Stark", + "Starry", + "State", + "Stately", + "Statuesque", + "Staunch", + "Steadfast", + "Steady", + "Steamy", + "Steel", + "Stellar", + "Sterling", + "Sthenic", + "Stimulant", + "Stimulating", + "Stimulative", + "Stipendiary", + "Stirred", + "Stirring", + "Stocky", + "Stoical", + "Storied", + "Stout", + "Stouthearted", + "Straightforward", + "Strange", + "Strapping", + "Strategic", + "Streetwise", + "Strenuous", + "Strict", + "Strident", + "Striking", + "Striped", + "Strong", + "Studious", + "Stunning", + "Stupendous", + "Sturdy", + "Stylish", + "Suasive", + "Suave", + "Sublime", + "Substant", + "Substantial", + "Substantive", + "Subtle", + "Suburban", + "Successful", + "Succinct", + "Succulent", + "Sufficient", + "Sugary", + "Suitable", + "Sultry", + "Summary", + "Summery", + "Sumptuous", + "Sunny", + "Super", + "Superabundant", + "Superb", + "Supereminent", + "Superethical", + "Superexcellent", + "Superficial", + "Superfluous", + "Superior", + "Superlative", + "Supernal", + "Supersonic", + "Supple", + "Supportive", + "Supreme", + "Sure", + "Surpassing", + "Surprised", + "Sustained", + "Svelte", + "Swank", + "Swashbuckling", + "Sweet", + "Swell", + "Swift", + "Swish", + "Sybaritic", + "Sylvan", + "Symmetrical", + "Sympathetic", + "Symphonious", + "Synergistic", + "Systematic", + "Tactful", + "Talented", + "Tall", + "Tame", + "Tan", + "Tangible", + "Tart", + "Tasteful", + "Tasty", + "Teachable", + "Teeming", + "Tempean", + "Temperate", + "Tenable", + "Tenacious", + "Tender", + "Terrific", + "Testimonial", + "Thankful", + "Thankworthy", + "Therapeutic", + "Thorough", + "Those", + "Thoughtful", + "Thrifty", + "Thrilled", + "Thrilling", + "Thriving", + "Tidy", + "Tight", + "Timeless", + "Timely", + "Tinted", + "Tiny", + "Tiptop", + "Tireless", + "Titanic", + "Titillating", + "Today", + "Together", + "Tolerant", + "Top", + "Tops", + "Total", + "Touching", + "Tough", + "Trailblazing", + "Trained", + "Tranquil", + "Transcendent", + "Transcendental", + "Transient", + "Transparent", + "Transpicuous", + "Traveled", + "Treasured", + "Tremendous", + "Triangular", + "Trim", + "Triumphant", + "True", + "Trustful", + "Trusting", + "Trustworthy", + "Trusty", + "Truthful", + "Tubular", + "Tuneful", + "Turgent", + "Twin", + "Tympanic", + "Uber", + "Ultimate", + "Ultra", + "Ultraprecise", + "Unabashed", + "Unadulterated", + "Unaffected", + "Unafraid", + "Unalloyed", + "Unambiguous", + "Unanimous", + "Unarguable", + "Unassuming", + "Unattached", + "Unbeaten", + "Unbelievable", + "Unbiased", + "Unbigoted", + "Unblemished", + "Unbroken", + "Uncommon", + "Uncomplicated", + "Unconditional", + "Unconscious", + "Uncontestable", + "Unconventional", + "Uncorrupted", + "Uncritical", + "Undamaged", + "Undauntable", + "Undaunted", + "Undefeated", + "Undefiled", + "Undeniable", + "Understandable", + "Understanding", + "Understated", + "Understood", + "Undesigning", + "Undiminished", + "Undisputed", + "Undivided", + "Undoubted", + "Unencumbered", + "Unequaled", + "Unequalled", + "Unequivocal", + "Unerring", + "Unfailing", + "Unfaltering", + "Unfaultable", + "Unfeigned", + "Unfettered", + "Unflagging", + "Unflappable", + "Ungrudging", + "Unhampered", + "Unharmed", + "Unhesitating", + "Unhurt", + "Unified", + "Uniform", + "Unimpaired", + "Unimpeachable", + "Unimpeded", + "Unique", + "United", + "Universal", + "Unlimited", + "Unmistakable", + "Unmitigated", + "Unobjectionable", + "Unobstructed", + "Unobtrusive", + "Unopposed", + "Unpretentious", + "Unquestionable", + "Unrefuted", + "Unreserved", + "Unrivalled", + "Unruffled", + "Unselfish", + "Unshakable", + "Unshaken", + "Unspoiled", + "Unspoilt", + "Unstoppable", + "Unsullied", + "Unsurpassed", + "Untarnished", + "Untiring", + "Untouched", + "Untroubled", + "Ununprejudiced", + "Unusual", + "Unwavering", + "Upbeat", + "Upcoming", + "Uplifted", + "Uplifting", + "Uppermost", + "Upright", + "Upset", + "Upstanding", + "Upward", + "Upwardly", + "Urban", + "Urbane", + "Usable", + "Useful", + "Useless", + "Utmost", + "Vacant", + "Vain", + "Valiant", + "Valid", + "Validatory", + "Valorous", + "Valuable", + "Valued", + "Vapid", + "Variable", + "Vast", + "Vaulting", + "Vehement", + "Velvety", + "Venerable", + "Venerated", + "Venturesome", + "Venue", + "Veracious", + "Verdurous", + "Veridical", + "Verifiable", + "Verified", + "Versatile", + "Versed", + "Vestal", + "Veteran", + "Viable", + "Vibrant", + "Vibratile", + "Victor", + "Victorious", + "Vigilant", + "Vigorous", + "Violet", + "Virile", + "Virtual", + "Virtuous", + "Visible", + "Visionary", + "Vital", + "Vivacious", + "Vivid", + "Vocal", + "Volant", + "Volitional", + "Voluminous", + "Voluptuous", + "Vulnerary", + "Wanted", + "Warm", + "Warmhearted", + "Warranted", + "Wasteful", + "Watchful", + "Waterlogged", + "Watery", + "Wavy", + "Wealthy", + "Weekly", + "Weighty", + "Welcome", + "Welcomed", + "Welcoming", + "Weleful", + "Welfaring", + "Well", + "Welsome", + "Wet", + "Whimsical", + "Whole", + "Wholehearted", + "Wholesome", + "Whopping", + "Wide", + "Wild", + "Willed", + "Willing", + "Winding", + "Windy", + "Winged", + "Winning", + "Winsome", + "Wired", + "Wise", + "Witty", + "Wizard", + "Wizardly", + "Wobbly", + "Wonderful", + "Wondrous", + "Wooden", + "Wordy", + "Workable", + "Worldly", + "Worshipful", + "Worth", + "Worthwhile", + "Worthy", + "Xenial", + "Xenodochial", + "Yearly", + "Yern", + "Young", + "Youthful", + "Yummy", + "Zaftig", + "Zany", + "Zappy", + "Zazzy", + "Zealand", + "Zealful", + "Zealous", + "Zestful", + "Zesty", + "Zigzag", + "Zingy", + "Zippy", + "Zootrophic", + "Zooty" + ] + +nouns :: [Text] +nouns = + [ "Academician", + "Acceptor", + "Access", + "Acclaim", + "Accolade", + "Account", + "Accuracy", + "Ace", + "Achiever", + "Acumen", + "Addition", + "Adherent", + "Adjutant", + "Administrator", + "Admirer", + "Adorer", + "Advantage", + "Aesthete", + "Aficionada", + "Aficionado", + "Agent", + "Aide", + "Almsgiver", + "Altruist", + "Ambassador", + "Amity", + "Angel", + "Apostle", + "Appreciator", + "Arbiter", + "Archetype", + "Architect", + "Artisan", + "Artist", + "Artiste", + "Asset", + "Assignee", + "Assigner", + "Athlete", + "Author", + "Authority", + "Avowal", + "Awardee", + "Aye", + "Azure", + "Baby", + "Backer", + "Backup", + "Beatitude", + "Beauty", + "Begetter", + "Being", + "Believer", + "Benchmark", + "Benefaction", + "Benefactor", + "Benefactress", + "Beneficiary", + "Benefit", + "Bestower", + "Betterment", + "Bigwig", + "Blessing", + "Bliss", + "Bloom", + "Blossom", + "Blossoming", + "Bodyguard", + "Bonanza", + "Bonus", + "Boost", + "Booster", + "Boss", + "Bound", + "Bounty", + "Brain", + "Brass", + "Brief", + "Brother", + "Buddy", + "Builder", + "Calm", + "Campaigner", + "Capital", + "Captain", + "Care", + "Caretaker", + "Catalyst", + "Cause", + "Celebrant", + "Celebrator", + "Celestial", + "Chair", + "Chairperson", + "Chamberlain", + "Champ", + "Champion", + "Charity", + "Charmer", + "Cheer", + "Cheers", + "Chief", + "Chieftain", + "Chirpy", + "Choice", + "Chortle", + "Chosen", + "Chuckle", + "Chum", + "Cinch", + "Civility", + "Clairvoyant", + "Classic", + "Clear", + "Climb", + "Climber", + "Climbing", + "Close", + "Closing", + "Coadjutant", + "Coadjutor", + "Coequal", + "Coiner", + "Collaborator", + "Colleague", + "Collector", + "Comfort", + "Comforter", + "Comic", + "Commander", + "Commendatory", + "Compassion", + "Composer", + "Comrade", + "Concierge", + "Condolence", + "Conductor", + "Confederate", + "Confidant", + "Confidence", + "Connoisseur", + "Consciousness", + "Conservator", + "Consoler", + "Constant", + "Constitutional", + "Consul", + "Consultant", + "Contemporary", + "Content", + "Contributor", + "Controller", + "Conversant", + "Cooperator", + "Cope", + "Cornerstone", + "Councillor", + "Counselor", + "Courage", + "Crack", + "Crackerjack", + "Craftsperson", + "Creator", + "Credential", + "Credit", + "Curator", + "Custodian", + "Dainty", + "Dancer", + "Daring", + "Darling", + "Dean", + "Dear", + "Decency", + "Deep", + "Defender", + "Definite", + "Delight", + "Demulcent", + "Deserving", + "Designer", + "Devisee", + "Devisor", + "Devotee", + "Devotional", + "Devout", + "Didactic", + "Director", + "Disciple", + "Discoverer", + "Distributor", + "Doer", + "Doll", + "Donee", + "Donor", + "Doting", + "Doyen", + "Doyenne", + "Dreamboat", + "Dynamic", + "Dynamite", + "Dynamo", + "Earnest", + "Ease", + "Effect", + "Efficiency", + "Efficient", + "Einstein", + "Elder", + "Eligible", + "Employer", + "Enchanter", + "Enchantress", + "Encourager", + "Endorser", + "Enlivening", + "Enough", + "Entertainer", + "Enthusiast", + "Entrepreneur", + "Epicure", + "Epicurean", + "Epitome", + "Equal", + "Equity", + "Equivalent", + "Essence", + "Essential", + "Esteem", + "Eternal", + "Ethic", + "Example", + "Exclusive", + "Executive", + "Exemplar", + "Exemplary", + "Experimenter", + "Expert", + "Exponent", + "Eyes", + "Fair", + "Faith", + "Faithful", + "Fame", + "Familiar", + "Fancier", + "Fancy", + "Fantastic", + "Fare", + "Fascinator", + "Fashioner", + "Favor", + "Favorite", + "Favour", + "Felicity", + "Fine", + "Fireball", + "Firm", + "First", + "Fleet", + "Folks", + "Foodie", + "Forbear", + "Force", + "Forefather", + "Foreman", + "Forerunner", + "Foresight", + "Forever", + "Forgiveness", + "Fortunate", + "Fortune", + "Forward", + "Foundation", + "Founder", + "Fountain", + "Fountainhead", + "Freedom", + "Freethinking", + "Fresh", + "Friend", + "Friendly", + "Fulfilling", + "Full", + "Fun", + "Funny", + "Gag", + "Gaiety", + "Gain", + "Gala", + "Galahad", + "Gale", + "Gallant", + "Gallantry", + "Game", + "Garb", + "Garden", + "Garland", + "Garnish", + "Gastronome", + "Gastronomy", + "Gather", + "Gathering", + "Geek", + "Gem", + "Generativity", + "Generator", + "Generosity", + "Genius", + "Gentle", + "Gentlefolk", + "Gentleman", + "Gentlewoman", + "Gift", + "Gild", + "Gilt", + "Girlfriend", + "Gist", + "Giver", + "Giving", + "Glamour", + "Glance", + "Glare", + "Glaze", + "Gleam", + "Gleaming", + "Glimmer", + "Glimmering", + "Glint", + "Glisten", + "Glister", + "Glitterati", + "Glitz", + "Glory", + "Gloss", + "Glossy", + "Goal", + "God", + "Goddess", + "Godparent", + "Godsend", + "Golconda", + "Gold", + "Good", + "Goodness", + "Goodwill", + "Goody", + "Gorgeousness", + "Gourmet", + "Governor", + "Grace", + "Gracility", + "Graciousness", + "Grade", + "Graduate", + "Grammy", + "Grandee", + "Grandeur", + "Grandmaster", + "Grant", + "Grantee", + "Grantor", + "Grass", + "Gratefulness", + "Gratification", + "Gratitude", + "Great", + "Greatness", + "Greeting", + "Grin", + "Grit", + "Groove", + "Growing", + "Growth", + "Grubstake", + "Guarantor", + "Guard", + "Guardian", + "Guest", + "Guffaw", + "Guidance", + "Guide", + "Gumption", + "Guru", + "Gush", + "Gusto", + "Gut", + "Gymnastic", + "Hale", + "Handler", + "Happening", + "Harmony", + "Head", + "Headman", + "Heart", + "Heartthrob", + "Heaven", + "Height", + "Heir", + "Heiress", + "Hello", + "Help", + "Helper", + "Helpmate", + "Heritor", + "Heritress", + "Heritrix", + "Hero", + "Heroine", + "Heuristic", + "Highflier", + "Hilarity", + "Holy", + "Honesty", + "Honor", + "Hooray", + "Hope", + "Hopeful", + "Hotshot", + "Humanitarian", + "Humor", + "Husband", + "Icon", + "Idea", + "Ideal", + "Idol", + "Idolizer", + "Improvement", + "Inamorata", + "Inamorato", + "Increase", + "Independent", + "Indivisible", + "Industrialist", + "Infant", + "Infinite", + "Ingenuity", + "Inheritor", + "Initiative", + "Initiator", + "Innocent", + "Innovator", + "Inspiration", + "Institutor", + "Integral", + "Intellect", + "Intent", + "Interest", + "Intimate", + "Inventor", + "Invitation", + "Invite", + "Invitee", + "Jest", + "Jester", + "Jingle", + "Joker", + "Josh", + "Jubilation", + "Juggler", + "Justice", + "Keeper", + "Key", + "Kindred", + "Kingpin", + "Kiss", + "Knight", + "Lady", + "Lark", + "Lasting", + "Latitudinarian", + "Laugh", + "Laureate", + "Lead", + "Leader", + "Learning", + "Legatee", + "Legator", + "Legend", + "Legislator", + "Leisure", + "Liberation", + "Libertarian", + "Lieutenant", + "Life", + "Light", + "Like", + "Liking", + "Lionheart", + "Literate", + "Lord", + "Love", + "Lover", + "Luck", + "Lust", + "Luster", + "Lustre", + "Luxury", + "Maestro", + "Magician", + "Magistrate", + "Magnitude", + "Majesty", + "Major", + "Majority", + "Make", + "Maker", + "Mana", + "Manager", + "Manner", + "Marvel", + "Massage", + "Mastermind", + "Mate", + "Matriarch", + "Matter", + "Mentor", + "Mercy", + "Merit", + "Method", + "Might", + "Minder", + "Minikin", + "Mint", + "Miracle", + "Mirth", + "Model", + "Modern", + "Mom", + "Moppet", + "Morale", + "Most", + "Motivator", + "Motor", + "Mover", + "Moving", + "Much", + "Multitude", + "Music", + "Nabit", + "Narration", + "Narrator", + "Nascency", + "Natation", + "National", + "Native", + "Natural", + "Negoce", + "Neighbor", + "Neonate", + "Neoteric", + "Nestling", + "Newborn", + "Nicety", + "Nimblewit", + "Nipper", + "Nirvana", + "Noble", + "Nobleman", + "Nod", + "Nonpareil", + "Noon", + "Notable", + "Note", + "Notice", + "Novel", + "Nudge", + "Nurse", + "Nursling", + "Nurture", + "Objective", + "Offer", + "Officer", + "Official", + "Offspring", + "Olympian", + "One", + "Oodles", + "Oomph", + "Ooze", + "Operator", + "Opportunity", + "Optimist", + "Optimum", + "Orchestrator", + "Organizer", + "Original", + "Originator", + "Ornamental", + "Overflowing", + "Overseer", + "Owner", + "Pacifist", + "Pal", + "Paradigm", + "Paradise", + "Paragon", + "Paramount", + "Pard", + "Pardon", + "Parent", + "Participant", + "Particular", + "Partisan", + "Partner", + "Passion", + "Pathfinder", + "Patience", + "Patient", + "Patriarch", + "Patron", + "Peace", + "Peacekeeper", + "Peach", + "Peak", + "Pearl", + "Peer", + "Pep", + "Perfection", + "Perfectionist", + "Performer", + "Personality", + "Perspective", + "Pet", + "Phenomenon", + "Philanthropist", + "Philanthropy", + "Philosopher", + "Pick", + "Pilot", + "Pioneer", + "Pivot", + "Plan", + "Planner", + "Play", + "Player", + "Playmate", + "Pleasing", + "Pleasure", + "Plenitude", + "Plenty", + "Poet", + "Poise", + "Positive", + "Possessor", + "Possible", + "Postulant", + "Potential", + "Pragmatic", + "Praise", + "Prayer", + "Precious", + "Preemption", + "Premier", + "Premium", + "Presence", + "Present", + "Presenter", + "President", + "Pretty", + "Primary", + "Prime", + "Primogenitor", + "Prince", + "Princess", + "Principal", + "Prize", + "Pro", + "Proconsul", + "Procreator", + "Prodigy", + "Produce", + "Producer", + "Professional", + "Professor", + "Progenitor", + "Progeny", + "Progress", + "Progressive", + "Prolepsis", + "Promoter", + "Promotion", + "Promulgator", + "Prophet", + "Proponent", + "Proposer", + "Proprietor", + "Prospective", + "Protagonist", + "Protector", + "Protege", + "Provider", + "Provocative", + "Publisher", + "Purveyor", + "Quaff", + "Quaintise", + "Quaintisiness", + "Quality", + "Quantity", + "Quarter", + "Queen", + "Queenhood", + "Querist", + "Quest", + "Quester", + "Question", + "Quick", + "Quickness", + "Quickstep", + "Quiet", + "Quillet", + "Quintessence", + "Quip", + "Quirk", + "Quotation", + "Radiant", + "Rapture", + "Rational", + "Ready", + "Real", + "Reason", + "Receiver", + "Reception", + "Reciprocal", + "Recuperation", + "Regulator", + "Rejoicing", + "Rejuvenation", + "Release", + "Relief", + "Reliever", + "Renovation", + "Reputation", + "Resource", + "Respect", + "Restoration", + "Result", + "Reverence", + "Reward", + "Rhapsody", + "Rich", + "Right", + "Rise", + "Roll", + "Romantic", + "Romeo", + "Rooter", + "Rose", + "Round", + "Rouse", + "Ruler", + "Ruling", + "Run", + "Runner", + "Sage", + "Saint", + "Salient", + "Salubrity", + "Salute", + "Sanctity", + "Sanctuary", + "Satisfaction", + "Savant", + "Saver", + "Savior", + "Savory", + "Savvy", + "Scholar", + "Scholarship", + "Science", + "Scion", + "Script", + "Scripter", + "Sculptor", + "Seeker", + "Select", + "Sensation", + "Sense", + "Sensibility", + "Sentiment", + "Sentinel", + "Serendipity", + "Serene", + "Serenity", + "Server", + "Set", + "Settling", + "Shape", + "Share", + "Sharp", + "Sharpy", + "Shaver", + "Shelter", + "Shine", + "Show", + "Sight", + "Significance", + "Significant", + "Simplicity", + "Sinew", + "Sir", + "Sire", + "Sister", + "Size", + "Skill", + "Skin", + "Skipper", + "Sleek", + "Slick", + "Smash", + "Smile", + "Smooth", + "Smoothie", + "Snap", + "Snuggle", + "Soar", + "Sociable", + "Social", + "Socializer", + "Sol", + "Solid", + "Sophisticate", + "Soul", + "Sovereign", + "Spark", + "Sparkling", + "Special", + "Specialist", + "Spectacle", + "Spectacular", + "Speed", + "Spell", + "Spice", + "Spirit", + "Spiritual", + "Splendor", + "Sponsor", + "Sport", + "Sportsmanship", + "Spot", + "Sprite", + "Sprout", + "Squire", + "Stalwart", + "Standard", + "Star", + "State", + "Steady", + "Steward", + "Stipend", + "Stipendiary", + "Stir", + "Stirring", + "Strategist", + "Strategy", + "Stripling", + "Strive", + "Student", + "Style", + "Stylist", + "Sublime", + "Substance", + "Success", + "Successor", + "Sufficiency", + "Sugar", + "Suitor", + "Summary", + "Summer", + "Super", + "Superintendent", + "Superior", + "Superlative", + "Superman", + "Supervisor", + "Superwoman", + "Supplier", + "Supply", + "Support", + "Surety", + "Surprise", + "Survivor", + "Swain", + "Sweetheart", + "Sweetness", + "Swell", + "Sympathy", + "Synergy", + "System", + "Tact", + "Talent", + "Taste", + "Teacher", + "Teaching", + "Team", + "Teammate", + "Tender", + "Testament", + "Testator", + "Testimonial", + "Testimony", + "Thank", + "Thanksgiving", + "Therapy", + "Thinker", + "Thrill", + "Timesaver", + "Tiptop", + "Titleholder", + "Today", + "Tootsie", + "Top", + "Tot", + "Total", + "Touch", + "Tout", + "Trailblazer", + "Training", + "Tranquillity", + "Transcendent", + "Transient", + "Travel", + "Treasure", + "Trim", + "Triumph", + "Trust", + "Trustee", + "Trusty", + "Truth", + "Try", + "Tuition", + "Tune", + "Tutor", + "Tycoon", + "Uberty", + "Ubiquitary", + "Ultimate", + "Ultimation", + "Understanding", + "Underwriter", + "Unique", + "Universal", + "Upholder", + "Uplift", + "Valedictorian", + "Valiant", + "Validator", + "Valuable", + "Value", + "Vast", + "Vaulter", + "Veracity", + "Vestal", + "Veteran", + "Virtue", + "Virtuosity", + "Visionary", + "Visitor", + "Vitality", + "Vogue", + "Votary", + "Warden", + "Warmth", + "Warrantee", + "Warrantor", + "Welcome", + "Well", + "Wellspring", + "Whip", + "Whiz", + "Whole", + "Wife", + "Will", + "Willpower", + "Win", + "Winner", + "Winning", + "Wisdom", + "Wise", + "Wizard", + "Wonder", + "Wonderment", + "Wooer", + "Wordsmith", + "Workhorse", + "Workmate", + "Worshipper", + "Worth", + "Worthy", + "Writer", + "Wunderkind", + "X", + "Xenagogue", + "Xenium", + "Xenophile", + "Xenophilia", + "Xesturgy", + "Xfactor", + "Yard", + "Yeve", + "Yield", + "Yift", + "Yoke", + "Young", + "Youngster", + "Youth", + "Zaniness", + "Zarf", + "Zeal", + "Zegedine", + "Zest", + "Zibeline", + "Zing" + ] diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 91a9f9f4bb..8b6a402288 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -452,6 +452,7 @@ hasNotification = \case XFile_ -> True XContact_ -> True XGrpInv_ -> True + XGrpMemFwd_ -> True XGrpDel_ -> True XCallInv_ -> True _ -> False diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index dfb2a6a7db..fca0f85a1e 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -27,6 +27,7 @@ module Simplex.Chat.Store setActiveUser, createDirectConnection, createConnReqConnection, + getProfileById, getConnReqContactXContactId, createDirectContact, getContactGroupNames, @@ -37,6 +38,7 @@ module Simplex.Chat.Store getContactIdByName, updateUserProfile, updateContactProfile, + updateContactAlias, getUserContacts, createUserContactLink, getUserContactLinkConnections, @@ -76,7 +78,7 @@ module Simplex.Chat.Store getUserGroups, getUserGroupDetails, getGroupInvitation, - createContactMember, + createNewContactMember, getMemberInvitation, createMemberConnection, updateGroupMemberStatus, @@ -95,6 +97,7 @@ module Simplex.Chat.Store randomBytes, createSentProbe, createSentProbeHash, + deleteSentProbe, matchReceivedProbe, matchReceivedProbeHash, matchSentProbe, @@ -173,6 +176,8 @@ module Simplex.Chat.Store getCalls, getPendingContactConnection, deletePendingContactConnection, + updateContactSettings, + updateGroupSettings, withTransaction, ) where @@ -193,6 +198,7 @@ import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, sortBy, sortOn) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Ord (Down (..)) import Data.Text (Text) @@ -221,6 +227,12 @@ 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.Protocol import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..)) @@ -230,6 +242,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (StrEncoding (strEncode)) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..), SMPServer, pattern SMPServer) +import Simplex.Messaging.Transport.Client (TransportHost) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM @@ -248,7 +261,13 @@ schemaMigrations = ("20220514_profiles_user_id", m20220514_profiles_user_id), ("20220626_auto_reply", m20220626_auto_reply), ("20220702_calls", m20220702_calls), - ("20220715_groups_chat_item_id", m20220715_groups_chat_item_id) + ("20220715_groups_chat_item_id", m20220715_groups_chat_item_id), + ("20220811_chat_items_indices", m20220811_chat_items_indices), + ("20220812_incognito_profiles", m20220812_incognito_profiles), + ("20220818_chat_notifications", m20220818_chat_notifications), + ("20220822_groups_host_conn_custom_user_profile_id", m20220822_groups_host_conn_custom_user_profile_id), + ("20220823_delete_broken_group_event_chat_items", m20220823_delete_broken_group_event_chat_items), + ("20220824_profiles_local_alias", m20220824_profiles_local_alias) ] -- | The list of migrations in ascending order by date @@ -298,7 +317,7 @@ createUser db Profile {displayName, fullName, image} activeUser = (profileId, displayName, userId, True, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser (userId, contactId, activeUser, displayName, fullName, image) + pure $ toUser (userId, contactId, profileId, activeUser, displayName, fullName, image) getUsers :: DB.Connection -> IO [User] getUsers db = @@ -306,15 +325,15 @@ getUsers db = <$> DB.query_ db [sql| - SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name, p.image + SELECT u.user_id, u.contact_id, p.contact_profile_id, u.active_user, u.local_display_name, p.full_name, p.image FROM users u JOIN contacts c ON u.contact_id = c.contact_id JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id |] -toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ImageData) -> User -toUser (userId, userContactId, activeUser, displayName, fullName, image) = - let profile = Profile {displayName, fullName, image} +toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData) -> User +toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image) = + let profile = LocalProfile {profileId, displayName, fullName, image, localAlias = ""} in User {userId, userContactId, localDisplayName = displayName, profile, activeUser} setActiveUser :: DB.Connection -> UserId -> IO () @@ -322,21 +341,22 @@ setActiveUser db userId = do DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> IO PendingContactConnection -createConnReqConnection db userId acId cReqHash xContactId = do +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> IO PendingContactConnection +createConnReqConnection db userId acId cReqHash xContactId incognitoProfile = do createdAt <- getCurrentTime + customUserProfileId <- createIncognitoProfile_ db userId createdAt incognitoProfile let pccConnStatus = ConnJoined DB.execute db [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, - created_at, updated_at, via_contact_uri_hash, xcontact_id - ) VALUES (?,?,?,?,?,?,?,?) + via_contact_uri_hash, xcontact_id, custom_user_profile_id, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?) |] - (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt, cReqHash, xContactId) + (userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId, customUserProfileId, createdAt, createdAt) pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, createdAt, updatedAt = createdAt} + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, customUserProfileId, createdAt, updatedAt = createdAt} getConnReqContactXContactId :: DB.Connection -> UserId -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) getConnReqContactXContactId db userId cReqHash = do @@ -352,9 +372,9 @@ getConnReqContactXContactId db userId cReqHash = do [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.enable_ntfs, ct.created_at, ct.updated_at, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -372,47 +392,72 @@ getConnReqContactXContactId db userId cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -createDirectConnection :: DB.Connection -> UserId -> ConnId -> ConnStatus -> IO PendingContactConnection -createDirectConnection db userId acId pccConnStatus = do +createDirectConnection :: DB.Connection -> UserId -> ConnId -> ConnStatus -> Maybe Profile -> IO PendingContactConnection +createDirectConnection db userId acId pccConnStatus incognitoProfile = do createdAt <- getCurrentTime + customUserProfileId <- createIncognitoProfile_ db userId createdAt incognitoProfile DB.execute db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_status, conn_type, created_at, updated_at) VALUES (?,?,?,?,?,?) + (user_id, agent_conn_id, conn_status, conn_type, custom_user_profile_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?) |] - (userId, acId, pccConnStatus, ConnContact, createdAt, createdAt) + (userId, acId, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt) pccConnId <- insertedRowId db - pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, createdAt, updatedAt = createdAt} + pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, customUserProfileId, createdAt, updatedAt = createdAt} -createMemberContactConnection_ :: DB.Connection -> UserId -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createMemberContactConnection_ db userId agentConnId viaContact = createConnection_ db userId ConnContact Nothing agentConnId viaContact Nothing +createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Maybe Profile -> IO (Maybe Int64) +createIncognitoProfile_ db userId createdAt incognitoProfile = + forM incognitoProfile $ \Profile {displayName, fullName, image} -> do + DB.execute + db + [sql| + 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) + insertedRowId db -createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe Int64 -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createConnection_ db userId connType entityId acId viaContact viaUserContactLink connLevel currentTs = do +getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile +getProfileById db userId profileId = + ExceptT . firstRow toProfile (SEProfileNotFound profileId) $ + DB.query + db + [sql| + SELECT display_name, full_name, image, local_alias + FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? + |] + (userId, profileId) + where + toProfile :: (ContactName, Text, Maybe ImageData, LocalAlias) -> LocalProfile + toProfile (displayName, fullName, image, localAlias) = LocalProfile {profileId, displayName, fullName, image, localAlias} + +createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> IO Connection +createConnection_ db userId connType entityId acId viaContact viaUserContactLink customUserProfileId connLevel currentTs = do DB.execute db [sql| INSERT INTO connections ( - user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, conn_status, conn_type, + user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, connLevel, viaContact, viaUserContactLink, ConnNew, connType) + ( (userId, acId, connLevel, viaContact, viaUserContactLink, customUserProfileId, ConnNew, connType) :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, viaUserContactLink, connLevel, connStatus = ConnNew, createdAt = currentTs} + pure Connection {connId, agentConnId = AgentConnId acId, connType, entityId, viaContact, viaUserContactLink, customUserProfileId, connLevel, connStatus = ConnNew, createdAt = currentTs} where ent ct = if connType == ct then entityId else Nothing createDirectContact :: DB.Connection -> UserId -> Connection -> Profile -> ExceptT StoreError IO Contact createDirectContact db userId activeConn@Connection {connId} profile = do createdAt <- liftIO getCurrentTime - (localDisplayName, contactId, _) <- createContact_ db userId connId profile Nothing createdAt - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt, updatedAt = createdAt} + (localDisplayName, contactId, profileId) <- createContact_ db userId connId profile Nothing createdAt + pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, chatSettings = defaultChatSettings, createdAt, updatedAt = createdAt} -createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, Int64, Int64) +createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (Text, ContactId, ProfileId) createContact_ db userId connId Profile {displayName, fullName, image} viaGroup currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute @@ -426,7 +471,7 @@ createContact_ db userId connId Profile {displayName, fullName, image} viaGroup (profileId, ldn, userId, viaGroup, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) - pure (ldn, contactId, profileId) + pure . Right $ (ldn, contactId, profileId) getContactGroupNames :: DB.Connection -> UserId -> Contact -> IO [GroupName] getContactGroupNames db userId Contact {contactId} = @@ -478,9 +523,9 @@ deleteContactProfile_ db userId contactId = (userId, contactId) updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () -updateUserProfile db User {userId, userContactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} +updateUserProfile db User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName}} p'@Profile {displayName = newName} | displayName == newName = - liftIO $ updateContactProfile_ db userId userContactId p' + liftIO $ updateContactProfile_ db userId profileId p' | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime @@ -489,49 +534,48 @@ updateUserProfile db User {userId, userContactId, localDisplayName, profile = Pr db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) - updateContactProfile_' db userId userContactId p' currentTs + updateContactProfile_' db userId profileId p' currentTs updateContact_ db userId userContactId localDisplayName newName currentTs updateContactProfile :: DB.Connection -> UserId -> Contact -> Profile -> ExceptT StoreError IO Contact -updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = Profile {displayName}} p'@Profile {displayName = newName} +updateContactProfile db userId c@Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}} p'@Profile {displayName = newName} | displayName == newName = - liftIO $ updateContactProfile_ db userId contactId p' $> (c :: Contact) {profile = p'} + liftIO $ updateContactProfile_ db userId profileId p' $> (c :: Contact) {profile = toLocalProfile profileId p' localAlias} | otherwise = ExceptT . withLocalDisplayName db userId newName $ \ldn -> do currentTs <- getCurrentTime - updateContactProfile_' db userId contactId p' currentTs + updateContactProfile_' db userId profileId p' currentTs updateContact_ db userId contactId localDisplayName ldn currentTs - pure $ (c :: Contact) {localDisplayName = ldn, profile = p'} + pure . Right $ (c :: Contact) {localDisplayName = ldn, profile = toLocalProfile profileId p' localAlias} -updateContactProfile_ :: DB.Connection -> UserId -> Int64 -> Profile -> IO () -updateContactProfile_ db userId contactId profile = do - currentTs <- getCurrentTime - updateContactProfile_' db userId contactId profile currentTs - -updateContactProfile_' :: DB.Connection -> UserId -> Int64 -> Profile -> UTCTime -> IO () -updateContactProfile_' db userId contactId Profile {displayName, fullName, image} updatedAt = do - DB.executeNamed +updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact +updateContactAlias db userId c@Contact {profile = lp@LocalProfile {profileId}} localAlias = do + updatedAt <- getCurrentTime + DB.execute db [sql| UPDATE contact_profiles - SET display_name = :display_name, - full_name = :full_name, - image = :image, - updated_at = :updated_at - WHERE contact_profile_id IN ( - SELECT contact_profile_id - FROM contacts - WHERE user_id = :user_id - AND contact_id = :contact_id - ) + SET local_alias = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? |] - [ ":display_name" := displayName, - ":full_name" := fullName, - ":image" := image, - ":updated_at" := updatedAt, - ":user_id" := userId, - ":contact_id" := contactId - ] + (localAlias, updatedAt, userId, profileId) + pure $ (c :: Contact) {profile = lp {localAlias = localAlias}} + +updateContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> IO () +updateContactProfile_ db userId profileId profile = do + currentTs <- getCurrentTime + updateContactProfile_' db userId profileId profile currentTs + +updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO () +updateContactProfile_' db userId profileId Profile {displayName, fullName, image} updatedAt = do + DB.execute + db + [sql| + UPDATE contact_profiles + SET display_name = ?, full_name = ?, image = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + (displayName, fullName, image, updatedAt, userId, profileId) updateContact_ :: DB.Connection -> UserId -> Int64 -> ContactName -> ContactName -> UTCTime -> IO () updateContact_ db userId contactId displayName newName updatedAt = do @@ -545,27 +589,29 @@ updateContact_ db userId contactId displayName newName updatedAt = do (newName, updatedAt, userId, contactId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId) -type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, UTCTime, UTCTime) +type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, LocalAlias, Maybe Bool, UTCTime, UTCTime) toContact :: ContactRow :. ConnectionRow -> Contact -toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt, updatedAt) :. connRow) = - let profile = Profile {displayName, fullName, image} +toContact ((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, enableNtfs_, createdAt, updatedAt) :. connRow) = + let profile = LocalProfile {profileId, displayName, fullName, image, localAlias} activeConn = toConnection connRow - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt} + chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, chatSettings, createdAt, updatedAt} toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact -toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt, updatedAt) :. connRow) = - let profile = Profile {displayName, fullName, image} +toContactOrError ((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, localAlias, enableNtfs_, createdAt, updatedAt) :. connRow) = + let profile = LocalProfile {profileId, displayName, fullName, image, localAlias} + chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} in case toMaybeConnection connRow of Just activeConn -> - Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt} + Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, chatSettings, createdAt, updatedAt} _ -> Left $ SEContactNotReady localDisplayName -- TODO return the last connection that is ready, not any last connection -- requires updating connection status -getContactByName :: DB.Connection -> UserId -> ContactName -> ExceptT StoreError IO Contact -getContactByName db userId localDisplayName = do - cId <- getContactIdByName db userId localDisplayName +getContactByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Contact +getContactByName db user@User {userId} localDisplayName = do + cId <- getContactIdByName db user localDisplayName getContact db userId cId getUserContacts :: DB.Connection -> User -> IO [Contact] @@ -582,7 +628,7 @@ createUserContactLink db userId agentConnId cReq = "INSERT INTO user_contact_links (user_id, conn_req_contact, created_at, updated_at) VALUES (?,?,?,?)" (userId, cReq, currentTs, currentTs) userContactLinkId <- insertedRowId db - void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing 0 currentTs + void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId Nothing Nothing Nothing 0 currentTs getUserContactLinkConnections :: DB.Connection -> User -> ExceptT StoreError IO [Connection] getUserContactLinkConnections db user = do @@ -595,7 +641,7 @@ getUserContactLinks db User {userId} = <$> DB.queryNamed db [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, uc.user_contact_link_id, uc.conn_req_contact FROM connections c @@ -713,7 +759,7 @@ createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayN createContactRequest :: IO (Either StoreError Int64) createContactRequest = do currentTs <- getCurrentTime - withLocalDisplayName db userId displayName (createContactRequest_ currentTs) + withLocalDisplayName db userId displayName (fmap Right . createContactRequest_ currentTs) where createContactRequest_ currentTs ldn = do DB.execute @@ -738,9 +784,9 @@ createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayN [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.enable_ntfs, ct.created_at, ct.updated_at, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -773,9 +819,10 @@ createOrUpdateContactRequest db userId userContactLinkId invId Profile {displayN updateProfile currentTs if displayName == oldDisplayName then Right <$> DB.execute db "UPDATE contact_requests SET agent_invitation_id = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ?" (invId, currentTs, userId, cReqId) - else withLocalDisplayName db userId displayName $ \ldn -> do - DB.execute db "UPDATE contact_requests SET agent_invitation_id = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ?" (invId, ldn, currentTs, userId, cReqId) - DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId) + else withLocalDisplayName db userId displayName $ \ldn -> + Right <$> do + DB.execute db "UPDATE contact_requests SET agent_invitation_id = ?, local_display_name = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ?" (invId, ldn, currentTs, userId, cReqId) + DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (oldLdn, userId) where updateProfile currentTs = DB.execute @@ -849,17 +896,18 @@ deleteContactRequest db userId contactRequestId = do (userId, userId, contactRequestId) DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND contact_request_id = ?" (userId, contactRequestId) -createAcceptedContact :: DB.Connection -> UserId -> ConnId -> ContactName -> Int64 -> Profile -> Int64 -> Maybe XContactId -> IO Contact -createAcceptedContact db userId agentConnId localDisplayName profileId profile userContactLinkId xContactId = do +createAcceptedContact :: DB.Connection -> UserId -> ConnId -> ContactName -> ProfileId -> Profile -> Int64 -> Maybe XContactId -> Maybe Profile -> IO Contact +createAcceptedContact db userId agentConnId localDisplayName profileId profile userContactLinkId xContactId incognitoProfile = do DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) - currentTs <- getCurrentTime + createdAt <- getCurrentTime + customUserProfileId <- createIncognitoProfile_ db userId createdAt incognitoProfile DB.execute db - "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?)" - (userId, localDisplayName, profileId, currentTs, currentTs, xContactId) + "INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, created_at, updated_at, xcontact_id) VALUES (?,?,?,?,?,?,?)" + (userId, localDisplayName, profileId, True, createdAt, createdAt, xContactId) contactId <- insertedRowId db - activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) 0 currentTs - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt = currentTs, updatedAt = currentTs} + activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId Nothing (Just userContactLinkId) customUserProfileId 0 createdAt + pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, chatSettings = defaultChatSettings, createdAt = createdAt, updatedAt = createdAt} getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] getLiveSndFileTransfers db User {userId} = do @@ -913,7 +961,7 @@ getPendingContactConnections db User {userId} = do <$> DB.queryNamed db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, custom_user_profile_id, created_at, updated_at FROM connections WHERE user_id = :user_id AND conn_type = :conn_type @@ -929,7 +977,7 @@ getContactConnections db userId Contact {contactId} = DB.query db [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id @@ -941,14 +989,14 @@ getContactConnections db userId Contact {contactId} = type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, ConnStatus, ConnType) :. EntityIdsRow :. Only UTCTime +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Maybe Int64, ConnStatus, ConnType) :. EntityIdsRow :. Only UTCTime -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe ConnStatus, Maybe ConnType) :. EntityIdsRow :. Only (Maybe UTCTime) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Int64, Maybe ConnStatus, Maybe ConnType) :. EntityIdsRow :. Only (Maybe UTCTime) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, connStatus, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, customUserProfileId, connStatus, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt) = let entityId = entityId_ connType - in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, viaUserContactLink, connStatus, connType, entityId, createdAt} + in Connection {connId, agentConnId = AgentConnId acId, connLevel, viaContact, viaUserContactLink, customUserProfileId, connStatus, connType, entityId, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -958,30 +1006,25 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, connStat entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just connStatus, Just connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only (Just createdAt)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, connStatus, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, customUserProfileId, Just connStatus, Just connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only (Just createdAt)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, customUserProfileId, connStatus, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. Only createdAt) toMaybeConnection _ = Nothing getMatchingContacts :: DB.Connection -> UserId -> Contact -> IO [Contact] -getMatchingContacts db userId Contact {contactId, profile = Profile {displayName, fullName, image}} = do +getMatchingContacts db userId Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly - <$> DB.queryNamed + <$> DB.query db [sql| SELECT ct.contact_id FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - WHERE ct.user_id = :user_id AND ct.contact_id != :contact_id - AND p.display_name = :display_name AND p.full_name = :full_name - AND ((p.image IS NULL AND :image IS NULL) OR p.image = :image) + WHERE ct.user_id = ? AND ct.contact_id != ? + AND p.display_name = ? AND p.full_name = ? + AND ((p.image IS NULL AND ? IS NULL) OR p.image = ?) |] - [ ":user_id" := userId, - ":contact_id" := contactId, - ":display_name" := displayName, - ":full_name" := fullName, - ":image" := image - ] + (userId, contactId, displayName, fullName, image, image) rights <$> mapM (runExceptT . getContact db userId) contactIds createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> Contact -> ExceptT StoreError IO (Probe, Int64) @@ -1002,6 +1045,13 @@ createSentProbeHash db userId probeId _to@Contact {contactId} = do "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (probeId, contactId, userId, currentTs, currentTs) +deleteSentProbe :: DB.Connection -> UserId -> Int64 -> IO () +deleteSentProbe db userId probeId = + DB.execute + db + "DELETE FROM sent_probes WHERE user_id = ? AND sent_probe_id = ?" + (userId, probeId) + matchReceivedProbe :: DB.Connection -> UserId -> Contact -> Probe -> IO (Maybe Contact) matchReceivedProbe db userId _from@Contact {contactId} (Probe probe) = do let probeHash = C.sha256Hash probe @@ -1123,7 +1173,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, + SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, custom_user_profile_id, conn_status, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at FROM connections WHERE user_id = ? AND agent_conn_id = ? @@ -1135,16 +1185,17 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do <$> DB.query db [sql| - SELECT c.local_display_name, p.display_name, p.full_name, p.image, c.via_group, c.created_at, c.updated_at + SELECT c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.local_alias, c.via_group, c.enable_ntfs, c.created_at, c.updated_at FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime, UTCTime)] -> Either StoreError Contact - toContact' contactId activeConn [(localDisplayName, displayName, fullName, image, viaGroup, createdAt, updatedAt)] = - let profile = Profile {displayName, fullName, image} - in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt} + toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, LocalAlias, Maybe Int64, Maybe Bool, UTCTime, UTCTime)] -> Either StoreError Contact + toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, enableNtfs_, createdAt, updatedAt)] = + let profile = LocalProfile {profileId, displayName, fullName, image, localAlias} + chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} + in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, chatSettings, createdAt, updatedAt} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = ExceptT $ do @@ -1154,21 +1205,21 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + mu.member_status, mu.invited_by, 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.display_name, pu.full_name, pu.image, pu.local_alias, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image + m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_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 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 = mu.contact_profile_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) @@ -1242,23 +1293,23 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + mu.member_status, mu.invited_by, 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.display_name, pu.full_name, pu.image, pu.local_alias, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_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 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 = mu.contact_profile_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 @@ -1284,26 +1335,28 @@ createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> Exc createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do let GroupProfile {displayName, fullName, image} = groupProfile currentTs <- getCurrentTime - withLocalDisplayName db userId displayName $ \ldn -> do - DB.execute - db - "INSERT INTO group_profiles (display_name, full_name, image, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (displayName, fullName, image, userId, currentTs, currentTs) - profileId <- insertedRowId db - DB.execute - db - "INSERT INTO groups (local_display_name, user_id, group_profile_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (ldn, userId, profileId, currentTs, currentTs) - groupId <- insertedRowId db - memberId <- encodedRandomBytes gVar 12 - membership <- createContactMember_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser currentTs - pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, createdAt = currentTs, updatedAt = currentTs} + withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + groupId <- liftIO $ do + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, image, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (displayName, fullName, image, userId, currentTs, currentTs) + profileId <- insertedRowId db + DB.execute + db + "INSERT INTO groups (local_display_name, user_id, group_profile_id, enable_ntfs, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (ldn, userId, profileId, True, currentTs, currentTs) + insertedRowId db + memberId <- liftIO $ encodedRandomBytes gVar 12 + membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs + let chatSettings = ChatSettings {enableNtfs = True} + pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one -createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> ExceptT StoreError IO GroupInfo -createGroupInvitation db user@User {userId} contact@Contact {contactId} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} = +createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO GroupInfo +createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do liftIO getInvitationGroupId_ >>= \case - Nothing -> ExceptT createGroupInvitation_ + Nothing -> createGroupInvitation_ -- TODO treat the case that the invitation details could've changed Just gId -> getGroupInfo db user gId where @@ -1311,24 +1364,82 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId} GroupInv getInvitationGroupId_ = maybeFirstRow fromOnly $ DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) - createGroupInvitation_ :: IO (Either StoreError GroupInfo) + createGroupInvitation_ :: ExceptT StoreError IO GroupInfo createGroupInvitation_ = do let GroupProfile {displayName, fullName, image} = groupProfile - withLocalDisplayName db userId displayName $ \localDisplayName -> do - currentTs <- getCurrentTime + ExceptT $ + withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + currentTs <- liftIO getCurrentTime + groupId <- liftIO $ do + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, image, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (displayName, fullName, image, userId, currentTs, currentTs) + profileId <- insertedRowId db + DB.execute + db + "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (profileId, localDisplayName, connRequest, customUserProfileId, userId, True, currentTs, currentTs) + insertedRowId db + _ <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs + membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs + let chatSettings = ChatSettings {enableNtfs = True} + pure GroupInfo {groupId, localDisplayName, groupProfile, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs} + +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> ExceptT StoreError IO GroupMember +createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt = do + incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId + (localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of + (Just profile@LocalProfile {displayName}, Just profileId) -> + (,profile) <$> insertMemberIncognitoProfile_ displayName profileId + _ -> (,profile' userOrContact) <$> liftIO insertMember_ + groupMemberId <- liftIO $ insertedRowId db + pure + GroupMember + { groupMemberId, + groupId, + memberId, + memberRole, + memberCategory, + memberStatus, + invitedBy, + localDisplayName, + memberProfile, + memberContactId = Just $ contactId' userOrContact, + memberContactProfileId = localProfileId (profile' userOrContact), + activeConn = Nothing + } + where + insertMember_ :: IO ContactName + insertMember_ = do + let localDisplayName = localDisplayName' userOrContact + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy) + :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) + ) + pure localDisplayName + insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO ContactName + insertMemberIncognitoProfile_ incognitoDisplayName customUserProfileId = ExceptT $ + withLocalDisplayName db userId incognitoDisplayName $ \incognitoLdn -> do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, image, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (displayName, fullName, image, userId, currentTs, currentTs) - profileId <- insertedRowId db - DB.execute - db - "INSERT INTO groups (group_profile_id, local_display_name, inv_queue_info, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (profileId, localDisplayName, connRequest, userId, currentTs, currentTs) - groupId <- insertedRowId db - _ <- createContactMember_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown currentTs - membership <- createContactMember_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) currentTs - pure $ GroupInfo {groupId, localDisplayName, groupProfile, membership, createdAt = currentTs, updatedAt = currentTs} + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy) + :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + ) + pure $ Right incognitoLdn setGroupInvitationChatItemId :: DB.Connection -> User -> GroupId -> ChatItemId -> IO () setGroupInvitationChatItemId db User {userId} groupId chatItemId = do @@ -1384,14 +1495,14 @@ getUserGroupDetails db User {userId, userContactId} = <$> DB.query db [sql| - SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, - m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name, mp.image + SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at, + mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, + mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.local_alias FROM groups g JOIN group_profiles gp USING (group_profile_id) - JOIN group_members m USING (group_id) - JOIN contact_profiles mp USING (contact_profile_id) - WHERE g.user_id = ? AND m.contact_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 = ? |] (userId, userContactId) @@ -1400,12 +1511,13 @@ getGroupInfoByName db user gName = do gId <- getGroupIdByName db user gName getGroupInfo db user gId -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, UTCTime, UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, UTCTime, UTCTime) :. GroupMemberRow toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo -toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, createdAt, updatedAt) :. userMemberRow) = +toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, hostConnCustomUserProfileId, enableNtfs_, createdAt, updatedAt) :. userMemberRow) = let membership = toGroupMember userContactId userMemberRow - in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, createdAt, updatedAt} + chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} + in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt} getGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMember db user@User {userId} groupId groupMemberId = @@ -1415,11 +1527,11 @@ getGroupMember db user@User {userId} groupId groupMemberId = [sql| SELECT m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.connection_id = ( SELECT max(cc.connection_id) FROM connections cc @@ -1437,11 +1549,11 @@ getGroupMembers db user@User {userId, userContactId} GroupInfo {groupId} = do [sql| SELECT m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN connections c ON c.connection_id = ( SELECT max(cc.connection_id) FROM connections cc @@ -1475,29 +1587,61 @@ getGroupInvitation db user groupId = do findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId) findFromContact _ = const Nothing -type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData) +type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, LocalAlias)) -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text, Maybe ImageData) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe LocalAlias)) toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) = - let memberProfile = Profile {displayName, fullName, image} +toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, localAlias)) = + let memberProfile = LocalProfile {profileId, displayName, fullName, image, localAlias} invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, invitedById, Just localDisplayName, memberContactId, Just displayName, Just fullName, image) = - Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, Just localAlias)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, localAlias)) toMaybeGroupMember _ _ = Nothing -createContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> ExceptT StoreError IO GroupMember -createContactMember db gVar user groupId contact memberRole agentConnId connRequest = +createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> ExceptT StoreError IO GroupMember +createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile} memberRole agentConnId connRequest = createWithRandomId gVar $ \memId -> do - currentTs <- getCurrentTime - member@GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact (MemberIdRole (MemberId memId) memberRole) GCInviteeMember GSMemInvited IBUser (Just connRequest) currentTs - void $ createMemberConnection_ db (userId user) groupMemberId agentConnId Nothing 0 currentTs + createdAt <- liftIO getCurrentTime + member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt + void $ createMemberConnection_ db userId groupMemberId agentConnId Nothing 0 createdAt pure member + where + createMember_ memberId createdAt = do + insertMember_ + groupMemberId <- liftIO $ insertedRowId db + pure + GroupMember + { groupMemberId, + groupId, + memberId, + memberRole, + memberCategory = GCInviteeMember, + memberStatus = GSMemInvited, + invitedBy = IBUser, + localDisplayName, + memberProfile = profile, + memberContactId = Just contactId, + memberContactProfileId = localProfileId profile, + activeConn = Nothing + } + where + insertMember_ = + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser) + :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) + ) getMemberInvitation :: DB.Connection -> User -> Int64 -> IO (Maybe ConnReqInvitation) getMemberInvitation db User {userId} groupMemberId = @@ -1512,18 +1656,14 @@ createMemberConnection db userId GroupMember {groupMemberId} agentConnId = do updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () updateGroupMemberStatus db userId GroupMember {groupMemberId} memStatus = do currentTs <- getCurrentTime - DB.executeNamed + DB.execute db [sql| UPDATE group_members - SET member_status = :member_status, updated_at = :updated_at - WHERE user_id = :user_id AND group_member_id = :group_member_id + SET member_status = ?, updated_at = ? + WHERE user_id = ? AND group_member_id = ? |] - [ ":user_id" := userId, - ":group_member_id" := groupMemberId, - ":member_status" := memStatus, - ":updated_at" := currentTs - ] + (memStatus, currentTs, userId, groupMemberId) -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember @@ -1545,7 +1685,7 @@ createNewGroupMember db user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile memContactId = Nothing, memProfileId } - createNewMember_ db user gInfo newMember currentTs + Right <$> createNewMember_ db user gInfo newMember currentTs createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember createNewMember_ @@ -1559,7 +1699,7 @@ createNewMember_ memInvitedBy = invitedBy, localDisplayName, memContactId = memberContactId, - memProfileId + memProfileId = memberContactProfileId } createdAt = do let invitedById = fromInvitedBy userContactId invitedBy @@ -1569,12 +1709,12 @@ createNewMember_ [sql| INSERT INTO group_members (group_id, member_id, member_role, member_category, member_status, - invited_by, user_id, local_display_name, contact_profile_id, contact_id, created_at, updated_at) + invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memProfileId, memberContactId, createdAt, createdAt) + (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) groupMemberId <- insertedRowId db - pure GroupMember {..} + pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} deleteGroupMember :: DB.Connection -> User -> GroupMember -> IO () deleteGroupMember db user@User {userId} m@GroupMember {groupMemberId} = do @@ -1680,11 +1820,11 @@ getIntroduction_ db reMember toMember = ExceptT $ do in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} toIntro _ = Left SEIntroNotFound -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> ConnId -> ConnId -> ExceptT StoreError IO GroupMember -createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId = do +createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> ConnId -> ConnId -> Maybe ProfileId -> ExceptT StoreError IO GroupMember +createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupMember {memberContactId, activeConn} memInfo@(MemberInfo _ _ memberProfile) groupAgentConnId directAgentConnId customUserProfileId = do let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn currentTs <- liftIO getCurrentTime - Connection {connId = directConnId} <- liftIO $ createMemberContactConnection_ db userId directAgentConnId memberContactId cLevel currentTs + Connection {connId = directConnId} <- liftIO $ createConnection_ db userId ConnContact Nothing directAgentConnId memberContactId Nothing customUserProfileId cLevel currentTs (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile (Just groupId) currentTs liftIO $ do let newMember = @@ -1701,12 +1841,12 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM conn <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId memberContactId cLevel currentTs pure (member :: GroupMember) {activeConn = Just conn} -createIntroToMemberContact :: DB.Connection -> UserId -> GroupMember -> GroupMember -> ConnId -> ConnId -> IO () -createIntroToMemberContact db userId GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} groupAgentConnId directAgentConnId = do +createIntroToMemberContact :: DB.Connection -> UserId -> GroupMember -> GroupMember -> ConnId -> ConnId -> Maybe ProfileId -> IO () +createIntroToMemberContact db userId GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} groupAgentConnId directAgentConnId customUserProfileId = do let cLevel = 1 + maybe 0 (connLevel :: Connection -> Int) activeConn currentTs <- getCurrentTime void $ createMemberConnection_ db userId groupMemberId groupAgentConnId viaContactId cLevel currentTs - Connection {connId = directConnId} <- createMemberContactConnection_ db userId directAgentConnId viaContactId cLevel currentTs + Connection {connId = directConnId} <- createConnection_ db userId ConnContact Nothing directAgentConnId viaContactId Nothing customUserProfileId cLevel currentTs contactId <- createMemberContact_ directConnId currentTs updateMember_ contactId currentTs where @@ -1736,48 +1876,7 @@ createIntroToMemberContact db userId GroupMember {memberContactId = viaContactId [":contact_id" := contactId, ":updated_at" := ts, ":group_member_id" := groupMemberId] createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Maybe Int64 -> Int -> UTCTime -> IO Connection -createMemberConnection_ db userId groupMemberId agentConnId viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId viaContact Nothing - -createContactMember_ :: IsContact a => DB.Connection -> User -> Int64 -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> UTCTime -> IO GroupMember -createContactMember_ db user groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy = - createContactMemberInv_ db user groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy Nothing - -createContactMemberInv_ :: IsContact a => DB.Connection -> User -> Int64 -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ConnReqInvitation -> UTCTime -> IO GroupMember -createContactMemberInv_ db User {userId, userContactId} groupId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy connRequest createdAt = do - insertMember_ - groupMemberId <- insertedRowId db - let memberProfile = profile' userOrContact - memberContactId = Just $ contactId' userOrContact - localDisplayName = localDisplayName' userOrContact - activeConn = Nothing - pure GroupMember {..} - where - insertMember_ = - DB.executeNamed - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_profile_id, contact_id, sent_inv_queue_info, created_at, updated_at) - VALUES - (:group_id,:member_id,:member_role,:member_category,:member_status,:invited_by, - :user_id,:local_display_name, - (SELECT contact_profile_id FROM contacts WHERE contact_id = :contact_id), - :contact_id, :sent_inv_queue_info, :created_at, :updated_at) - |] - [ ":group_id" := groupId, - ":member_id" := memberId, - ":member_role" := memberRole, - ":member_category" := memberCategory, - ":member_status" := memberStatus, - ":invited_by" := fromInvitedBy userContactId invitedBy, - ":user_id" := userId, - ":local_display_name" := localDisplayName' userOrContact, - ":contact_id" := contactId' userOrContact, - ":sent_inv_queue_info" := connRequest, - ":created_at" := createdAt, - ":updated_at" := createdAt - ] +createMemberConnection_ db userId groupMemberId agentConnId viaContact = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId viaContact Nothing Nothing getViaGroupMember :: DB.Connection -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) getViaGroupMember db User {userId, userContactId} Contact {contactId} = @@ -1787,24 +1886,24 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + mu.member_status, mu.invited_by, 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.display_name, pu.full_name, pu.image, pu.local_alias, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, - m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id - JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_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 = mu.contact_profile_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 @@ -1827,8 +1926,8 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} = db [sql| SELECT - ct.contact_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.created_at, ct.updated_at, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.local_alias, ct.via_group, ct.enable_ntfs, ct.created_at, ct.updated_at, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id @@ -1843,11 +1942,12 @@ getViaGroupContact db User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact' :: (Int64, ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime, UTCTime) :. ConnectionRow -> Contact - toContact' ((contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt, updatedAt) :. connRow) = - let profile = Profile {displayName, fullName, image} + toContact' :: (ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, LocalAlias, Maybe Int64, Maybe Bool, UTCTime, UTCTime) :. ConnectionRow -> Contact + toContact' ((contactId, profileId, localDisplayName, displayName, fullName, image, localAlias, viaGroup, enableNtfs_, createdAt, updatedAt) :. connRow) = + let profile = LocalProfile {profileId, displayName, fullName, image, localAlias} + chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_} activeConn = toConnection connRow - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt, updatedAt} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, chatSettings, createdAt, updatedAt} createSndFileTransfer :: DB.Connection -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> IO Int64 createSndFileTransfer db userId Contact {contactId} filePath FileInvitation {fileName, fileSize} acId chunkSize = do @@ -1953,7 +2053,7 @@ getChatRefByFileId db User {userId} fileId = createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection createSndFileConnection_ db userId fileId agentConnId = do currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId Nothing Nothing 0 currentTs + createConnection_ db userId ConnSndFile (Just fileId) agentConnId Nothing Nothing Nothing 0 currentTs updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do @@ -1971,14 +2071,12 @@ createSndFileChunk db SndFileTransfer {fileId, connId, fileSize, chunkSize} = do pure $ case map fromOnly ns of [] -> Just 1 n : _ -> if n * chunkSize >= fileSize then Nothing else Just (n + 1) - insertChunk = \case - Just chunkNo -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT OR REPLACE INTO snd_file_chunks (file_id, connection_id, chunk_number, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, connId, chunkNo, currentTs, currentTs) - Nothing -> pure () + insertChunk chunkNo_ = forM_ chunkNo_ $ \chunkNo -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT OR REPLACE INTO snd_file_chunks (file_id, connection_id, chunk_number, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, connId, chunkNo, currentTs, currentTs) updateSndFileChunkMsg :: DB.Connection -> SndFileTransfer -> Integer -> AgentMsgId -> IO () updateSndFileChunkMsg db SndFileTransfer {fileId, connId} chunkNo msgId = do @@ -2487,9 +2585,7 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q |] ((userId, msgId_) :. idsRow :. itemRow :. quoteRow) ciId <- insertedRowId db - case msgId_ of - Just msgId -> insertChatItemMessage_ db ciId msgId createdAt - Nothing -> pure () + forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId where itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId, UTCTime, UTCTime) @@ -2551,10 +2647,10 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image + m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + p.display_name, p.full_name, p.image, p.local_alias FROM group_members m - JOIN contact_profiles p ON m.contact_profile_id = p.contact_profile_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id LEFT JOIN chat_items i ON i.group_id = m.group_id AND m.group_member_id = i.group_member_id @@ -2589,9 +2685,9 @@ getDirectChatPreviews_ db User {userId} = do [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.enable_ntfs, ct.created_at, ct.updated_at, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), @@ -2654,11 +2750,11 @@ getGroupChatPreviews_ db User {userId, userContactId} = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, - pu.display_name, pu.full_name, pu.image, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.image, pu.local_alias, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem @@ -2667,18 +2763,18 @@ getGroupChatPreviews_ db User {userId, userContactId} = do f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image, + m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + p.display_name, p.full_name, p.image, p.local_alias, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, - rp.display_name, rp.full_name, rp.image + rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rp.display_name, rp.full_name, rp.image, rp.local_alias FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) LEFT JOIN ( SELECT group_id, MAX(chat_item_id) AS MaxId FROM chat_items @@ -2695,10 +2791,10 @@ getGroupChatPreviews_ db User {userId, userContactId} = do GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id - LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id - LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id) WHERE g.user_id = ? AND mu.contact_id = ? ORDER BY i.item_ts DESC |] @@ -2740,13 +2836,13 @@ getContactConnectionChatPreviews_ db User {userId} _ = <$> DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, custom_user_profile_id, created_at, updated_at FROM connections WHERE user_id = ? AND conn_type = ? AND contact_id IS NULL AND conn_level = 0 AND via_contact IS NULL |] (userId, ConnContact) where - toContactConnectionChatPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, UTCTime, UTCTime) -> AChat + toContactConnectionChatPreview :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe Int64, UTCTime, UTCTime) -> AChat toContactConnectionChatPreview connRow = let conn = toPendingContactConnection connRow stats = ChatStats {unreadCount = 0, minUnreadItemId = 0} @@ -2758,7 +2854,7 @@ getPendingContactConnection db userId connId = do DB.query db [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, created_at, updated_at + SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, custom_user_profile_id, created_at, updated_at FROM connections WHERE user_id = ? AND connection_id = ? @@ -2784,19 +2880,28 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, UTCTime, UTCTime) -> PendingContactConnection -toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUserContactLink, createdAt, updatedAt) = - PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, viaUserContactLink, createdAt, updatedAt} +updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () +updateContactSettings db User {userId} contactId ChatSettings {enableNtfs} = + DB.execute db "UPDATE contacts SET enable_ntfs = ? WHERE user_id = ? AND contact_id = ?" (enableNtfs, userId, contactId) -getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChat db user contactId pagination = do +updateGroupSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () +updateGroupSettings db User {userId} groupId ChatSettings {enableNtfs} = + DB.execute db "UPDATE groups SET enable_ntfs = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, userId, groupId) + +toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe Int64, UTCTime, UTCTime) -> PendingContactConnection +toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUserContactLink, customUserProfileId, createdAt, updatedAt) = + PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = isJust connReqHash, viaUserContactLink, customUserProfileId, createdAt, updatedAt} + +getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChat db user contactId pagination search_ = do + let search = fromMaybe "" search_ case pagination of - CPLast count -> getDirectChatLast_ db user contactId count - CPAfter afterId count -> getDirectChatAfter_ db user contactId afterId count - CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count + CPLast count -> getDirectChatLast_ db user contactId count search + CPAfter afterId count -> getDirectChatAfter_ db user contactId afterId count search + CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search -getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatLast_ db User {userId} contactId count = do +getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatLast_ db User {userId} contactId count search = do contact <- getContact db userId contactId stats <- liftIO $ getDirectChatStats_ db userId contactId chatItems <- ExceptT getDirectChatItemsLast_ @@ -2820,14 +2925,14 @@ getDirectChatLast_ db User {userId} contactId count = do FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 + WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%' ORDER BY i.chat_item_id DESC LIMIT ? |] - (userId, contactId, count) + (userId, contactId, search, count) -getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do +getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatAfter_ db User {userId} contactId afterChatItemId count search = do contact <- getContact db userId contactId stats <- liftIO $ getDirectChatStats_ db userId contactId chatItems <- ExceptT getDirectChatItemsAfter_ @@ -2851,14 +2956,15 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id > ? AND i.item_deleted != 1 + WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%' + AND i.chat_item_id > ? ORDER BY i.chat_item_id ASC LIMIT ? |] - (userId, contactId, afterChatItemId, count) + (userId, contactId, search, afterChatItemId, count) -getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do +getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatBefore_ db User {userId} contactId beforeChatItemId count search = do contact <- getContact db userId contactId stats <- liftIO $ getDirectChatStats_ db userId contactId chatItems <- ExceptT getDirectChatItemsBefore_ @@ -2882,11 +2988,12 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id < ? AND i.item_deleted != 1 + WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 AND i.item_text LIKE '%' || ? || '%' + AND i.chat_item_id < ? ORDER BY i.chat_item_id DESC LIMIT ? |] - (userId, contactId, beforeChatItemId, count) + (userId, contactId, search, beforeChatItemId, count) getDirectChatStats_ :: DB.Connection -> UserId -> Int64 -> IO ChatStats getDirectChatStats_ db userId contactId = @@ -2905,8 +3012,8 @@ getDirectChatStats_ db userId contactId = toChatStats' [statsRow] = toChatStats statsRow toChatStats' _ = ChatStats {unreadCount = 0, minUnreadItemId = 0} -getContactIdByName :: DB.Connection -> UserId -> ContactName -> ExceptT StoreError IO Int64 -getContactIdByName db userId cName = +getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 +getContactIdByName db User {userId} cName = ExceptT . firstRow fromOnly (SEContactNotFoundByName cName) $ DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND local_display_name = ?" (userId, cName) @@ -2918,9 +3025,9 @@ getContact db userId contactId = [sql| SELECT -- Contact - ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at, ct.updated_at, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.local_alias, ct.enable_ntfs, ct.created_at, ct.updated_at, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.conn_status, c.conn_type, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -2940,15 +3047,16 @@ getContact db userId contactId = |] (userId, contactId, ConnReady, ConnSndReady) -getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChat db user groupId pagination = do +getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChat db user groupId pagination search_ = do + let search = fromMaybe "" search_ case pagination of - CPLast count -> getGroupChatLast_ db user groupId count - CPAfter afterId count -> getGroupChatAfter_ db user groupId afterId count - CPBefore beforeId count -> getGroupChatBefore_ db user groupId beforeId count + CPLast count -> getGroupChatLast_ db user groupId count search + CPAfter afterId count -> getGroupChatAfter_ db user groupId afterId count search + CPBefore beforeId count -> getGroupChatBefore_ db user groupId beforeId count search -getGroupChatLast_ :: DB.Connection -> User -> Int64 -> Int -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatLast_ db user@User {userId} groupId count = do +getGroupChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ db user@User {userId} groupId count search = do groupInfo <- getGroupInfo db user groupId stats <- liftIO $ getGroupChatStats_ db userId groupId chatItemIds <- liftIO getGroupChatItemIdsLast_ @@ -2956,104 +3064,66 @@ getGroupChatLast_ db user@User {userId} groupId count = do pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] - getGroupChatItemIdsLast_ = do + getGroupChatItemIdsLast_ = map fromOnly <$> DB.query db [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_deleted != 1 + WHERE user_id = ? AND group_id = ? AND item_deleted != 1 AND item_text LIKE '%' || ? || '%' ORDER BY item_ts DESC, chat_item_id DESC LIMIT ? |] - (userId, groupId, count) + (userId, groupId, search, count) -getGroupChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId count = do +getGroupChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ db user@User {userId} groupId afterChatItemId count search = do groupInfo <- getGroupInfo db user groupId stats <- liftIO $ getGroupChatStats_ db userId groupId - chatItems <- ExceptT getGroupChatItemsAfter_ + afterChatItem <- getGroupChatItem db user groupId afterChatItemId + chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) + chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds pure $ Chat (GroupChat groupInfo) chatItems stats where - getGroupChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTGroup]) - getGroupChatItemsAfter_ = do - tz <- getCurrentTimeZone - currentTs <- getCurrentTime - mapM (toGroupChatItem tz currentTs userContactId) + getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] + getGroupChatItemIdsAfter_ afterChatItemTs = + map fromOnly <$> DB.query db [sql| - SELECT - -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, - -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, - -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image, - -- quoted ChatItem - ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, - -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, - rp.display_name, rp.full_name, rp.image - FROM chat_items i - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN group_members m ON m.group_member_id = i.group_member_id - LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id - LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id - LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id - LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id - WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id > ? AND i.item_deleted != 1 - ORDER BY i.item_ts ASC, i.chat_item_id ASC + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_deleted != 1 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, afterChatItemId, count) + (userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count) -getGroupChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemId count = do +getGroupChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ db user@User {userId} groupId beforeChatItemId count search = do groupInfo <- getGroupInfo db user groupId stats <- liftIO $ getGroupChatStats_ db userId groupId - chatItems <- ExceptT getGroupChatItemsBefore_ + beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId + chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) + chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats where - getGroupChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTGroup]) - getGroupChatItemsBefore_ = do - tz <- getCurrentTimeZone - currentTs <- getCurrentTime - mapM (toGroupChatItem tz currentTs userContactId) + getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] + getGroupChatItemIdsBefore_ beforeChatItemTs = + map fromOnly <$> DB.query db [sql| - SELECT - -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, i.updated_at, - -- CIFile - f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, - -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image, - -- quoted ChatItem - ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, - -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, - rp.display_name, rp.full_name, rp.image - FROM chat_items i - LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN group_members m ON m.group_member_id = i.group_member_id - LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id - LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id - LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id - LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id - WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id < ? AND i.item_deleted != 1 - ORDER BY i.item_ts DESC, i.chat_item_id DESC + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_deleted != 1 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, beforeChatItemId, count) + (userId, groupId, search, beforeChatItemTs, beforeChatItemTs, beforeChatItemId, count) getGroupChatStats_ :: DB.Connection -> UserId -> Int64 -> IO ChatStats getGroupChatStats_ db userId groupId = @@ -3080,15 +3150,15 @@ getGroupInfo db User {userId, userContactId} groupId = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, g.updated_at, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.created_at, g.updated_at, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, - pu.display_name, pu.full_name, pu.image + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.image, pu.local_alias FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? |] (groupId, userId, userContactId) @@ -3103,7 +3173,7 @@ updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, grou currentTs <- getCurrentTime updateGroupProfile_ currentTs updateGroup_ ldn currentTs - pure $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p'} + pure . Right $ (g :: GroupInfo) {localDisplayName = ldn, groupProfile = p'} where updateGroupProfile_ currentTs = DB.execute @@ -3452,21 +3522,21 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image, + m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + p.display_name, p.full_name, p.image, p.local_alias, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, - rp.display_name, rp.full_name, rp.image + rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rp.display_name, rp.full_name, rp.image, rp.local_alias FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id - LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id - LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = COALESCE(rm.member_profile_id, rm.contact_profile_id) WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ? |] (userId, groupId, itemId) @@ -3724,7 +3794,7 @@ getSMPServers db User {userId} = |] (Only userId) where - toSmpServer :: (String, String, C.KeyHash) -> SMPServer + toSmpServer :: (NonEmpty TransportHost, String, C.KeyHash) -> SMPServer toSmpServer (host, port, keyHash) = SMPServer host port keyHash overwriteSMPServers :: DB.Connection -> User -> [SMPServer] -> ExceptT StoreError IO () @@ -3778,7 +3848,7 @@ getCalls db User {userId} = do -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. -withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO a) -> IO (Either StoreError a) +withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO (Either StoreError a)) -> IO (Either StoreError a) withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreateName` 20) where getLdnSuffix :: IO Int @@ -3799,7 +3869,7 @@ withLocalDisplayName db userId displayName action = getLdnSuffix >>= (`tryCreate currentTs <- getCurrentTime let ldn = displayName <> (if ldnSuffix == 0 then "" else T.pack $ '_' : show ldnSuffix) E.try (insertName ldn currentTs) >>= \case - Right () -> Right <$> action ldn + Right () -> action ldn Left e | DB.sqlError e == DB.ErrorConstraint -> tryCreateName (ldnSuffix + 1) (attempts - 1) | otherwise -> E.throwIO e @@ -3873,6 +3943,7 @@ data StoreError | SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId} | SEChatItemNotFoundByFileId {fileId :: FileTransferId} | SEChatItemNotFoundByGroupId {groupId :: GroupId} + | SEProfileNotFound {profileId :: Int64} deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index f1554f576b..66a35fc138 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -26,11 +26,11 @@ terminalChatConfig = InitialAgentServers { smp = L.fromList - [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im", - "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im", - "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im" + [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", + "smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im,jjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion", + "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" ], - ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im"], + ntf = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.simplex.im,ntg7jdjy2i3qbib3sykiho3enekwiaqg3icctliqhtqcg6jmoh6cxiad.onion"], netCfg = defaultNetworkConfig } } diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 80d99490f0..c2416cdd5a 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -9,6 +9,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE UndecidableInstances #-} @@ -22,6 +23,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) +import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime) @@ -39,7 +41,7 @@ import Simplex.Messaging.Util ((<$?>)) class IsContact a where contactId' :: a -> ContactId - profile' :: a -> Profile + profile' :: a -> LocalProfile localDisplayName' :: a -> ContactName instance IsContact User where @@ -56,7 +58,7 @@ data User = User { userId :: UserId, userContactId :: ContactId, localDisplayName :: ContactName, - profile :: Profile, + profile :: LocalProfile, activeUser :: Bool } deriving (Show, Generic, FromJSON) @@ -67,12 +69,15 @@ type UserId = ContactId type ContactId = Int64 +type ProfileId = Int64 + data Contact = Contact { contactId :: ContactId, localDisplayName :: ContactName, - profile :: Profile, + profile :: LocalProfile, activeConn :: Connection, viaGroup :: Maybe Int64, + chatSettings :: ChatSettings, createdAt :: UTCTime, updatedAt :: UTCTime } @@ -88,6 +93,9 @@ contactConn = activeConn contactConnId :: Contact -> ConnId contactConnId Contact {activeConn} = aConnId activeConn +contactConnIncognito :: Contact -> Bool +contactConnIncognito Contact {activeConn = Connection {customUserProfileId}} = isJust customUserProfileId + data ContactRef = ContactRef { contactId :: ContactId, localDisplayName :: ContactName @@ -184,6 +192,8 @@ data GroupInfo = GroupInfo localDisplayName :: GroupName, groupProfile :: GroupProfile, membership :: GroupMember, + hostConnCustomUserProfileId :: Maybe ProfileId, + chatSettings :: ChatSettings, createdAt :: UTCTime, updatedAt :: UTCTime } @@ -194,10 +204,28 @@ instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOption groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g +-- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties) +data ChatSettings = ChatSettings + { enableNtfs :: Bool + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOptions + +defaultChatSettings :: ChatSettings +defaultChatSettings = ChatSettings {enableNtfs = True} + +pattern DisableNtfs :: ChatSettings +pattern DisableNtfs = ChatSettings {enableNtfs = False} + data Profile = Profile { displayName :: ContactName, fullName :: Text, image :: Maybe ImageData + -- fields that should not be read into this data type to prevent sending them as part of profile to contacts: + -- - contact_profile_id + -- - incognito + -- - local_alias } deriving (Eq, Show, Generic, FromJSON) @@ -205,6 +233,32 @@ instance ToJSON Profile where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +type LocalAlias = Text + +data LocalProfile = LocalProfile + { profileId :: ProfileId, + displayName :: ContactName, + fullName :: Text, + image :: Maybe ImageData, + localAlias :: LocalAlias + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON LocalProfile where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +localProfileId :: LocalProfile -> ProfileId +localProfileId = profileId + +toLocalProfile :: ProfileId -> Profile -> LocalAlias -> LocalProfile +toLocalProfile profileId Profile {displayName, fullName, image} localAlias = + LocalProfile {profileId, displayName, fullName, image, localAlias} + +fromLocalProfile :: LocalProfile -> Profile +fromLocalProfile LocalProfile {displayName, fullName, image} = + Profile {displayName, fullName, image} + data GroupProfile = GroupProfile { displayName :: GroupName, fullName :: Text, @@ -238,7 +292,9 @@ data GroupInvitation = GroupInvitation } deriving (Eq, Show, Generic, FromJSON) -instance ToJSON GroupInvitation where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON GroupInvitation where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} data MemberIdRole = MemberIdRole { memberId :: MemberId, @@ -267,7 +323,7 @@ instance ToJSON MemberInfo where toEncoding = J.genericToEncoding J.defaultOptio memberInfo :: GroupMember -> MemberInfo memberInfo GroupMember {memberId, memberRole, memberProfile} = - MemberInfo memberId memberRole memberProfile + MemberInfo memberId memberRole (fromLocalProfile memberProfile) data ReceivedGroupInvitation = ReceivedGroupInvitation { fromMember :: GroupMember, @@ -278,6 +334,8 @@ data ReceivedGroupInvitation = ReceivedGroupInvitation type GroupMemberId = Int64 +-- memberProfile's profileId is COALESCE(member_profile_id, contact_profile_id), member_profile_id is non null +-- if incognito profile was saved for member (used for hosts and invitees in incognito groups) data GroupMember = GroupMember { groupMemberId :: GroupMemberId, groupId :: GroupId, @@ -287,8 +345,9 @@ data GroupMember = GroupMember memberStatus :: GroupMemberStatus, invitedBy :: InvitedBy, localDisplayName :: ContactName, - memberProfile :: Profile, - memberContactId :: Maybe Int64, + memberProfile :: LocalProfile, + memberContactId :: Maybe ContactId, + memberContactProfileId :: ProfileId, activeConn :: Maybe Connection } deriving (Eq, Show, Generic) @@ -306,6 +365,9 @@ memberConnId GroupMember {activeConn} = aConnId <$> activeConn groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId +memberIncognito :: GroupMember -> Bool +memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId + data NewGroupMember = NewGroupMember { memInfo :: MemberInfo, memCategory :: GroupMemberCategory, @@ -695,6 +757,7 @@ data Connection = Connection connLevel :: Int, viaContact :: Maybe Int64, -- group member contact ID, if not direct connection viaUserContactLink :: Maybe Int64, -- user contact link ID, if connected via "user address" + customUserProfileId :: Maybe Int64, connType :: ConnType, connStatus :: ConnStatus, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID @@ -715,6 +778,7 @@ data PendingContactConnection = PendingContactConnection pccConnStatus :: ConnStatus, viaContactUri :: Bool, viaUserContactLink :: Maybe Int64, + customUserProfileId :: Maybe Int64, createdAt :: UTCTime, updatedAt :: UTCTime } diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e05b1dd727..699ac7e696 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -14,6 +14,7 @@ import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Char (toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) @@ -42,8 +43,9 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (ProtocolServer (..)) +import Simplex.Messaging.Protocol (AProtocolType, ProtocolServer (..)) import qualified Simplex.Messaging.Protocol as SMP +import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (bshow) import System.Console.ANSI.Types @@ -52,7 +54,7 @@ serializeChatResponse = unlines . map unStyle . responseToView False responseToView :: Bool -> ChatResponse -> [StyledString] responseToView testView = \case - CRActiveUser User {profile} -> viewUserProfile profile + CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] @@ -62,13 +64,13 @@ responseToView testView = \case CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRNetworkConfig cfg -> viewNetworkConfig cfg - CRContactInfo ct cStats -> viewContactInfo ct cStats + CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats - CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item False + CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False CRLastMessages chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True) chatItems CRChatItemStatusUpdated _ -> [] - CRChatItemUpdated (AChatItem _ _ chat item) -> viewItemUpdate chat item - CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> viewItemDelete chat deletedItem toItem + CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item + CRChatItemDeleted (AChatItem _ _ chat deletedItem) (AChatItem _ _ _ toItem) -> unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent mc n ts -> viewSentBroadcast mc n ts CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr @@ -90,7 +92,7 @@ responseToView testView = \case CRGroupCreated g -> viewGroupCreated g CRGroupMembers g -> viewGroupMembers g CRGroupsList gs -> viewGroupsList gs - CRSentGroupInvitation g c -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + CRSentGroupInvitation g c _ -> viewSentGroupInvitation g c CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus CRUserProfile p -> viewUserProfile p CRUserProfileNoChange -> ["user profile did not change"] @@ -98,7 +100,7 @@ responseToView testView = \case CRChatCmdError e -> viewChatError e CRInvitation cReq -> viewConnReqInvitation cReq CRSentConfirmation -> ["confirmation sent!"] - CRSentInvitation -> ["connection request sent!"] + CRSentInvitation customUserProfile -> viewSentInvitation customUserProfile testView CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"] CRChatCleared chatInfo -> viewChatCleared chatInfo CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."] @@ -115,6 +117,7 @@ responseToView testView = \case CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' + CRContactAliasUpdated c -> viewContactAliasUpdated c CRContactUpdated c c' -> viewContactUpdated c c' CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile @@ -127,20 +130,21 @@ responseToView testView = \case CRSndFileRcvCancelled _ ft@SndFileTransfer {recipientDisplayName = c} -> [ttyContact c <> " cancelled receiving " <> sndFile ft] CRContactConnecting _ -> [] - CRContactConnected ct -> [ttyFullContact ct <> ": contact is connected"] + CRContactConnected ct userCustomProfile -> viewContactConnected ct userCustomProfile testView CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"] - CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> smpServer srv <> " (" <> contactList cs <> ")"] - CRContactsSubscribed srv cs -> [plain $ "server connected " <> smpServer srv <> " (" <> contactList cs <> ")"] + CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] + CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] CRContactSubSummary summary -> [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" where (errors, subscribed) = partition (isJust . contactError) summary - CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} -> - [groupInvitation' ldn fullName] + CRGroupInvitation g -> [groupInvitation' g] CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role - CRUserJoinedGroup g _ -> [ttyGroup' g <> ": you joined the group"] - CRJoinedGroupMember g m -> [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "] + CRUserJoinedGroup g _ -> viewUserJoinedGroup g + CRJoinedGroupMember g m -> viewJoinedGroupMember g m + CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] + CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] CRJoinedGroupMemberConnecting g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] CRConnectedToGroupMember g m -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] CRDeletedMemberUser g by -> [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g @@ -152,7 +156,7 @@ responseToView testView = \case CRGroupUpdated g g' m -> viewGroupUpdated g g' m CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed g -> [ttyFullGroup g <> ": connected to server(s)"] + CRGroupSubscribed g -> viewGroupSubscribed g CRPendingSubSummary _ -> [] CRSndFileSubError SndFileTransfer {fileId, fileName} e -> ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] @@ -201,10 +205,25 @@ responseToView testView = \case _ -> Nothing viewErrorsSummary :: [a] -> StyledString -> [StyledString] viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] - smpServer :: SMPServer -> String - smpServer SMP.ProtocolServer {host, port} = B.unpack . strEncode $ SrvLoc host port contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs + unmuted :: ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] + unmuted chat ChatItem {chatDir} s = case (chat, chatDir) of + (DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> [] + (GroupChat GroupInfo {chatSettings = DisableNtfs}, CIGroupRcv _) -> [] + _ -> s + +viewGroupSubscribed :: GroupInfo -> [StyledString] +viewGroupSubscribed g@GroupInfo {membership} = + [incognito <> ttyFullGroup g <> ": connected to server(s)"] + where + incognito = if memberIncognito membership then incognitoPrefix else "" + +showSMPServer :: SMPServer -> String +showSMPServer = B.unpack . strEncode . host + +viewHostEvent :: AProtocolType -> TransportHost -> String +viewHostEvent p h = map toUpper (B.unpack $ strEncode p) <> " host " <> B.unpack (strEncode h) viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> [StyledString] viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} doShow = case chat of @@ -355,6 +374,10 @@ viewConnReqInvitation cReq = "and ask them to connect: " <> highlight' "/c " ] +viewSentGroupInvitation :: GroupInfo -> Contact -> [StyledString] +viewSentGroupInvitation g c = + ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + viewChatCleared :: AChatInfo -> [StyledString] viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"] @@ -364,7 +387,12 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of viewContactsList :: [Contact] -> [StyledString] viewContactsList = let ldn = T.toLower . (localDisplayName :: Contact -> ContactName) - in map ttyFullContact . sortOn ldn + incognito ct = if contactConnIncognito ct then incognitoPrefix else "" + in map (\ct -> incognito ct <> ttyFullContact ct <> muted ct) . sortOn ldn + where + muted Contact {chatSettings, localDisplayName = ldn} + | enableNtfs chatSettings = "" + | otherwise = " (muted, you can " <> highlight ("/unmute @" <> ldn) <> ")" viewUserContactLinkDeleted :: [StyledString] viewUserContactLinkDeleted = @@ -388,6 +416,17 @@ autoAcceptStatus_ autoAccept autoReply = ("auto_accept " <> if autoAccept then "on" else "off") : maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply +viewSentInvitation :: Maybe Profile -> Bool -> [StyledString] +viewSentInvitation incognitoProfile testView = + case incognitoProfile of + Just profile -> + if testView + then incognitoProfile' profile : message + else message + where + message = ["connection request sent incognito!"] + Nothing -> ["connection request sent!"] + viewReceivedContactRequest :: ContactName -> Profile -> [StyledString] viewReceivedContactRequest c Profile {fullName} = [ ttyFullName c fullName <> " wants to connect to you!", @@ -407,11 +446,22 @@ viewCannotResendInvitation GroupInfo {localDisplayName = gn} c = "to re-send invitation: " <> highlight ("/rm " <> gn <> " " <> c) <> ", " <> highlight ("/a " <> gn <> " " <> c) ] +viewUserJoinedGroup :: GroupInfo -> [StyledString] +viewUserJoinedGroup g@GroupInfo {membership = membership@GroupMember {memberProfile}} = + if memberIncognito membership + then [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile memberProfile)] + else [ttyGroup' g <> ": you joined the group"] + +viewJoinedGroupMember :: GroupInfo -> GroupMember -> [StyledString] +viewJoinedGroupMember g m = + [ttyGroup' g <> ": " <> ttyMember m <> " joined the group "] + viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [StyledString] -viewReceivedGroupInvitation g c role = - [ ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role), - "use " <> highlight ("/j " <> groupName' g) <> " to accept" - ] +viewReceivedGroupInvitation g@GroupInfo {membership = membership@GroupMember {memberProfile}} c role = + ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role) : + if memberIncognito membership + then ["use " <> highlight ("/j " <> groupName' g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile memberProfile)] + else ["use " <> highlight ("/j " <> groupName' g) <> " to accept"] groupPreserved :: GroupInfo -> [StyledString] groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"] @@ -426,7 +476,8 @@ viewGroupMembers :: Group -> [StyledString] viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members where removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft - groupMember m = ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m + groupMember m = incognito m <> ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m + incognito m = if memberIncognito m then incognitoPrefix else "" role m = plain . strEncode $ memberRole (m :: GroupMember) category m = case memberCategory m of GCUserMember -> "you, " @@ -442,32 +493,55 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt GSMemCreator -> "created group" _ -> "" +viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] +viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView = + case userIncognitoProfile of + Just profile -> + if testView + then incognitoProfile' profile : message + else message + where + message = + [ ttyFullContact ct <> ": contact is connected, your incognito profile for this contact is " <> incognitoProfile' profile, + "use " <> highlight ("/info " <> localDisplayName) <> " to print out this incognito profile again" + ] + Nothing -> + [ttyFullContact ct <> ": contact is connected"] + viewGroupsList :: [GroupInfo] -> [StyledString] viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] viewGroupsList gs = map groupSS $ sortOn ldn_ gs where ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) - groupSS GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership} = + groupSS g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership, chatSettings} = case memberStatus membership of - GSMemInvited -> groupInvitation' ldn fullName - s -> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s + GSMemInvited -> groupInvitation' g + s -> incognito <> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s where + incognito = if memberIncognito membership then incognitoPrefix else "" viewMemberStatus = \case GSMemRemoved -> delete "you are removed" GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" - _ -> "" + _ + | enableNtfs chatSettings -> "" + | otherwise -> " (muted, you can " <> highlight ("/unmute #" <> ldn) <> ")" delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")" -groupInvitation' :: GroupName -> Text -> StyledString -groupInvitation' displayName fullName = - highlight ("#" <> displayName) - <> optFullName displayName fullName +groupInvitation' :: GroupInfo -> StyledString +groupInvitation' GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership = membership@GroupMember {memberProfile}} = + highlight ("#" <> ldn) + <> optFullName ldn fullName <> " - you are invited (" - <> highlight ("/j " <> displayName) - <> " to join, " - <> highlight ("/d #" <> displayName) + <> highlight ("/j " <> ldn) + <> joinText + <> highlight ("/d #" <> ldn) <> " to delete invitation)" + where + joinText = + if memberIncognito membership + then " to join as " <> incognitoProfile' (fromLocalProfile memberProfile) <> ", " + else " to join, " viewContactsMerged :: Contact -> Contact -> [StyledString] viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} = @@ -506,16 +580,22 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} = "use `/network socks=[ timeout=]` to change settings" ] -viewContactInfo :: Contact -> ConnectionStats -> [StyledString] -viewContactInfo Contact {contactId} stats = +viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] +viewContactInfo Contact {contactId, profile = LocalProfile {localAlias}} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> viewConnectionStats stats + <> maybe + ["you've shared main profile with this contact"] + (\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p]) + incognitoProfile + <> if localAlias /= "" then ["alias: " <> plain localAlias] else ["alias not set"] viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] -viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId} stats = +viewGroupMemberInfo GroupInfo {groupId} GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}} stats = [ "group ID: " <> sShow groupId, "member ID: " <> sShow groupMemberId ] <> maybe ["member not connected"] viewConnectionStats stats + <> if localAlias /= "" then ["alias: " <> plain localAlias] else ["no alias for contact"] viewConnectionStats :: ConnectionStats -> [StyledString] viewConnectionStats ConnectionStats {rcvServers, sndServers} = @@ -526,7 +606,7 @@ viewServers :: [SMPServer] -> StyledString viewServers = plain . intercalate ", " . map (B.unpack . strEncode) viewServerHosts :: [SMPServer] -> StyledString -viewServerHosts = plain . intercalate ", " . map host +viewServerHosts = plain . intercalate ", " . map showSMPServer viewUserProfileUpdated :: Profile -> Profile -> [StyledString] viewUserProfileUpdated Profile {displayName = n, fullName, image} Profile {displayName = n', fullName = fullName', image = image'} @@ -549,10 +629,15 @@ viewGroupUpdated where byMember = maybe "" ((" by " <>) . ttyMember) m +viewContactAliasUpdated :: Contact -> [StyledString] +viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}} + | localAlias == "" = ["contact " <> ttyContact n <> " alias removed"] + | otherwise = ["contact " <> ttyContact n <> " alias updated: " <> plain localAlias] + viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated - Contact {localDisplayName = n, profile = Profile {fullName}} - Contact {localDisplayName = n', profile = Profile {fullName = fullName'}} + Contact {localDisplayName = n, profile = LocalProfile {fullName}} + Contact {localDisplayName = n', profile = LocalProfile {fullName = fullName'}} | n == n' && fullName == fullName' = [] | n == n' = ["contact " <> ttyContact n <> fullNameUpdate] | otherwise = @@ -809,6 +894,8 @@ viewChatError = \case CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"] CEGroupUserRole -> ["you have insufficient permissions for this group command"] + CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"] + CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> groupName' g)] CEGroupMemberNotActive -> ["you cannot invite other members yet, try later"] @@ -862,6 +949,8 @@ viewChatError = \case \ secured with different credentials, or due to a bug - please re-create the connection" ] AGENT A_DUPLICATE -> [] + AGENT A_PROHIBITED -> [] + CONN NOT_FOUND -> [] e -> ["smp agent error: " <> sShow e] where fileNotFound fileId = ["file " <> sShow fileId <> " not found"] @@ -873,14 +962,14 @@ ttyContact' :: Contact -> StyledString ttyContact' Contact {localDisplayName = c} = ttyContact c ttyFullContact :: Contact -> StyledString -ttyFullContact Contact {localDisplayName, profile = Profile {fullName}} = +ttyFullContact Contact {localDisplayName, profile = LocalProfile {fullName}} = ttyFullName localDisplayName fullName ttyMember :: GroupMember -> StyledString ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName ttyFullMember :: GroupMember -> StyledString -ttyFullMember GroupMember {localDisplayName, memberProfile = Profile {fullName}} = +ttyFullMember GroupMember {localDisplayName, memberProfile = LocalProfile {fullName}} = ttyFullName localDisplayName fullName ttyFullName :: ContactName -> Text -> StyledString @@ -899,7 +988,8 @@ ttyFromContactDeleted :: ContactName -> StyledString ttyFromContactDeleted c = ttyFrom $ c <> "> [deleted] " ttyToContact' :: Contact -> StyledString -ttyToContact' Contact {localDisplayName = c} = ttyToContact c +ttyToContact' Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} = + maybe "" (const incognitoPrefix) customUserProfileId <> ttyToContact c ttyQuotedContact :: Contact -> StyledString ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ c <> ">" @@ -909,7 +999,8 @@ ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom c ttyQuotedMember _ = "> " <> ttyFrom "?" ttyFromContact' :: Contact -> StyledString -ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c +ttyFromContact' Contact {localDisplayName = c, activeConn = Connection {customUserProfileId}} = + maybe "" (const incognitoPrefix) customUserProfileId <> ttyFromContact c ttyGroup :: GroupName -> StyledString ttyGroup g = styled (colored Blue) $ "#" <> g @@ -939,10 +1030,12 @@ ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow ttyFromGroup' :: GroupInfo -> GroupMember -> StyledString -ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m +ttyFromGroup' g@GroupInfo {membership} GroupMember {localDisplayName = m} = + (if memberIncognito membership then incognitoPrefix else "") <> ttyFromGroup g m ttyToGroup :: GroupInfo -> StyledString -ttyToGroup GroupInfo {localDisplayName = g} = styled (colored Cyan) $ "#" <> g <> " " +ttyToGroup GroupInfo {localDisplayName = g, membership} = + (if memberIncognito membership then incognitoPrefix else "") <> styled (colored Cyan) ("#" <> g <> " ") ttyFilePath :: FilePath -> StyledString ttyFilePath = plain @@ -950,12 +1043,24 @@ ttyFilePath = plain optFullName :: ContactName -> Text -> StyledString optFullName localDisplayName fullName = plain $ optionalFullName localDisplayName fullName +incognitoPrefix :: StyledString +incognitoPrefix = styleIncognito' "i " + +incognitoProfile' :: Profile -> StyledString +incognitoProfile' Profile {displayName} = styleIncognito displayName + highlight :: StyledFormat a => a -> StyledString highlight = styled $ colored Cyan highlight' :: String -> StyledString highlight' = highlight +styleIncognito :: StyledFormat a => a -> StyledString +styleIncognito = styled $ colored Magenta + +styleIncognito' :: String -> StyledString +styleIncognito' = styleIncognito + styleTime :: String -> StyledString styleTime = Styled [SetColor Foreground Vivid Black] diff --git a/stack.yaml b/stack.yaml index 56b03e35fc..a77d573f0f 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 7d99c4b35cf2dc531219bc83146b714c9bae429c + commit: f2c1455a2755e1275983dc154321fc0a5c0d7b17 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 6410130836..6edb5e99f8 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -51,6 +51,7 @@ testOpts = smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], networkConfig = defaultNetworkConfig, logConnections = False, + logServerHosts = False, logAgent = False, chatCmd = "", chatCmdDelay = 3, @@ -91,7 +92,7 @@ testCfg = testAgentCfgV1 :: AgentConfig testAgentCfgV1 = testAgentCfg - { smpAgentVersion = 1, + { smpClientVRange = mkVersionRange 1 1, smpAgentVRange = mkVersionRange 1 1, smpCfg = (smpCfg testAgentCfg) {smpServerVRange = mkVersionRange 1 1} } diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index d1497f8020..d6625f8ff5 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -19,7 +19,7 @@ import qualified Data.Text as T import Simplex.Chat.Call import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Options (ChatOpts (..)) -import Simplex.Chat.Types (ConnStatus (..), ImageData (..), Profile (..), User (..)) +import Simplex.Chat.Types (ConnStatus (..), ImageData (..), LocalProfile (..), Profile (..), User (..)) import Simplex.Messaging.Util (unlessM) import System.Directory (copyFile, doesDirectoryExist, doesFileExist) import System.FilePath (()) @@ -88,6 +88,13 @@ chatTests = do it "reject contact and delete contact link" testRejectContactAndDeleteUserContact it "delete connection requests when contact link deleted" testDeleteConnectionRequests it "auto-reply message" testAutoReplyMessage + describe "incognito mode" $ do + it "connect incognito via invitation link" testConnectIncognitoInvitationLink + it "connect incognito via contact address" testConnectIncognitoContactAddress + it "accept contact request incognito" testAcceptContactRequestIncognito + it "join group incognito" testJoinGroupIncognito + it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito + it "set contact alias" testSetAlias describe "SMP servers" $ it "get and set SMP servers" testGetSetSMPServers describe "async connection handshake" $ do @@ -108,6 +115,9 @@ chatTests = do describe "maintenance mode" $ do it "start/stop/export/import chat" testMaintenanceMode it "export/import chat with files" testMaintenanceModeWithFiles + describe "mute/unmute messages" $ do + it "mute/unmute contact" testMuteContact + it "mute/unmute group" testMuteGroup versionTestMatrix2 :: (TestCC -> TestCC -> IO ()) -> Spec versionTestMatrix2 runTest = do @@ -159,11 +169,13 @@ testAddContact = versionTestMatrix2 runTestAddContact (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") chatsEmpty alice bob - alice #> "@bob hello 🙂" - bob <# "alice> hello 🙂" + alice #> "@bob hello there 🙂" + bob <# "alice> hello there 🙂" chatsOneMessage alice bob - bob #> "@alice hi" - alice <# "bob> hi" + bob #> "@alice hello there" + alice <# "bob> hello there" + bob #> "@alice how are you?" + alice <# "bob> how are you?" chatsManyMessages alice bob -- test adding the same contact one more time - local name will be different alice ##> "/c" @@ -177,15 +189,15 @@ testAddContact = versionTestMatrix2 runTestAddContact bob <# "alice_1> hello" bob #> "@alice_1 hi" alice <# "bob_1> hi" - alice @@@ [("@bob_1", "hi"), ("@bob", "hi")] - bob @@@ [("@alice_1", "hi"), ("@alice", "hi")] + alice @@@ [("@bob_1", "hi"), ("@bob", "how are you?")] + bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] -- test deleting contact alice ##> "/d bob_1" alice <## "bob_1: contact is deleted" alice ##> "@bob_1 hey" alice <## "no contact bob_1" - alice @@@ [("@bob", "hi")] - bob @@@ [("@alice_1", "hi"), ("@alice", "hi")] + alice @@@ [("@bob", "how are you?")] + bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] -- test clearing chat alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") alice #$> ("/_get chat @2 count=100", chat, []) @@ -197,18 +209,20 @@ testAddContact = versionTestMatrix2 runTestAddContact bob @@@ [("@alice", "")] bob #$> ("/_get chat @2 count=100", chat, []) chatsOneMessage alice bob = do - alice @@@ [("@bob", "hello 🙂")] - alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂")]) - bob @@@ [("@alice", "hello 🙂")] - bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂")]) + alice @@@ [("@bob", "hello there 🙂")] + alice #$> ("/_get chat @2 count=100", chat, [(1, "hello there 🙂")]) + bob @@@ [("@alice", "hello there 🙂")] + bob #$> ("/_get chat @2 count=100", chat, [(0, "hello there 🙂")]) chatsManyMessages alice bob = do - alice @@@ [("@bob", "hi")] - alice #$> ("/_get chat @2 count=100", chat, [(1, "hello 🙂"), (0, "hi")]) - bob @@@ [("@alice", "hi")] - bob #$> ("/_get chat @2 count=100", chat, [(0, "hello 🙂"), (1, "hi")]) + alice @@@ [("@bob", "how are you?")] + alice #$> ("/_get chat @2 count=100", chat, [(1, "hello there 🙂"), (0, "hello there"), (0, "how are you?")]) + bob @@@ [("@alice", "how are you?")] + bob #$> ("/_get chat @2 count=100", chat, [(0, "hello there 🙂"), (1, "hello there"), (1, "how are you?")]) -- pagination - alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hi")]) - alice #$> ("/_get chat @2 before=2 count=100", chat, [(1, "hello 🙂")]) + alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hello there"), (0, "how are you?")]) + alice #$> ("/_get chat @2 before=2 count=100", chat, [(1, "hello there 🙂")]) + -- search + alice #$> ("/_get chat @2 count=100 search=ello ther", chat, [(1, "hello there 🙂"), (0, "hello there")]) -- read messages alice #$> ("/_read chat @2 from=1 to=100", id, "ok") bob #$> ("/_read chat @2 from=1 to=100", id, "ok") @@ -475,6 +489,7 @@ testGroupShared alice bob cath checkMessages = do -- so we take into account group event items as well as sent group invitations in direct chats alice #$> ("/_get chat #1 after=5 count=100", chat, [(0, "hi there"), (0, "hey team")]) alice #$> ("/_get chat #1 before=7 count=100", chat, [(0, "connected"), (0, "connected"), (1, "hello"), (0, "hi there")]) + alice #$> ("/_get chat #1 count=100 search=team", chat, [(0, "hey team")]) bob @@@ [("@cath", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] bob #$> ("/_get chat #1 count=100", chat, [(0, "connected"), (0, "added cath (Catherine)"), (0, "connected"), (0, "hello"), (1, "hi there"), (0, "hey team")]) cath @@@ [("@bob", "hey"), ("#team", "hey team"), ("@alice", "received invitation to join group team as admin")] @@ -2040,6 +2055,324 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $ alice <# "@bob hello!" ] +testConnectIncognitoInvitationLink :: IO () +testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice #$> ("/incognito on", id, "ok") + bob #$> ("/incognito on", id, "ok") + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + bobIncognito <- getTermLine bob + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ do + bob <## (aliceIncognito <> ": contact is connected, your incognito profile for this contact is " <> bobIncognito) + bob <## ("use /info " <> aliceIncognito <> " to print out this incognito profile again"), + do + alice <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## ("use /info " <> bobIncognito <> " to print out this incognito profile again") + ] + -- after turning incognito mode off conversation is incognito + alice #$> ("/incognito off", id, "ok") + bob #$> ("/incognito off", id, "ok") + alice ?#> ("@" <> bobIncognito <> " psst, I'm incognito") + bob ?<# (aliceIncognito <> "> psst, I'm incognito") + bob ?#> ("@" <> aliceIncognito <> " me too") + alice ?<# (bobIncognito <> "> me too") + -- new contact is connected non incognito + connectUsers alice cath + alice <##> cath + -- bob is not notified on profile change + alice ##> "/p alice" + concurrentlyN_ + [ alice <## "user full name removed (your contacts are notified)", + cath <## "contact alice removed full name" + ] + alice ?#> ("@" <> bobIncognito <> " do you see that I've changed profile?") + bob ?<# (aliceIncognito <> "> do you see that I've changed profile?") + bob ?#> ("@" <> aliceIncognito <> " no") + alice ?<# (bobIncognito <> "> no") + +testConnectIncognitoContactAddress :: IO () +testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + bob #$> ("/incognito on", id, "ok") + bob ##> ("/c " <> cLink) + bobIncognito <- getTermLine bob + bob <## "connection request sent incognito!" + alice <## (bobIncognito <> " wants to connect to you!") + alice <## ("to accept: /ac " <> bobIncognito) + alice <## ("to reject: /rc " <> bobIncognito <> " (the sender will NOT be notified)") + alice ##> ("/ac " <> bobIncognito) + alice <## (bobIncognito <> ": accepting contact request...") + _ <- getTermLine bob + concurrentlyN_ + [ do + bob <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> bobIncognito) + bob <## "use /info alice to print out this incognito profile again", + alice <## (bobIncognito <> ": contact is connected") + ] + -- after turning incognito mode off conversation is incognito + alice #$> ("/incognito off", id, "ok") + bob #$> ("/incognito off", id, "ok") + alice #> ("@" <> bobIncognito <> " who are you?") + bob ?<# "alice> who are you?" + bob ?#> "@alice I'm Batman" + alice <# (bobIncognito <> "> I'm Batman") + +testAcceptContactRequestIncognito :: IO () +testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + bob ##> ("/c " <> cLink) + alice <#? bob + alice #$> ("/incognito on", id, "ok") + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ bob <## (aliceIncognito <> ": contact is connected"), + do + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /info bob to print out this incognito profile again" + ] + -- after turning incognito mode off conversation is incognito + alice #$> ("/incognito off", id, "ok") + bob #$> ("/incognito off", id, "ok") + alice ?#> "@bob my profile is totally inconspicuous" + bob <# (aliceIncognito <> "> my profile is totally inconspicuous") + bob #> ("@" <> aliceIncognito <> " I know!") + alice ?<# "bob> I know!" + +testJoinGroupIncognito :: IO () +testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + -- non incognito connections + connectUsers alice bob + connectUsers alice dan + connectUsers bob cath + connectUsers bob dan + connectUsers cath dan + -- cath connected incognito to alice + alice ##> "/c" + inv <- getInvitation alice + cath #$> ("/incognito on", id, "ok") + cath ##> ("/c " <> inv) + cath <## "confirmation sent!" + cathIncognito <- getTermLine cath + concurrentlyN_ + [ do + cath <## ("alice (Alice): contact is connected, your incognito profile for this contact is " <> cathIncognito) + cath <## "use /info alice to print out this incognito profile again", + alice <## (cathIncognito <> ": contact is connected") + ] + -- alice creates group + alice ##> "/g secret_club" + alice <## "group #secret_club is created" + alice <## "use /a secret_club to add members" + -- alice invites bob + alice ##> "/a secret_club bob" + concurrentlyN_ + [ alice <## "invitation to join the group #secret_club sent to bob", + do + bob <## "#secret_club: alice invites you to join the group as admin" + bob <## "use /j secret_club to accept" + ] + bob ##> "/j secret_club" + concurrently_ + (alice <## "#secret_club: bob joined the group") + (bob <## "#secret_club: you joined the group") + -- alice invites cath + alice ##> ("/a secret_club " <> cathIncognito) + concurrentlyN_ + [ alice <## ("invitation to join the group #secret_club sent to " <> cathIncognito), + do + cath <## "#secret_club: alice invites you to join the group as admin" + cath <## ("use /j secret_club to join incognito as " <> cathIncognito) + ] + -- cath uses the same incognito profile when joining group, disabling incognito mode doesn't affect it + cath #$> ("/incognito off", id, "ok") + cath ##> "/j secret_club" + -- cath and bob don't merge contacts + concurrentlyN_ + [ alice <## ("#secret_club: " <> cathIncognito <> " joined the group"), + do + cath <## ("#secret_club: you joined the group incognito as " <> cathIncognito) + cath <## "#secret_club: member bob_1 (Bob) is connected", + do + bob <## ("#secret_club: alice added " <> cathIncognito <> " to the group (connecting...)") + bob <## ("#secret_club: new member " <> cathIncognito <> " is connected") + ] + -- cath cannot invite to the group because her membership is incognito + cath ##> "/a secret_club dan" + cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts" + -- alice invites dan + alice ##> "/a secret_club dan" + concurrentlyN_ + [ alice <## "invitation to join the group #secret_club sent to dan", + do + dan <## "#secret_club: alice invites you to join the group as admin" + dan <## "use /j secret_club to accept" + ] + dan ##> "/j secret_club" + -- cath and dan don't merge contacts + concurrentlyN_ + [ alice <## "#secret_club: dan joined the group", + do + dan <## "#secret_club: you joined the group" + dan + <### [ "#secret_club: member " <> cathIncognito <> " is connected", + "#secret_club: member bob_1 (Bob) is connected", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + do + bob <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" + bob <## "#secret_club: new member dan_1 is connected" + bob <## "contact dan_1 is merged into dan" + bob <## "use @dan to send messages", + do + cath <## "#secret_club: alice added dan_1 (Daniel) to the group (connecting...)" + cath <## "#secret_club: new member dan_1 is connected" + ] + -- send messages - group is incognito for cath + alice #> "#secret_club hello" + concurrentlyN_ + [ bob <# "#secret_club alice> hello", + cath ?<# "#secret_club alice> hello", + dan <# "#secret_club alice> hello" + ] + bob #> "#secret_club hi there" + concurrentlyN_ + [ alice <# "#secret_club bob> hi there", + cath ?<# "#secret_club bob_1> hi there", + dan <# "#secret_club bob> hi there" + ] + cath ?#> "#secret_club hey" + concurrentlyN_ + [ alice <# ("#secret_club " <> cathIncognito <> "> hey"), + bob <# ("#secret_club " <> cathIncognito <> "> hey"), + dan <# ("#secret_club " <> cathIncognito <> "> hey") + ] + dan #> "#secret_club how is it going?" + concurrentlyN_ + [ alice <# "#secret_club dan> how is it going?", + bob <# "#secret_club dan> how is it going?", + cath ?<# "#secret_club dan_1> how is it going?" + ] + -- cath and bob can send messages via new direct connection, cath is incognito + bob #> ("@" <> cathIncognito <> " hi, I'm bob") + cath ?<# "bob_1> hi, I'm bob" + cath ?#> "@bob_1 hey, I'm incognito" + bob <# (cathIncognito <> "> hey, I'm incognito") + -- cath and dan can send messages via new direct connection, cath is incognito + dan #> ("@" <> cathIncognito <> " hi, I'm dan") + cath ?<# "dan_1> hi, I'm dan" + cath ?#> "@dan_1 hey, I'm incognito" + dan <# (cathIncognito <> "> hey, I'm incognito") + -- non incognito connections are separate + bob <##> cath + dan <##> cath + -- list groups + cath ##> "/gs" + cath <## "i #secret_club" + -- list group members + alice ##> "/ms secret_club" + alice + <### [ "alice (Alice): owner, you, created group", + "bob (Bob): admin, invited, connected", + cathIncognito <> ": admin, invited, connected", + "dan (Daniel): admin, invited, connected" + ] + bob ##> "/ms secret_club" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, you, connected", + cathIncognito <> ": admin, connected", + "dan (Daniel): admin, connected" + ] + cath ##> "/ms secret_club" + cath + <### [ "alice (Alice): owner, host, connected", + "bob_1 (Bob): admin, connected", + "i " <> cathIncognito <> ": admin, you, connected", + "dan_1 (Daniel): admin, connected" + ] + dan ##> "/ms secret_club" + dan + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): admin, connected", + cathIncognito <> ": admin, connected", + "dan (Daniel): admin, you, connected" + ] + -- remove member + bob ##> ("/rm secret_club " <> cathIncognito) + concurrentlyN_ + [ bob <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), + alice <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), + dan <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), + do + cath <## "#secret_club: bob_1 removed you from the group" + cath <## "use /d #secret_club to delete the group" + ] + bob #> "#secret_club hi" + concurrentlyN_ + [ alice <# "#secret_club bob> hi", + dan <# "#secret_club bob> hi", + (cath "#secret_club hello" + concurrentlyN_ + [ bob <# "#secret_club alice> hello", + dan <# "#secret_club alice> hello", + (cath "#secret_club hello" + cath <## "you are no longer a member of the group" + -- cath can still message members directly + bob #> ("@" <> cathIncognito <> " I removed you from group") + cath ?<# "bob_1> I removed you from group" + cath ?#> "@bob_1 ok" + bob <# (cathIncognito <> "> ok") + +testCantInviteContactIncognito :: IO () +testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ + \alice bob -> do + -- alice connected incognito to bob + alice #$> ("/incognito on", id, "ok") + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ bob <## (aliceIncognito <> ": contact is connected"), + do + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /info bob to print out this incognito profile again" + ] + -- alice creates group non incognito + alice #$> ("/incognito off", id, "ok") + alice ##> "/g club" + alice <## "group #club is created" + alice <## "use /a club to add members" + alice ##> "/a club bob" + alice <## "you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito" + -- bob doesn't receive invitation + (bob do + connectUsers alice bob + alice #$> ("/_set alias @2 my friend bob", id, "contact bob alias updated: my friend bob") + alice #$> ("/_set alias @2", id, "contact bob alias removed") + testGetSetSMPServers :: IO () testGetSetSMPServers = testChat2 aliceProfile bobProfile $ @@ -2047,7 +2380,7 @@ testGetSetSMPServers = alice #$> ("/smp_servers", id, "no custom SMP servers saved") alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok") alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im") - alice #$> ("/smp_servers smp://2345-w==@smp2.example.im,smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/smp_servers smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") alice #$> ("/smp_servers default", id, "ok") alice #$> ("/smp_servers", id, "no custom SMP servers saved") @@ -2427,6 +2760,53 @@ testMaintenanceModeWithFiles = withTmpFiles $ do -- works after full restart withTestChat "alice" $ \alice -> testChatWorking alice bob +testMuteContact :: IO () +testMuteContact = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #> "@bob hello" + bob <# "alice> hello" + bob ##> "/mute alice" + bob <## "ok" + alice #> "@bob hi" + (bob "/cs" + bob <## "alice (Alice) (muted, you can /unmute @alice)" + bob ##> "/unmute alice" + bob <## "ok" + bob ##> "/cs" + bob <## "alice (Alice)" + alice #> "@bob hi again" + bob <# "alice> hi again" + +testMuteGroup :: IO () +testMuteGroup = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + threadDelay 1000000 + alice #> "#team hello!" + concurrently_ + (bob <# "#team alice> hello!") + (cath <# "#team alice> hello!") + bob ##> "/mute #team" + bob <## "ok" + alice #> "#team hi" + concurrently_ + (bob hi") + bob ##> "/gs" + bob <## "#team (muted, you can /unmute #team)" + bob ##> "/unmute #team" + bob <## "ok" + alice #> "#team hi again" + concurrently_ + (bob <# "#team alice> hi again") + (cath <# "#team alice> hi again") + bob ##> "/gs" + bob <## "#team" + withTestChatContactConnected :: String -> (TestCC -> IO a) -> IO a withTestChatContactConnected dbPrefix action = withTestChat dbPrefix $ \cc -> do @@ -2508,7 +2888,7 @@ connectUsers cc1 cc2 = do showName :: TestCC -> IO String showName (TestCC ChatController {currentUser} _ _ _ _) = do - Just User {localDisplayName, profile = Profile {fullName}} <- readTVarIO currentUser + Just User {localDisplayName, profile = LocalProfile {fullName}} <- readTVarIO currentUser pure . T.unpack $ localDisplayName <> " (" <> fullName <> ")" createGroup2 :: String -> TestCC -> TestCC -> IO () @@ -2575,6 +2955,11 @@ cc #> cmd = do cc `send` cmd cc <# cmd +(?#>) :: TestCC -> String -> IO () +cc ?#> cmd = do + cc `send` cmd + cc <# ("i " <> cmd) + (#$>) :: (Eq a, Show a) => TestCC -> (String, String -> a, a) -> Expectation cc #$> (cmd, f, res) = do cc ##> cmd @@ -2627,6 +3012,9 @@ getInAnyOrder f cc ls = do (<#) :: TestCC -> String -> Expectation cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line +(?<#) :: TestCC -> String -> Expectation +cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line + ( Expectation (