From 0e18b13beaaf46c65374ec6d5f82a2f1bcf6238c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 1 Dec 2023 03:38:21 +0800 Subject: [PATCH 1/5] desktop: adapting onboarding process to linking devices (#3490) * desktop: adapting onboarding process to linking devices * show progress on long operations * changes * clearing chat cache logic * lines --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../views/onboarding/SimpleXInfo.android.kt | 15 ++ .../kotlin/chat/simplex/common/App.kt | 56 ++++-- .../chat/simplex/common/model/ChatModel.kt | 9 +- .../chat/simplex/common/model/SimpleXAPI.kt | 55 +++++- .../chat/simplex/common/platform/Core.kt | 14 +- .../simplex/common/platform/NtfManager.kt | 8 +- .../chat/simplex/common/views/WelcomeView.kt | 23 ++- .../common/views/chatlist/ChatListView.kt | 29 +-- .../common/views/chatlist/ShareListView.kt | 4 +- .../common/views/chatlist/UserPicker.kt | 53 ++++-- .../simplex/common/views/helpers/Section.kt | 6 +- .../views/onboarding/CreateSimpleXAddress.kt | 4 + .../views/onboarding/LinkAMobileView.kt | 91 +++++++++ .../common/views/onboarding/OnboardingView.kt | 1 + .../onboarding/SetupDatabasePassphrase.kt | 11 +- .../common/views/onboarding/SimpleXInfo.kt | 16 +- .../common/views/remote/ConnectMobileView.kt | 144 +++++++++------ .../views/usersettings/NetworkAndServers.kt | 23 ++- .../views/usersettings/PrivacySettings.kt | 64 +++---- .../common/views/usersettings/SettingsView.kt | 173 ++++++++++-------- .../views/usersettings/UserProfilesView.kt | 5 +- .../commonMain/resources/MR/base/strings.xml | 3 + .../resources/MR/images/ic_refresh.svg | 1 + .../views/onboarding/SimpleXInfo.desktop.kt | 23 +++ 24 files changed, 565 insertions(+), 266 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt new file mode 100644 index 000000000..bfe87b17d --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt @@ -0,0 +1,15 @@ +package chat.simplex.common.views.onboarding + +import androidx.compose.runtime.Composable +import chat.simplex.common.model.SharedPreference +import chat.simplex.common.model.User +import chat.simplex.res.MR + +@Composable +actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { + if (user == null) { + OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick = onclick) + } else { + OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index cfbcb7aa4..d4062a1aa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -37,8 +37,7 @@ import kotlinx.coroutines.flow.* data class SettingsViewState( val userPickerState: MutableStateFlow, - val scaffoldState: ScaffoldState, - val switchingUsersAndHosts: MutableState + val scaffoldState: ScaffoldState ) @Composable @@ -102,11 +101,8 @@ fun MainScreen() { } Box { - var onboarding by remember { mutableStateOf(chatModel.controller.appPrefs.onboardingStage.get()) } - LaunchedEffect(Unit) { - snapshotFlow { chatModel.controller.appPrefs.onboardingStage.state.value }.distinctUntilChanged().collect { onboarding = it } - } - val userCreated = chatModel.userCreated.value + val onboarding by remember { chatModel.controller.appPrefs.onboardingStage.state } + val localUserCreated = chatModel.localUserCreated.value var showInitializationView by remember { mutableStateOf(false) } when { chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView() @@ -115,14 +111,18 @@ fun MainScreen() { DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) } } - remember { chatModel.chatDbEncrypted }.value == null || userCreated == null -> SplashView() - onboarding == OnboardingStage.OnboardingComplete && userCreated -> { + remember { chatModel.chatDbEncrypted }.value == null || localUserCreated == null -> SplashView() + onboarding == OnboardingStage.OnboardingComplete -> { Box { showAdvertiseLAAlert = true - val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } + val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(if (chatModel.desktopNoUserNoRemote()) AnimatedViewState.VISIBLE else AnimatedViewState.GONE)) } + KeyChangeEffect(chatModel.desktopNoUserNoRemote) { + if (chatModel.desktopNoUserNoRemote() && !ModalManager.start.hasModalsOpen()) { + userPickerState.value = AnimatedViewState.VISIBLE + } + } val scaffoldState = rememberScaffoldState() - val switchingUsersAndHosts = rememberSaveable { mutableStateOf(false) } - val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsersAndHosts) } + val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) } if (appPlatform.isAndroid) { AndroidScreen(settingsState) } else { @@ -137,12 +137,14 @@ fun MainScreen() { } } onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {} + onboarding == OnboardingStage.LinkAMobile -> LinkAMobile() onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel, null) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) } if (appPlatform.isAndroid) { ModalManager.fullscreen.showInView() + SwitchingUsersView() } val unauthorized = remember { derivedStateOf { AppLock.userAuthorized.value != true } } @@ -262,7 +264,7 @@ fun CenterPartOfScreen() { .background(MaterialTheme.colors.background), contentAlignment = Alignment.Center ) { - Text(stringResource(MR.strings.no_selected_chat)) + Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat)) } } else { ModalManager.center.showInView() @@ -286,6 +288,7 @@ fun DesktopScreen(settingsState: SettingsViewState) { } Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) { ModalManager.start.showInView() + SwitchingUsersView() } Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) { Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) { @@ -298,7 +301,7 @@ fun DesktopScreen(settingsState: SettingsViewState) { EndPartOfScreen() } } - val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState + val (userPickerState, scaffoldState ) = settingsState val scope = rememberCoroutineScope() if (scaffoldState.drawerState.isOpen) { Box( @@ -312,7 +315,7 @@ fun DesktopScreen(settingsState: SettingsViewState) { ) } VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) - UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { + UserPicker(chatModel, userPickerState) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } userPickerState.value = AnimatedViewState.GONE } @@ -335,3 +338,26 @@ fun InitializationView() { } } } + +@Composable +private fun SwitchingUsersView() { + if (remember { chatModel.switchingUsersAndHosts }.value) { + Box( + Modifier.fillMaxSize().clickable(enabled = false, onClick = {}), + contentAlignment = Alignment.Center + ) { + ProgressIndicator() + } + } +} + +@Composable +private fun ProgressIndicator() { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.5.dp + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 60b70c0ae..808b1938e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2,7 +2,7 @@ package chat.simplex.common.model import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -43,7 +43,7 @@ object ChatModel { val setDeliveryReceipts = mutableStateOf(false) val currentUser = mutableStateOf(null) val users = mutableStateListOf() - val userCreated = mutableStateOf(null) + val localUserCreated = mutableStateOf(null) val chatRunning = mutableStateOf(null) val chatDbChanged = mutableStateOf(false) val chatDbEncrypted = mutableStateOf(false) @@ -51,6 +51,7 @@ object ChatModel { val chats = mutableStateListOf() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() + val switchingUsersAndHosts = mutableStateOf(false) // current chat val chatId = mutableStateOf(null) @@ -108,6 +109,9 @@ object ChatModel { var updatingChatsMutex: Mutex = Mutex() + val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null + fun desktopNoUserNoRemote(): Boolean = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null + // remote controller val remoteHosts = mutableStateListOf() val currentRemoteHost = mutableStateOf(null) @@ -620,6 +624,7 @@ object ChatModel { terminalItems.add(item) } + val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 34b6fd99f..c61772578 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -362,7 +362,7 @@ object ChatController { chatModel.users.addAll(users) if (justStarted) { chatModel.currentUser.value = user - chatModel.userCreated.value = true + chatModel.localUserCreated.value = true getUserChatData(null) appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true @@ -382,6 +382,31 @@ object ChatController { } } + suspend fun startChatWithoutUser() { + Log.d(TAG, "user: null") + try { + if (chatModel.chatRunning.value == true) return + apiSetTempFolder(coreTmpDir.absolutePath) + apiSetFilesFolder(appFilesDir.absolutePath) + if (appPlatform.isDesktop) { + apiSetRemoteHostsFolder(remoteHostsDir.absolutePath) + } + apiSetXFTPConfig(getXFTPCfg()) + apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) + chatModel.users.clear() + chatModel.currentUser.value = null + chatModel.localUserCreated.value = false + appPrefs.chatLastStart.set(Clock.System.now()) + chatModel.chatRunning.value = true + startReceiver() + setLocalDeviceName(appPrefs.deviceNameForRemoteAccess.get()!!) + Log.d(TAG, "startChat: started without user") + } catch (e: Error) { + Log.e(TAG, "failed starting chat without user $e") + throw e + } + } + suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) { try { changeActiveUser_(rhId, toUserId, viewPwd) @@ -475,7 +500,9 @@ object ChatController { val r = sendCmd(rh, CC.ShowActiveUser()) if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") - chatModel.userCreated.value = false + if (rh == null) { + chatModel.localUserCreated.value = false + } return null } @@ -1990,7 +2017,7 @@ object ChatController { chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) } - suspend fun switchUIRemoteHost(rhId: Long?) { + suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded { // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null ModalManager.center.closeModals() @@ -2003,7 +2030,10 @@ object ChatController { chatModel.users.clear() chatModel.users.addAll(users) chatModel.currentUser.value = user - chatModel.userCreated.value = true + if (user == null) { + chatModel.chatItems.clear() + chatModel.chats.clear() + } val statuses = apiGetNetworkStatuses(rhId) if (statuses != null) { chatModel.networkStatuses.clear() @@ -2013,6 +2043,23 @@ object ChatController { getUserChatData(rhId) } + suspend fun showProgressIfNeeded(block: suspend () -> Unit) { + val job = withBGApi { + try { + delay(500) + chatModel.switchingUsersAndHosts.value = true + } catch (e: Throwable) { + chatModel.switchingUsersAndHosts.value = false + } + } + try { + block() + } finally { + job.cancel() + chatModel.switchingUsersAndHosts.value = false + } + } + fun getXFTPCfg(): XFTPFileConfig { return XFTPFileConfig(minFileSize = 0) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 3d3a91cb3..a4c1c333e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -55,10 +55,22 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) val user = chatController.apiGetActiveUser(null) if (user == null) { - chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = null chatModel.users.clear() + if (appPlatform.isDesktop) { + /** + * Setting it here to null because otherwise the screen will flash in [MainScreen] after the first start + * because of default value of [OnboardingStage.OnboardingComplete] + * */ + chatModel.localUserCreated.value = null + if (chatController.listRemoteHosts()?.isEmpty() == true) { + chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } + chatController.startChatWithoutUser() + } else { + chatController.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } } else { val savedOnboardingStage = appPreferences.onboardingStage.get() appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 06925e28a..6ca065086 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -59,7 +59,9 @@ abstract class NtfManager { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { // TODO include remote host ID in desktop notifications? - chatModel.controller.changeActiveUser(null, userId, null) + chatModel.controller.showProgressIfNeeded { + chatModel.controller.changeActiveUser(null, userId, null) + } } val cInfo = chatModel.getChat(chatId)?.chatInfo chatModel.clearOverlays.value = true @@ -72,7 +74,9 @@ abstract class NtfManager { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { // TODO include remote host ID in desktop notifications? - chatModel.controller.changeActiveUser(null, userId, null) + chatModel.controller.showProgressIfNeeded { + chatModel.controller.changeActiveUser(null, userId, null) + } } chatModel.chatId.value = null chatModel.clearOverlays.value = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index fa86a6291..a63c4c528 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.Profile +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -76,7 +76,13 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { disabled = !canCreateProfile(displayName.value), textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, - click = { createProfileInProfiles(chatModel, displayName.value, close) }, + click = { + if (chatModel.localUserCreated.value == true) { + createProfileInProfiles(chatModel, displayName.value, close) + } else { + createProfileInNoProfileSetup(displayName.value, close) + } + }, ) SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device)) SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts)) @@ -168,6 +174,17 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) { } } +fun createProfileInNoProfileSetup(displayName: String, close: () -> Unit) { + withApi { + val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null)) ?: return@withApi + controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + chatModel.chatRunning.value = false + controller.startChat(user) + controller.switchUIRemoteHost(null) + close() + } +} + fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) { withApi { val rhId = chatModel.remoteHostId() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 301f09033..a91e5e7b3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -68,14 +68,14 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp var searchInList by rememberSaveable { mutableStateOf("") } val scope = rememberCoroutineScope() - val (userPickerState, scaffoldState, switchingUsersAndHosts ) = settingsState + val (userPickerState, scaffoldState ) = settingsState Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } }, scaffoldState = scaffoldState, drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) }, drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), drawerGesturesEnabled = appPlatform.isAndroid, floatingActionButton = { - if (searchInList.isEmpty()) { + if (searchInList.isEmpty() && !chatModel.desktopNoUserNoRemote) { FloatingActionButton( onClick = { if (!stopped) { @@ -104,7 +104,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf ) { if (chatModel.chats.isNotEmpty()) { ChatList(chatModel, search = searchInList) - } else if (!switchingUsersAndHosts.value) { + } else if (!chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { Box(Modifier.fillMaxSize()) { if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) { OnboardingButtons(showNewChatSheet) @@ -121,19 +121,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, switchingUsersAndHosts) { + UserPicker(chatModel, userPickerState) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } userPickerState.value = AnimatedViewState.GONE } } - if (switchingUsersAndHosts.value) { - Box( - Modifier.fillMaxSize().clickable(enabled = false, onClick = {}), - contentAlignment = Alignment.Center - ) { - ProgressIndicator() - } - } } @Composable @@ -209,7 +201,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user navigationButton = { if (showSearch) { NavigationButtonBack(hideSearchOnBack) - } else if (chatModel.users.isEmpty()) { + } else if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } } else { val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } } @@ -304,17 +296,6 @@ private fun ToggleFilterButton() { } } -@Composable -private fun ProgressIndicator() { - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(30.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 2.5.dp - ) -} - @Composable expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index ecd47c937..8338d2960 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) { var searchInList by rememberSaveable { mutableStateOf("") } - val (userPickerState, scaffoldState, switchingUsersAndHosts) = settingsState + val (userPickerState, scaffoldState) = settingsState val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp Scaffold( Modifier.padding(end = endPadding), @@ -47,7 +47,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe } } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState, switchingUsersAndHosts, showSettings = false, showCancel = true, cancelClicked = { + UserPicker(chatModel, userPickerState, showSettings = false, showCancel = true, cancelClicked = { chatModel.sharedContent.value = null userPickerState.value = AnimatedViewState.GONE }) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 46a333f61..d87c05a91 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -26,7 +26,9 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* +import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.remote.* +import chat.simplex.common.views.usersettings.doWithAuth import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay @@ -38,7 +40,6 @@ import kotlin.math.roundToInt fun UserPicker( chatModel: ChatModel, userPickerState: MutableStateFlow, - switchingUsersAndHosts: MutableState, showSettings: Boolean = true, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, @@ -123,14 +124,10 @@ fun UserPicker( userPickerState.value = AnimatedViewState.HIDING if (!u.user.activeUser) { scope.launch { - val job = launch { - delay(500) - switchingUsersAndHosts.value = true + controller.showProgressIfNeeded { + ModalManager.closeAllModalsEverywhere() + chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) } - ModalManager.closeAllModalsEverywhere() - chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) - job.cancel() - switchingUsersAndHosts.value = false } } } @@ -162,13 +159,13 @@ fun UserPicker( val currentRemoteHost = remember { chatModel.currentRemoteHost }.value Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { if (remoteHosts.isNotEmpty()) { - if (currentRemoteHost == null) { + if (currentRemoteHost == null && chatModel.localUserCreated.value == true) { LocalDevicePickerItem(true) { userPickerState.value = AnimatedViewState.HIDING switchToLocalDevice() } Divider(Modifier.requiredHeight(1.dp)) - } else { + } else if (currentRemoteHost != null) { val connecting = rememberSaveable { mutableStateOf(false) } RemoteHostPickerItem(currentRemoteHost, actionButtonClick = { @@ -176,7 +173,7 @@ fun UserPicker( stopRemoteHostAndReloadHosts(currentRemoteHost, true) }) { userPickerState.value = AnimatedViewState.HIDING - switchToRemoteHost(currentRemoteHost, switchingUsersAndHosts, connecting) + switchToRemoteHost(currentRemoteHost, connecting) } Divider(Modifier.requiredHeight(1.dp)) } @@ -184,7 +181,7 @@ fun UserPicker( UsersView() - if (remoteHosts.isNotEmpty() && currentRemoteHost != null) { + if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) { LocalDevicePickerItem(false) { userPickerState.value = AnimatedViewState.HIDING switchToLocalDevice() @@ -199,7 +196,7 @@ fun UserPicker( stopRemoteHostAndReloadHosts(h, false) }) { userPickerState.value = AnimatedViewState.HIDING - switchToRemoteHost(h, switchingUsersAndHosts, connecting) + switchToRemoteHost(h, connecting) } Divider(Modifier.requiredHeight(1.dp)) } @@ -220,6 +217,18 @@ fun UserPicker( userPickerState.value = AnimatedViewState.GONE } Divider(Modifier.requiredHeight(1.dp)) + } else if (chatModel.desktopNoUserNoRemote) { + CreateInitialProfile { + doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { + ModalManager.center.showModalCloseable { close -> + LaunchedEffect(Unit) { + userPickerState.value = AnimatedViewState.HIDING + } + CreateProfile(chat.simplex.common.platform.chatModel, close) + } + } + } + Divider(Modifier.requiredHeight(1.dp)) } if (showSettings) { SettingsPickerItem(settingsClicked) @@ -401,6 +410,16 @@ private fun LinkAMobilePickerItem(onClick: () -> Unit) { } } +@Composable +private fun CreateInitialProfile(onClick: () -> Unit) { + SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { + val text = generalGetString(MR.strings.create_chat_profile) + Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) + Text(text, color = if (isInDarkTheme()) MenuTextColorDark else Color.Black) + } +} + @Composable private fun SettingsPickerItem(onClick: () -> Unit) { SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { @@ -441,21 +460,15 @@ private fun switchToLocalDevice() { } } -private fun switchToRemoteHost(h: RemoteHostInfo, switchingUsersAndHosts: MutableState, connecting: MutableState) { +private fun switchToRemoteHost(h: RemoteHostInfo, connecting: MutableState) { if (!h.activeHost()) { withBGApi { - val job = launch { - delay(500) - switchingUsersAndHosts.value = true - } ModalManager.closeAllModalsEverywhere() if (h.sessionState != null) { chatModel.controller.switchUIRemoteHost(h.remoteHostId) } else { connectMobileDevice(h, connecting) } - job.cancel() - switchingUsersAndHosts.value = false } } else { connectMobileDevice(h, connecting) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 16d7e88d6..ba0edb98d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.platform.onRightClick @@ -202,13 +203,14 @@ fun SectionTextFooter(text: String) { } @Composable -fun SectionTextFooter(text: AnnotatedString) { +fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start) { Text( text, Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F), color = MaterialTheme.colors.secondary, lineHeight = 18.sp, - fontSize = 14.sp + fontSize = 14.sp, + textAlign = textAlign ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 49e62fb06..092fb8bf6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -182,6 +182,10 @@ private fun prepareChatBeforeAddressCreation(rhId: Long?) { val user = chatModel.controller.apiGetActiveUser(rhId) ?: return@withApi chatModel.currentUser.value = user if (chatModel.users.isEmpty()) { + if (appPlatform.isDesktop) { + // Make possible to use chat after going to remote device linking and returning back to local profile creation + chatModel.chatRunning.value = false + } chatModel.controller.startChat(user) } else { val users = chatModel.controller.listUsers(rhId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt new file mode 100644 index 000000000..03b3d6dbd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt @@ -0,0 +1,91 @@ +package chat.simplex.common.views.onboarding + +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.remote.AddingMobileDevice +import chat.simplex.common.views.remote.DeviceNameField +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun LinkAMobile() { + val connecting = rememberSaveable { mutableStateOf(false) } + val deviceName = chatModel.controller.appPrefs.deviceNameForRemoteAccess + var deviceNameInQrCode by remember { mutableStateOf(chatModel.controller.appPrefs.deviceNameForRemoteAccess.get()) } + val staleQrCode = remember { mutableStateOf(false) } + + LinkAMobileLayout( + deviceName = remember { deviceName.state }, + connecting, + staleQrCode, + updateDeviceName = { + withBGApi { + if (it != "" && it != deviceName.get()) { + chatModel.controller.setLocalDeviceName(it) + deviceName.set(it) + staleQrCode.value = deviceName.get() != deviceNameInQrCode + } + } + } + ) + KeyChangeEffect(staleQrCode.value) { + if (!staleQrCode.value) { + deviceNameInQrCode = deviceName.get() + } + } +} + +@Composable +private fun LinkAMobileLayout( + deviceName: State, + connecting: MutableState, + staleQrCode: MutableState, + updateDeviceName: (String) -> Unit, +) { + Column(Modifier.padding(top = 20.dp)) { + AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles)) + Row(Modifier.weight(1f).padding(horizontal = DEFAULT_PADDING * 2), verticalAlignment = Alignment.CenterVertically) { + Column( + Modifier.weight(0.3f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { + DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } + SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) + PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), remember { ChatModel.controller.appPrefs.offerRemoteMulticast.state }.value) { + ChatModel.controller.appPrefs.offerRemoteMulticast.set(it) + } + } + } + Box(Modifier.weight(0.7f)) { + AddingMobileDevice(false, staleQrCode, connecting) { + if (chatModel.remoteHosts.isEmpty()) { + chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } else { + chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) + } + } + } + } + SimpleButtonDecorated( + text = stringResource(MR.strings.about_simplex), + icon = painterResource(MR.images.ic_arrow_back_ios_new), + textDecoration = TextDecoration.None, + fontWeight = FontWeight.Medium + ) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index 0a48bbd1e..d4c63248e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.onboarding enum class OnboardingStage { Step1_SimpleXInfo, Step2_CreateProfile, + LinkAMobile, Step2_5_SetupDatabasePassphrase, Step3_CreateSimpleXAddress, Step4_SetNotificationsMode, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index a4ebd23de..d9d012a50 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -43,7 +43,11 @@ fun SetupDatabasePassphrase(m: ChatModel) { val newKey = rememberSaveable { mutableStateOf("") } val confirmNewKey = rememberSaveable { mutableStateOf("") } fun nextStep() { - m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + if (appPlatform.isAndroid || chatModel.currentUser.value != null) { + m.controller.appPrefs.onboardingStage.set(OnboardingStage.Step3_CreateSimpleXAddress) + } else { + m.controller.appPrefs.onboardingStage.set(OnboardingStage.LinkAMobile) + } } SetupDatabasePassphraseLayout( currentKey, @@ -159,10 +163,7 @@ private fun SetupDatabasePassphraseLayout( } }, isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, - keyboardActions = KeyboardActions(onDone = { - if (!disabled) onClickUpdate() - defaultKeyboardAction(ImeAction.Done) - }), + keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }), ) Box(Modifier.align(Alignment.CenterHorizontally).padding(vertical = DEFAULT_PADDING)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index f20c4508b..2aad2556a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -8,6 +8,7 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -99,26 +100,22 @@ private fun InfoRow(icon: Painter, titleId: StringResource, textId: StringResour } @Composable -fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)? = null) { - if (user == null) { - OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, onclick) - } else { - OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick) - } -} +expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)? = null) @Composable fun OnboardingActionButton( labelId: StringResource, onboarding: OnboardingStage?, border: Boolean, + icon: Painter? = null, + iconColor: Color = MaterialTheme.colors.primary, onclick: (() -> Unit)? ) { val modifier = if (border) { Modifier .border(border = BorderStroke(1.dp, MaterialTheme.colors.primary), shape = RoundedCornerShape(50)) .padding( - horizontal = DEFAULT_PADDING * 2, + horizontal = if (icon == null) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF, vertical = 4.dp ) } else { @@ -131,6 +128,9 @@ fun OnboardingActionButton( ChatController.appPrefs.onboardingStage.set(onboarding) } }, modifier) { + if (icon != null) { + Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor) + } Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp) Icon( painterResource(MR.images.ic_arrow_forward_ios), "next stage", tint = MaterialTheme.colors.primary, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 53f0339be..b6ed14150 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -13,12 +13,14 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.input.* +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -97,9 +99,11 @@ fun ConnectMobileLayout( SectionDividerSpaced(maxBottomPadding = false) } SectionView(stringResource(MR.strings.devices).uppercase()) { - SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) { - if (connectedHost.value == null) { - Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + if (chatModel.localUserCreated.value == true) { + SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) { + if (connectedHost.value == null) { + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } } } @@ -162,26 +166,37 @@ fun DeviceNameField( @Composable private fun ConnectMobileViewLayout( - title: String, + title: String?, invitation: String?, deviceName: String?, sessionCode: String?, - port: String? + port: String?, + staleQrCode: Boolean = false, + refreshQrCode: () -> Unit = {} ) { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - AppBarTitle(title) + if (title != null) { + AppBarTitle(title) + } SectionView { if (invitation != null && sessionCode == null && port != null) { - QRCode( - invitation, Modifier - .padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) - .aspectRatio(1f) - ) - SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code)) - SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port)) + Box { + QRCode( + invitation, Modifier + .padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) + .aspectRatio(1f) + ) + if (staleQrCode) { + Box(Modifier.matchParentSize().background(MaterialTheme.colors.background.copy(alpha = 0.9f)), contentAlignment = Alignment.Center) { + SimpleButtonDecorated(stringResource(MR.strings.refresh_qr_code), painterResource(MR.images.ic_refresh), click = refreshQrCode) + } + } + } + SectionTextFooter(annotatedStringResource(MR.strings.open_on_mobile_and_scan_qr_code), textAlign = TextAlign.Center) + SectionTextFooter(annotatedStringResource(MR.strings.waiting_for_mobile_to_connect_on_port, port), textAlign = TextAlign.Center) if (remember { controller.appPrefs.developerTools.state }.value) { val clipboard = LocalClipboardManager.current @@ -237,55 +252,72 @@ fun connectMobileDevice(rh: RemoteHostInfo, connecting: MutableState) { private fun showAddingMobileDevice(connecting: MutableState) { ModalManager.start.showModalCloseable { close -> - val invitation = rememberSaveable { mutableStateOf(null) } - val port = rememberSaveable { mutableStateOf(null) } - val pairing = remember { chatModel.remoteHostPairing } - val sessionCode = when (val state = pairing.value?.second) { - is RemoteHostSessionState.PendingConfirmation -> state.sessionCode - else -> null + AddingMobileDevice(true, remember { mutableStateOf(false) }, connecting, close) + } +} + +@Composable +fun AddingMobileDevice(showTitle: Boolean, staleQrCode: MutableState, connecting: MutableState, close: () -> Unit) { + val invitation = rememberSaveable { mutableStateOf(null) } + val port = rememberSaveable { mutableStateOf(null) } + val startRemoteHost = suspend { + val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get()) + if (r != null) { + connecting.value = true + invitation.value = r.second + port.value = r.third + chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting } - /** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */ - var cachedSessionCode by remember { mutableStateOf(null) } - if (cachedSessionCode == null && sessionCode != null) { - cachedSessionCode = sessionCode - } - val remoteDeviceName = pairing.value?.first?.hostDeviceName - ConnectMobileViewLayout( - title = if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection), - invitation = invitation.value, - deviceName = remoteDeviceName, - sessionCode = cachedSessionCode, - port = port.value - ) - val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) } - LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { - if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) { - close() - } - } - KeyChangeEffect(pairing.value) { - if (pairing.value == null) { - close() - } - } - DisposableEffect(Unit) { + } + val pairing = remember { chatModel.remoteHostPairing } + val sessionCode = when (val state = pairing.value?.second) { + is RemoteHostSessionState.PendingConfirmation -> state.sessionCode + else -> null + } + /** It's needed to prevent screen flashes when [chatModel.newRemoteHostPairing] sets to null in background */ + var cachedSessionCode by remember { mutableStateOf(null) } + if (cachedSessionCode == null && sessionCode != null) { + cachedSessionCode = sessionCode + } + val remoteDeviceName = pairing.value?.first?.hostDeviceName + ConnectMobileViewLayout( + title = if (!showTitle) null else if (cachedSessionCode == null) stringResource(MR.strings.link_a_mobile) else stringResource(MR.strings.verify_connection), + invitation = invitation.value, + deviceName = remoteDeviceName, + sessionCode = cachedSessionCode, + port = port.value, + staleQrCode = staleQrCode.value, + refreshQrCode = { withBGApi { - val r = chatModel.controller.startRemoteHost(null, controller.appPrefs.offerRemoteMulticast.get()) - if (r != null) { - connecting.value = true - invitation.value = r.second - port.value = r.third - chatModel.remoteHostPairing.value = null to RemoteHostSessionState.Starting + if (chatController.stopRemoteHost(null)) { + startRemoteHost() + staleQrCode.value = false } } - onDispose { - if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) { - withBGApi { - chatController.stopRemoteHost(null) - } + }, + ) + val oldRemoteHostId by remember { mutableStateOf(chatModel.currentRemoteHost.value?.remoteHostId) } + LaunchedEffect(remember { chatModel.currentRemoteHost }.value) { + if (chatModel.currentRemoteHost.value?.remoteHostId != null && chatModel.currentRemoteHost.value?.remoteHostId != oldRemoteHostId) { + close() + } + } + KeyChangeEffect(pairing.value) { + if (pairing.value == null) { + close() + } + } + DisposableEffect(Unit) { + withBGApi { + startRemoteHost() + } + onDispose { + if (chatModel.currentRemoteHost.value?.remoteHostId == oldRemoteHostId) { + withBGApi { + chatController.stopRemoteHost(null) } - chatModel.remoteHostPairing.value = null } + chatModel.remoteHostPairing.value = null } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index ad30b57c6..a96de71b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ClickableText import chat.simplex.common.views.helpers.* @@ -169,18 +170,20 @@ fun NetworkAndServersView( verticalArrangement = Arrangement.spacedBy(8.dp) ) { AppBarTitle(stringResource(MR.strings.network_and_servers)) - SectionView(generalGetString(MR.strings.settings_section_title_messages)) { - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) }) + if (!chatModel.desktopNoUserNoRemote) { + SectionView(generalGetString(MR.strings.settings_section_title_messages)) { + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) }) - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) }) - if (currentRemoteHost == null) { - UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) - UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) - if (developerTools) { - SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) + if (currentRemoteHost == null) { + UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) + UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) + if (developerTools) { + SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) + } + SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } - SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } } if (currentRemoteHost == null && networkUseSocksProxy.value) { @@ -192,7 +195,7 @@ fun NetworkAndServersView( } } Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) - } else { + } else if (!chatModel.desktopNoUserNoRemote) { Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 1a5aa49eb..3d2b7b7fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -92,7 +92,6 @@ fun PrivacySettingsView( chatModel.simplexLinkMode.value = it }) } - SectionDividerSpaced() val currentUser = chatModel.currentUser.value if (currentUser != null) { @@ -142,39 +141,42 @@ fun PrivacySettingsView( } } - DeliveryReceiptsSection( - currentUser = currentUser, - setOrAskSendReceiptsContacts = { enable -> - val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> - if (chat.chatInfo is ChatInfo.Direct) { - val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts - count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + if (!chatModel.desktopNoUserNoRemote) { + SectionDividerSpaced() + DeliveryReceiptsSection( + currentUser = currentUser, + setOrAskSendReceiptsContacts = { enable -> + val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Direct) { + val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (contactReceiptsOverrides == 0) { + setSendReceiptsContacts(enable, clearOverrides = false) } else { - count + showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) + } + }, + setOrAskSendReceiptsGroups = { enable -> + val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> + if (chat.chatInfo is ChatInfo.Group) { + val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts + count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) + } else { + count + } + } + if (groupReceiptsOverrides == 0) { + setSendReceiptsGroups(enable, clearOverrides = false) + } else { + showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) } } - if (contactReceiptsOverrides == 0) { - setSendReceiptsContacts(enable, clearOverrides = false) - } else { - showUserContactsReceiptsAlert(enable, contactReceiptsOverrides, ::setSendReceiptsContacts) - } - }, - setOrAskSendReceiptsGroups = { enable -> - val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> - if (chat.chatInfo is ChatInfo.Group) { - val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts - count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) - } else { - count - } - } - if (groupReceiptsOverrides == 0) { - setSendReceiptsGroups(enable, clearOverrides = false) - } else { - showUserGroupsReceiptsAlert(enable, groupReceiptsOverrides, ::setSendReceiptsGroups) - } - } - ) + ) + } } SectionBottomSpacer() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index dfe358152..c73cb0142 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SimpleXInfo @@ -38,76 +39,39 @@ import kotlinx.coroutines.launch fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) { val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false - - if (user != null) { - val requireAuth = remember { chatModel.controller.appPrefs.performLA.state } - SettingsLayout( - profile = user.profile, - stopped, - chatModel.chatDbEncrypted.value == true, - remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, - remember { chatModel.controller.appPrefs.notificationsMode.state }, - user.displayName, - setPerformLA = setPerformLA, - showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, - showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } }, - showSettingsModalWithSearch = { modalView -> - ModalManager.start.showCustomModal { close -> - val search = rememberSaveable { mutableStateOf("") } - ModalView( - { close() }, - endButtons = { - SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it } - }, - content = { modalView(chatModel, search) }) + SettingsLayout( + profile = user?.profile, + stopped, + chatModel.chatDbEncrypted.value == true, + remember { chatModel.controller.appPrefs.storeDBPassphrase.state }.value, + remember { chatModel.controller.appPrefs.notificationsMode.state }, + user?.displayName, + setPerformLA = setPerformLA, + showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, + showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } }, + showSettingsModalWithSearch = { modalView -> + ModalManager.start.showCustomModal { close -> + val search = rememberSaveable { mutableStateOf("") } + ModalView( + { close() }, + endButtons = { + SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it } + }, + content = { modalView(chatModel, search) }) + } + }, + showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } }, + showVersion = { + withApi { + val info = chatModel.controller.apiGetVersion() + if (info != null) { + ModalManager.start.showModal { VersionInfoView(info) } } - }, - showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } }, - showVersion = { - withApi { - val info = chatModel.controller.apiGetVersion() - if (info != null) { - ModalManager.start.showModal { VersionInfoView(info) } - } - } - }, - withAuth = { title, desc, block -> - if (!requireAuth.value) { - block() - } else { - var autoShow = true - ModalManager.fullscreen.showModalCloseable { close -> - val onFinishAuth = { success: Boolean -> - if (success) { - close() - block() - } - } - - LaunchedEffect(Unit) { - if (autoShow) { - autoShow = false - runAuth(title, desc, onFinishAuth) - } - } - Box( - Modifier.fillMaxSize().background(MaterialTheme.colors.background), - contentAlignment = Alignment.Center - ) { - SimpleButton( - stringResource(MR.strings.auth_unlock), - icon = painterResource(MR.images.ic_lock), - click = { - runAuth(title, desc, onFinishAuth) - } - ) - } - } - } - }, - drawerState = drawerState, - ) - } + } + }, + withAuth = ::doWithAuth, + drawerState = drawerState, + ) } val simplexTeamUri = @@ -115,12 +79,12 @@ val simplexTeamUri = @Composable fun SettingsLayout( - profile: LocalProfile, + profile: LocalProfile?, stopped: Boolean, encrypted: Boolean, passphraseSaved: Boolean, notificationsMode: State, - userDisplayName: String, + userDisplayName: String?, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), @@ -150,13 +114,22 @@ fun SettingsLayout( AppBarTitle(stringResource(MR.strings.your_settings)) SectionView(stringResource(MR.strings.settings_section_title_you)) { - SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { - ProfilePreview(profile, stopped = stopped) - } val profileHidden = rememberSaveable { mutableStateOf(false) } - SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) - ChatPreferencesItem(showCustomModal, stopped = stopped) + if (profile != null) { + SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { + ProfilePreview(profile, stopped = stopped) + } + SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) + ChatPreferencesItem(showCustomModal, stopped = stopped) + } else if (chatModel.localUserCreated.value == false) { + SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.center.showModalCloseable { close -> + LaunchedEffect(Unit) { + closeSettings() + } + CreateProfile(chatModel, close) + } } }, disabled = stopped, extraPadding = true) + } if (appPlatform.isDesktop) { SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped, extraPadding = true) } else { @@ -171,15 +144,19 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) - DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + if (!chatModel.desktopNoUserNoRemote) { + DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + } } SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_help)) { - SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true) + if (!chatModel.desktopNoUserNoRemote) { + SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true) + } SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true) } SectionDividerSpaced() @@ -469,6 +446,42 @@ fun PreferenceToggleWithIcon( } } +fun doWithAuth(title: String, desc: String, block: () -> Unit) { + val requireAuth = chatModel.controller.appPrefs.performLA.get() + if (!requireAuth) { + block() + } else { + var autoShow = true + ModalManager.fullscreen.showModalCloseable { close -> + val onFinishAuth = { success: Boolean -> + if (success) { + close() + block() + } + } + + LaunchedEffect(Unit) { + if (autoShow) { + autoShow = false + runAuth(title, desc, onFinishAuth) + } + } + Box( + Modifier.fillMaxSize().background(MaterialTheme.colors.background), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(MR.strings.auth_unlock), + icon = painterResource(MR.images.ic_lock), + click = { + runAuth(title, desc, onFinishAuth) + } + ) + } + } + } +} + private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> Unit) { authenticate( title, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index 401c3afed..390f1cac9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction @@ -56,7 +57,9 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: ModalManager.end.closeModals() } withBGApi { - m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) + controller.showProgressIfNeeded { + m.controller.changeActiveUser(user.remoteHostId, user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) + } } }, removeUser = { user -> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 22f5d2fbe..82d571d4a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -565,6 +565,7 @@ Your settings Your SimpleX address Your chat profiles + Create chat profile Database passphrase & export About SimpleX Chat How to use it @@ -1689,6 +1690,8 @@ Paste desktop address Desktop Not compatible! + Refresh + No connected mobile Coming soon! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg new file mode 100644 index 000000000..a1f27d579 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt new file mode 100644 index 000000000..83c438e4a --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt @@ -0,0 +1,23 @@ +package chat.simplex.common.views.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.SharedPreference +import chat.simplex.common.model.User +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { + if (user == null) { + Row(horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING * 2.5f)) { + OnboardingActionButton(MR.strings.link_a_mobile, onboarding = if (controller.appPrefs.initialRandomDBPassphrase.get()) OnboardingStage.Step2_5_SetupDatabasePassphrase else OnboardingStage.LinkAMobile, true, icon = painterResource(MR.images.ic_smartphone_300), onclick = onclick) + OnboardingActionButton(MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, true, icon = painterResource(MR.images.ic_desktop), onclick = onclick) + } + } else { + OnboardingActionButton(MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, true, onclick = onclick) + } +} From 540c8883a0eb57c000555dad1c1a0491ac0a235c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 1 Dec 2023 03:39:16 +0800 Subject: [PATCH 2/5] android: do not show alert too early in obboarding (#3493) --- .../android/src/main/java/chat/simplex/app/SimplexApp.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index b6afab4ea..90696b79a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -75,7 +75,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } Lifecycle.Event.ON_RESUME -> { isAppOnForeground = true - if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { + if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) { SimplexService.showBackgroundServiceNoticeIfNeeded() } /** From b74e33b9582afb3f385580e928af0452de896b32 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 1 Dec 2023 19:02:50 +0400 Subject: [PATCH 3/5] docs: inactive group members rfc (#3419) --- .../rfcs/2023-11-21-inactive-group-members.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/rfcs/2023-11-21-inactive-group-members.md diff --git a/docs/rfcs/2023-11-21-inactive-group-members.md b/docs/rfcs/2023-11-21-inactive-group-members.md new file mode 100644 index 000000000..66e58848a --- /dev/null +++ b/docs/rfcs/2023-11-21-inactive-group-members.md @@ -0,0 +1,110 @@ +# Inactive group members + +## Problem + +Group traffic is higher than necessary due to lack of diagnosis of inactive group members. By inactive we understand group members who went offline for indefinitely long time, uninstalled application without leaving group, failed to send x.grp.leave message before deleting connection, or in any other way failed to explicitly communicate further inactivity. + +Currently other group members continue to identify such members as active and to send messages to their connections until exceeding receiving SMP queues quotas, with pending messages being slowly retried even after that. + +## Solution + +Identify inactive members and don't send messages to their connections. Silent periodically online members should continue to receive messages, so decision to mark member as inactive should be made conservatively. + +Agent: +- on SMP.QUOTA error notify client with ERR CONN QUOTA (new ConnectionErrorType QUOTA). +- on receiving QCONT notify client (new event). + +Chat, on sending side, per member: +- unanswered_snd_msg_count - number of messages that were sent consecutively without receiving a message from member. +- last_rcv_ts - timestamp of last received message. +- inactive flag. +- set inactive if: + - agent reports QUOTA error. + - on sending message: (unanswered_snd_msg_count > K) && (last_rcv_ts earlier than Ddiff days ago), Ddiff = 1/2/3 days? +- reset inactive: + - on receiving QCONT. + - on receiving message or receipt. Also reset unanswered_snd_msg_count, last_rcv_ts. +- don't send to member if inactive. + - don't send only content messages (x.msg.new, etc.) and always send messages altering group state? +- unanswered_snd_msg_count, last_rcv_ts to be tracked, checked, reset only for members with compatible version. + +Chat, on receiving side, per member: +- unanswered_rcv_msg_count - number of messages that were received consecutively without sending a message to member. +- send non-optional receipt / another (new) protocol message if: + - on receiving message: unanswered_rcv_msg_count > M, M < K. +- on sending a message or receipt to member reset unanswered_rcv_msg_count. +- unanswered_rcv_msg_count to be tracked, checked, reset only for members with compatible version. + +\*** + +Consider above condition: + +> (unanswered_snd_msg_count > K) && (last_rcv_ts earlier than Ddiff days ago) + +It still doesn't account for following situation: + +1. Sending member sends a few (N1, N1 < M) messages to silent member on day D1. +2. Sending member doesn't send messages for several days. +3. Sending member sends more messages (N2, N1 + N2 > K) to silent member on day DI (DI - D1 > diff in days in above condition), while silent member is offline. + - Sending member checks above condition and evaluates it to be true, marks silent member as inactive. + - Simply remembering last_snd_ts on sending side and adding check for it not being from several days ago to above condition is not enough, as it will be overwritten by current day sends and will only evaluate false for the first send. What could work is remembering prev_session_last_snd_ts or prev_day_last_snd_ts, but it further complicates logic, and still probably wouldn't account for some time zone differences. +4. Sending member sends yet more messages, which will not be queued for silent member marked inactive. +5. Silent member comes online, sends receipt upon receiving message fulfilling above condition: `unanswered_rcv_msg_count > M`, and will lose following messages. + - If sending member created messages from 4 as pending, and sent them upon receiving receipt from silent member, silent member would only receive them after sending member coming online. If they are in different time zones it may happen on next day. + +Same situation can occur even without step 1, simply by sending many messages while other member is offline. + +The problem is less acute the greater the difference between K and M, but making K >> M renders this whole mechanism obsolete, as we could then simply rely on QUOTA errors to mark group members inactive (and don't slow retry in agent?). + +Perhaps an acceptable way to solve this problem is to add a task to cleanup manager that would send receipts to all members on condition: (unanswered_rcv_msg_count > 0) && (last_reply_ts earlier than 1 day ago). (Adds last_reply_ts to tracking on receiving side). Perhaps it should be a task separate from cleanup manager that only occurs once per start, or with longer interval. + +\*** + +Additionally we could consider group member connection as disabled with smaller AUTH error count. Currently it's 10 messages, could be 1. + +### Delivery suspension notice + +When receiving side comes back online, replies and continues to receive messages, it has no way of knowing there was a gap in messages from sending member. To notify receiving member about delivery suspension, sending member should send notice containing shared message id of the last sent message (new protocol event) to them: + +```haskell +XGrpMemSuspended :: SharedMsgId -> ChatMsgEvent 'Json +``` + +Sending side additionally tracks: +- xgrpmemsuspended_sent flag - to only send it once. + +When processing it, receiving member creates a "gap" chat item (e.g. event saying "member x suspended delivery to you due to your inactivity, there may be a gap in messages"). + +After receiving member signals activity by sending any reply, sending member may send message history before continuing normal delivery. + +Starting point for message history: either receiving member could request history starting from specific shared message id (received in XGrpMemSuspended) with another new protocol event, or sending member can remember it instead of just flag. + +### Sending message history + +New protocol event: + +```haskell +XGrpMsgHistory :: [ChatMessage 'Json] -> ChatMsgEvent 'Json +``` + +Sending member builds messages history starting starting from requested/remembered shared message id: +- `messages` table is periodically cleaned up, so messages would be retrieved from `chat_items`. +- if chat item for starting shared message id is not found (it may have been deleted manually or as a disappearing message), abort? + - sending member could track number of skipped messages per member, but again if any chat items were deleted, older (previously successfully sent) chat items would be retrieved, resulting in duplicate messages. If receiving member has also cleaned up records in `messages` table, they wouldn't be deduplicated. + - sending member could track timestamp of first unsent message instead of shared msg id. +- sending member should probably limit maximum number of messages sent as history (100?). +- only XMsgNew events should be sent in XGrpMsgHistory (chat items to be transformed back into text messages). + - updates, deletions would be reflected in chat item list. + - reactions would be omitted. + - files would be likely expired by the time of sending history, so only file name and size may be sent in FileInvitation, with invitation being practically not acceptable. + - add new flag to CIFile "expired" for receiving member to mark chat items created based on such invitations. + - FileInvitation in MsgContainer could also contain this flag as optional to explicitly communicate that only file metadata is sent. + - alternatively sending member could re-upload files, but this seems excessive. + - XMsgNew events don't include message timestamps (instead usually broker ts is retrieved from agent message meta), so receiving member wouldn't be able to restore them from history. Perhaps history should include XGrpMsgForward events containing XMsgNew events instead. +- XGrpMsgHistory is likely to exceed message block limit. + - either multiple messages comprising a history can be batched as a single message on chat level until the block size is exceeded. + - or large history messages could be batched on agent level. + +\*** + +Same XGrpMsgHistory protocol event could be sent by host to new members, after sending introductions. From 40e69ae71377e85ae6f42da235bb88e64eb357e1 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 1 Dec 2023 23:04:00 +0800 Subject: [PATCH 4/5] desktop: enable database operations (#3495) * desktop: enable database operations * disconnect hosts button * not relaying on dev tools * different logic * different logic 2 * toggle placement --- .../chat/simplex/common/model/SimpleXAPI.kt | 5 +- .../views/database/DatabaseErrorView.kt | 3 +- .../common/views/database/DatabaseView.kt | 249 ++++++++++-------- .../common/views/remote/ConnectMobileView.kt | 1 + .../common/views/usersettings/SettingsView.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 1 + 6 files changed, 146 insertions(+), 117 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index c61772578..724e6bfcb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -430,8 +430,9 @@ object ChatController { } suspend fun getUserChatData(rhId: Long?) { - chatModel.userAddress.value = apiGetUserAddress(rhId) - chatModel.chatItemTTL.value = getChatItemTTL(rhId) + val hasUser = chatModel.currentUser.value != null + chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null + chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None updatingChatsMutex.withLock { val chats = apiGetChats(rhId) chatModel.updateChats(chats) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index bce8fdf4f..4e5424215 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -264,7 +264,8 @@ private fun DatabaseKeyField(text: MutableState, enabled: Boolean, onCli text, generalGetString(MR.strings.enter_passphrase), isValid = ::validKey, - keyboardActions = KeyboardActions(onDone = if (enabled) { + // Don't enable this on desktop since it interfere with key event listener + keyboardActions = KeyboardActions(onDone = if (enabled && appPlatform.isAndroid) { { onClick?.invoke() } } else null ), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index a4c8dc981..224317f94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -4,6 +4,7 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionTextFooter import SectionItemView +import SectionSpacer import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* @@ -20,6 +21,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -59,7 +61,9 @@ fun DatabaseView( val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) { - importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) + importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) { + startChat(m, chatLastStart, m.chatDbChanged) + } } } val chatItemTTL = remember { mutableStateOf(m.chatItemTTL.value) } @@ -77,7 +81,6 @@ fun DatabaseView( m.chatDbEncrypted.value, m.controller.appPrefs.storeDBPassphrase.state.value, m.controller.appPrefs.initialRandomDBPassphrase, - m.controller.appPrefs.developerTools.state.value, importArchiveLauncher, chatArchiveName, chatArchiveTime, @@ -100,7 +103,13 @@ fun DatabaseView( setCiTTL(m, rhId, chatItemTTL, progressIndicator, appFilesCountAndSize) } }, - showSettingsModal + showSettingsModal, + disconnectAllHosts = { + val connected = chatModel.remoteHosts.filter { it.sessionState is RemoteHostSessionState.Connected } + connected.forEachIndexed { index, h -> + controller.stopRemoteHostAndReloadHosts(h, index == connected.lastIndex && chatModel.connectedToRemote()) + } + } ) if (progressIndicator.value) { Box( @@ -129,7 +138,6 @@ fun DatabaseLayout( chatDbEncrypted: Boolean?, passphraseSaved: Boolean, initialRandomDBPassphrase: SharedPreference, - developerTools: Boolean, importArchiveLauncher: FileChooserLauncher, chatArchiveName: MutableState, chatArchiveTime: MutableState, @@ -144,36 +152,43 @@ fun DatabaseLayout( deleteChatAlert: () -> Unit, deleteAppFilesAndMedia: () -> Unit, onChatItemTTLSelected: (ChatItemTTL) -> Unit, - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + disconnectAllHosts: () -> Unit, ) { val stopped = !runChat - val operationsDisabled = !stopped || progressIndicator + val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), ) { AppBarTitle(stringResource(MR.strings.your_chat_database)) - SectionView(stringResource(MR.strings.messages_section_title).uppercase()) { - TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) - } - SectionTextFooter( - remember(currentUser?.displayName) { - buildAnnotatedString { - append(generalGetString(MR.strings.messages_section_description) + " ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(currentUser?.displayName ?: "") - } - append(".") - } + if (!chatModel.desktopNoUserNoRemote) { + SectionView(stringResource(MR.strings.messages_section_title).uppercase()) { + TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) } - ) - - if (currentRemoteHost == null) { + SectionTextFooter( + remember(currentUser?.displayName) { + buildAnnotatedString { + append(generalGetString(MR.strings.messages_section_description) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(currentUser?.displayName ?: "") + } + append(".") + } + } + ) SectionDividerSpaced(maxTopPadding = true) - + } + val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected } + if (chatModel.localUserCreated.value == true) { SectionView(stringResource(MR.strings.run_chat_section)) { - RunChatSetting(runChat, stopped, startChat, stopChatAlert) + if (!toggleEnabled) { + SectionItemView(disconnectAllHosts) { + Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + RunChatSetting(runChat, stopped, toggleEnabled, startChat, stopChatAlert) } SectionTextFooter( if (stopped) { @@ -183,92 +198,96 @@ fun DatabaseLayout( } ) SectionDividerSpaced() - - SectionView(stringResource(MR.strings.chat_database_section)) { - val unencrypted = chatDbEncrypted == false - SettingsActionItem( - if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) - else painterResource(MR.images.ic_lock), - stringResource(MR.strings.database_passphrase), - click = showSettingsModal() { DatabaseEncryptionView(it) }, - iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, - disabled = operationsDisabled - ) - if (appPlatform.isDesktop && developerTools) { - SettingsActionItem( - painterResource(MR.images.ic_folder_open), - stringResource(MR.strings.open_database_folder), - ::desktopOpenDatabaseDir, - disabled = operationsDisabled - ) - } - SettingsActionItem( - painterResource(MR.images.ic_ios_share), - stringResource(MR.strings.export_database), - click = { - if (initialRandomDBPassphrase.get()) { - exportProhibitedAlert() - } else { - exportArchive() - } - }, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary, - disabled = operationsDisabled - ) - SettingsActionItem( - painterResource(MR.images.ic_download), - stringResource(MR.strings.import_database), - { withApi { importArchiveLauncher.launch("application/zip") } }, - textColor = Color.Red, - iconColor = Color.Red, - disabled = operationsDisabled - ) - val chatArchiveNameVal = chatArchiveName.value - val chatArchiveTimeVal = chatArchiveTime.value - val chatLastStartVal = chatLastStart.value - if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) { - val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal) - SettingsActionItem( - painterResource(MR.images.ic_inventory_2), - title, - click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) }, - disabled = operationsDisabled - ) - } - SettingsActionItem( - painterResource(MR.images.ic_delete_forever), - stringResource(MR.strings.delete_database), - deleteChatAlert, - textColor = Color.Red, - iconColor = Color.Red, - disabled = operationsDisabled - ) - } - SectionDividerSpaced(maxTopPadding = true) - - SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) { - val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0 - SectionItemView( - deleteAppFilesAndMedia, - disabled = deleteFilesDisabled - ) { - Text( - stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all), - color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red - ) - } - } - val (count, size) = appFilesCountAndSize.value - SectionTextFooter( - if (count == 0) { - stringResource(MR.strings.no_received_app_files) - } else { - String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size)) - } - ) } + SectionView(stringResource(MR.strings.chat_database_section)) { + if (chatModel.localUserCreated.value != true && !toggleEnabled) { + SectionItemView(disconnectAllHosts) { + Text(generalGetString(MR.strings.disconnect_remote_hosts), Modifier.fillMaxWidth(), color = WarningOrange) + } + } + val unencrypted = chatDbEncrypted == false + SettingsActionItem( + if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) + else painterResource(MR.images.ic_lock), + stringResource(MR.strings.database_passphrase), + click = showSettingsModal() { DatabaseEncryptionView(it) }, + iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, + disabled = operationsDisabled + ) + if (appPlatform.isDesktop) { + SettingsActionItem( + painterResource(MR.images.ic_folder_open), + stringResource(MR.strings.open_database_folder), + ::desktopOpenDatabaseDir, + disabled = operationsDisabled + ) + } + SettingsActionItem( + painterResource(MR.images.ic_ios_share), + stringResource(MR.strings.export_database), + click = { + if (initialRandomDBPassphrase.get()) { + exportProhibitedAlert() + } else { + exportArchive() + } + }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = operationsDisabled + ) + SettingsActionItem( + painterResource(MR.images.ic_download), + stringResource(MR.strings.import_database), + { withApi { importArchiveLauncher.launch("application/zip") } }, + textColor = Color.Red, + iconColor = Color.Red, + disabled = operationsDisabled + ) + val chatArchiveNameVal = chatArchiveName.value + val chatArchiveTimeVal = chatArchiveTime.value + val chatLastStartVal = chatLastStart.value + if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) { + val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal) + SettingsActionItem( + painterResource(MR.images.ic_inventory_2), + title, + click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) }, + disabled = operationsDisabled + ) + } + SettingsActionItem( + painterResource(MR.images.ic_delete_forever), + stringResource(MR.strings.delete_database), + deleteChatAlert, + textColor = Color.Red, + iconColor = Color.Red, + disabled = operationsDisabled + ) + } + SectionDividerSpaced(maxTopPadding = true) + + SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) { + val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0 + SectionItemView( + deleteAppFilesAndMedia, + disabled = deleteFilesDisabled + ) { + Text( + stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all), + color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red + ) + } + } + val (count, size) = appFilesCountAndSize.value + SectionTextFooter( + if (count == 0) { + stringResource(MR.strings.no_received_app_files) + } else { + String.format(stringResource(MR.strings.total_files_count_and_size), count, formatBytes(size)) + } + ) SectionBottomSpacer() } } @@ -319,6 +338,7 @@ private fun TtlOptions(current: State, enabled: State, onS fun RunChatSetting( runChat: Boolean, stopped: Boolean, + enabled: Boolean, startChat: () -> Unit, stopChatAlert: () -> Unit ) { @@ -337,6 +357,7 @@ fun RunChatSetting( stopChatAlert() } }, + enabled = enabled, ) } } @@ -501,13 +522,14 @@ private fun importArchiveAlert( m: ChatModel, importedArchiveURI: URI, appFilesCountAndSize: MutableState>, - progressIndicator: MutableState + progressIndicator: MutableState, + startChat: () -> Unit, ) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.import_database_question), text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one), confirmText = generalGetString(MR.strings.import_database_confirmation), - onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) }, + onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator, startChat) }, destructive = true, ) } @@ -516,7 +538,8 @@ private fun importArchive( m: ChatModel, importedArchiveURI: URI, appFilesCountAndSize: MutableState>, - progressIndicator: MutableState + progressIndicator: MutableState, + startChat: () -> Unit, ) { progressIndicator.value = true val archivePath = saveArchiveFromURI(importedArchiveURI) @@ -533,6 +556,10 @@ private fun importArchive( operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database)) } + if (chatModel.localUserCreated.value == false) { + chatModel.chatRunning.value = false + startChat() + } } else { operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database) + "\n" + generalGetString(MR.strings.non_fatal_errors_occured_during_import)) @@ -681,7 +708,6 @@ fun PreviewDatabaseLayout() { chatDbEncrypted = false, passphraseSaved = false, initialRandomDBPassphrase = SharedPreference({ true }, {}), - developerTools = true, importArchiveLauncher = rememberFileChooserLauncher(true) {}, chatArchiveName = remember { mutableStateOf("dummy_archive") }, chatArchiveTime = remember { mutableStateOf(Clock.System.now()) }, @@ -697,6 +723,7 @@ fun PreviewDatabaseLayout() { deleteAppFilesAndMedia = {}, showSettingsModal = { {} }, onChatItemTTLSelected = {}, + disconnectAllHosts = {}, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index b6ed14150..a7e76b4ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -233,6 +233,7 @@ private fun ConnectMobileViewLayout( } } } + SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c73cb0142..0f37fb197 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -144,9 +144,7 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) - if (!chatModel.desktopNoUserNoRemote) { - DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) - } + DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 82d571d4a..bfd61aa9e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1660,6 +1660,7 @@ Unlink desktop? Unlink Disconnect + Disconnect mobiles %s was disconnected]]> Disconnect desktop? Only one device can work at the same time From 9a1c7f41f770df72891c4fd6a3f459a9062bfd42 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Fri, 1 Dec 2023 18:52:47 +0200 Subject: [PATCH 5/5] core: expand ranges to fit ghc 8.10 & 9.6 (#3496) * expand ranges to fit ghc 8.10 & 9.6 * update nix * use hashes from mq master * fix more deps * use network-transport from hackage --- cabal.project | 7 +--- package.yaml | 12 +++--- scripts/nix/sha256map.nix | 3 +- simplex-chat.cabal | 84 +++++++++++++++++++-------------------- stack.yaml | 4 +- 5 files changed, 51 insertions(+), 59 deletions(-) diff --git a/cabal.project b/cabal.project index 7190c6183..026f963f7 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 6bffcc8503e5193d57e543ac0100712a8e27d454 + tag: 90a8fc91d35c578c3b52ad296a6f1df715da2278 source-repository-package type: git @@ -45,8 +45,3 @@ source-repository-package type: git location: https://github.com/simplex-chat/android-support.git tag: 9aa09f148089d6752ce563b14c2df1895718d806 - -source-repository-package - type: git - location: https://github.com/simplex-chat/network-transport.git - tag: 0013798272a683e35ca38d2fdaf480942311fba8 diff --git a/package.yaml b/package.yaml index 145b178c4..7c828ed7d 100644 --- a/package.yaml +++ b/package.yaml @@ -32,10 +32,10 @@ dependencies: - filepath == 1.4.* - http-types == 0.12.* - http2 >= 4.2.2 && < 4.3 - - memory == 0.18.* - - mtl == 2.3.* + - memory >= 0.15 && < 0.19 + - mtl >= 2.2 && < 3 - network >= 3.1.2.7 && < 3.2 - - network-transport == 0.5.6 + - network-transport >= 0.5.6 && < 0.6 - optparse-applicative >= 0.15 && < 0.17 - process == 1.6.* - random >= 1.1 && < 1.3 @@ -45,14 +45,14 @@ dependencies: - socks == 0.6.* - sqlcipher-simple == 0.4.* - stm == 2.5.* - - template-haskell == 2.20.* + - template-haskell >= 2.16 && < 2.21 - terminal == 0.2.* - - text == 2.0.* + - text >= 2.0 && < 3 - time == 1.9.* - tls >= 1.6.0 && < 1.7 - unliftio == 0.2.* - unliftio-core == 0.2.* - - zip == 2.0.* + - zip >= 1.7 && < 2.1 flags: swift: diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 004187e88..15a4b04ec 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."6bffcc8503e5193d57e543ac0100712a8e27d454" = "131kdcvh01985lnf4azss4rg7swpjjh647c29m95b33hd1f7mf17"; + "https://github.com/simplex-chat/simplexmq.git"."90a8fc91d35c578c3b52ad296a6f1df715da2278" = "1yjixh6b2s1law3kh885fsbr1inv1r7iy4g9g2bn6j4ygdn8vlzy"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; @@ -7,5 +7,4 @@ "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/simplex-chat/android-support.git"."9aa09f148089d6752ce563b14c2df1895718d806" = "0pbf2pf13v2kjzi397nr13f1h3jv0imvsq8rpiyy2qyx5vd50pqn"; - "https://github.com/simplex-chat/network-transport.git"."0013798272a683e35ca38d2fdaf480942311fba8" = "0dnn62apgvc248df0m8ib7phrzn63wm0xs71xvlypv52j6cgwzkb"; } diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 18b5da39a..6f7e61eda 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -184,10 +184,10 @@ library , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl ==2.3.* + , memory >=0.15 && <0.19 + , mtl >=2.2 && <3 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport >=0.5.6 && <0.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -197,14 +197,14 @@ library , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.20.* + , template-haskell >=2.16 && <2.21 , terminal ==0.2.* - , text ==2.0.* + , text >=2.0 && <3 , time ==1.9.* , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* - , zip ==2.0.* + , zip >=1.7 && <2.1 default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON @@ -236,10 +236,10 @@ executable simplex-bot , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl ==2.3.* + , memory >=0.15 && <0.19 + , mtl >=2.2 && <3 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport >=0.5.6 && <0.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -250,14 +250,14 @@ executable simplex-bot , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.20.* + , template-haskell >=2.16 && <2.21 , terminal ==0.2.* - , text ==2.0.* + , text >=2.0 && <3 , time ==1.9.* , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* - , zip ==2.0.* + , zip >=1.7 && <2.1 default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON @@ -289,10 +289,10 @@ executable simplex-bot-advanced , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl ==2.3.* + , memory >=0.15 && <0.19 + , mtl >=2.2 && <3 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport >=0.5.6 && <0.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -303,14 +303,14 @@ executable simplex-bot-advanced , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.20.* + , template-haskell >=2.16 && <2.21 , terminal ==0.2.* - , text ==2.0.* + , text >=2.0 && <3 , time ==1.9.* , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* - , zip ==2.0.* + , zip >=1.7 && <2.1 default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON @@ -344,10 +344,10 @@ executable simplex-broadcast-bot , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl ==2.3.* + , memory >=0.15 && <0.19 + , mtl >=2.2 && <3 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport >=0.5.6 && <0.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -358,14 +358,14 @@ executable simplex-broadcast-bot , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.20.* + , template-haskell >=2.16 && <2.21 , terminal ==0.2.* - , text ==2.0.* + , text >=2.0 && <3 , time ==1.9.* , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* - , zip ==2.0.* + , zip >=1.7 && <2.1 default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON @@ -398,10 +398,10 @@ executable simplex-chat , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl ==2.3.* + , memory >=0.15 && <0.19 + , mtl >=2.2 && <3 , network ==3.1.* - , network-transport ==0.5.6 + , network-transport >=0.5.6 && <0.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -412,15 +412,15 @@ executable simplex-chat , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.20.* + , template-haskell >=2.16 && <2.21 , terminal ==0.2.* - , text ==2.0.* + , text >=2.0 && <3 , time ==1.9.* , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* - , zip ==2.0.* + , zip >=1.7 && <2.1 default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON @@ -456,10 +456,10 @@ executable simplex-directory-service , filepath ==1.4.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl ==2.3.* + , memory >=0.15 && <0.19 + , mtl >=2.2 && <3 , network >=3.1.2.7 && <3.2 - , network-transport ==0.5.6 + , network-transport >=0.5.6 && <0.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -470,14 +470,14 @@ executable simplex-directory-service , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.20.* + , template-haskell >=2.16 && <2.21 , terminal ==0.2.* - , text ==2.0.* + , text >=2.0 && <3 , time ==1.9.* , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* - , zip ==2.0.* + , zip >=1.7 && <2.1 default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON @@ -540,10 +540,10 @@ test-suite simplex-chat-test , hspec ==2.11.* , http-types ==0.12.* , http2 >=4.2.2 && <4.3 - , memory ==0.18.* - , mtl ==2.3.* + , memory >=0.15 && <0.19 + , mtl >=2.2 && <3 , network ==3.1.* - , network-transport ==0.5.6 + , network-transport >=0.5.6 && <0.6 , optparse-applicative >=0.15 && <0.17 , process ==1.6.* , random >=1.1 && <1.3 @@ -555,14 +555,14 @@ test-suite simplex-chat-test , socks ==0.6.* , sqlcipher-simple ==0.4.* , stm ==2.5.* - , template-haskell ==2.20.* + , template-haskell >=2.16 && <2.21 , terminal ==0.2.* - , text ==2.0.* + , text >=2.0 && <3 , time ==1.9.* , tls >=1.6.0 && <1.7 , unliftio ==0.2.* , unliftio-core ==0.2.* - , zip ==2.0.* + , zip >=1.7 && <2.1 default-language: Haskell2010 if flag(swift) cpp-options: -DswiftJSON diff --git a/stack.yaml b/stack.yaml index 66e89de6f..2866c9bfa 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: 6bffcc8503e5193d57e543ac0100712a8e27d454 + commit: 90a8fc91d35c578c3b52ad296a6f1df715da2278 - github: kazu-yamamoto/http2 commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb # - ../direct-sqlcipher @@ -65,8 +65,6 @@ extra-deps: commit: f708b00009b54890172068f168bf98508ffcd495 - github: simplex-chat/android-support commit: 9aa09f148089d6752ce563b14c2df1895718d806 - - github: simplex-chat/network-transport - commit: 0013798272a683e35ca38d2fdaf480942311fba8 # # extra-deps: []