From 9c061508a41f5f88f1fab25422fe10b12d52d8eb Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 29 Dec 2023 22:46:45 +0700 Subject: [PATCH] android, desktop: rework UX of creating new connection (#3529) * android, desktop: rework UX of creating new connection * different place for clipboard listener * changes * changes * changes * button, strings * focus * code optimization and search icon * incognito link * comment * paddings * optimization * icon size and space in search * appbar background color * secondary color for icons in search bar * lighter tool bar and avatars * darker avatars in toolbar * background for selected item and divider * replacing connection view with actual chat view * clear * close unneeded view * filter icon background * filter doesn't hide current chat with empty search field * fixes for review * clearing focus on hiding keyboard * fix invalid qr code message * rename * color * buttons and text visibility when chat is not running yet * loading chats label --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- .../main/java/chat/simplex/app/SimplexApp.kt | 10 + .../common/platform/Modifier.android.kt | 2 + .../chatlist/ChatListNavLinkView.android.kt | 3 +- .../common/views/helpers/Utils.android.kt | 17 +- .../newchat/ConnectViaLinkView.android.kt | 68 -- .../views/newchat/QRCodeScanner.android.kt | 162 +++-- .../newchat/ScanToConnectView.android.kt | 22 - .../kotlin/chat/simplex/common/App.kt | 19 +- .../chat/simplex/common/model/ChatModel.kt | 41 +- .../chat/simplex/common/model/SimpleXAPI.kt | 28 +- .../chat/simplex/common/platform/Core.kt | 7 +- .../chat/simplex/common/platform/Modifier.kt | 2 + .../simplex/common/views/chat/ChatInfoView.kt | 2 +- .../simplex/common/views/chat/ChatView.kt | 681 +++++++++--------- .../simplex/common/views/chat/ScanCodeView.kt | 31 +- .../common/views/chat/VerifyCodeView.kt | 4 +- .../common/views/chat/group/GroupLinkView.kt | 2 +- .../views/chat/group/GroupMemberInfoView.kt | 4 +- .../common/views/chatlist/ChatHelpView.kt | 4 +- .../views/chatlist/ChatListNavLinkView.kt | 47 +- .../common/views/chatlist/ChatListView.kt | 263 +++++-- .../common/views/helpers/AlertManager.kt | 23 +- .../views/helpers/DefaultProgressBar.kt | 27 + .../common/views/helpers/DefaultTopAppBar.kt | 2 +- .../simplex/common/views/helpers/ModalView.kt | 16 +- .../common/views/helpers/SearchTextField.kt | 7 +- .../simplex/common/views/helpers/Utils.kt | 3 + .../views/newchat/AddContactLearnMore.kt | 7 +- .../common/views/newchat/AddContactView.kt | 186 ----- .../{ScanToConnectView.kt => ConnectPlan.kt} | 299 ++++---- .../views/newchat/ConnectViaLinkView.kt | 12 - .../newchat/ContactConnectionInfoView.kt | 49 +- .../common/views/newchat/CreateLinkView.kt | 118 --- .../common/views/newchat/NewChatSheet.kt | 18 +- .../common/views/newchat/NewChatView.kt | 436 +++++++++++ .../common/views/newchat/PasteToConnect.kt | 135 ---- .../simplex/common/views/newchat/QRCode.kt | 35 +- .../common/views/newchat/QRCodeScanner.kt | 10 +- .../views/onboarding/CreateSimpleXAddress.kt | 2 +- .../common/views/remote/ConnectDesktopView.kt | 13 +- .../common/views/remote/ConnectMobileView.kt | 6 +- .../views/usersettings/IncognitoView.kt | 2 + .../views/usersettings/ProtocolServerView.kt | 2 +- .../views/usersettings/ScanProtocolServer.kt | 28 +- .../views/usersettings/UserAddressView.kt | 2 +- .../commonMain/resources/MR/ar/strings.xml | 5 +- .../commonMain/resources/MR/base/strings.xml | 38 +- .../commonMain/resources/MR/bg/strings.xml | 9 +- .../commonMain/resources/MR/cs/strings.xml | 3 - .../commonMain/resources/MR/de/strings.xml | 13 +- .../commonMain/resources/MR/es/strings.xml | 3 - .../commonMain/resources/MR/fi/strings.xml | 3 - .../commonMain/resources/MR/fr/strings.xml | 11 +- .../commonMain/resources/MR/hu/strings.xml | 9 +- .../commonMain/resources/MR/it/strings.xml | 11 +- .../commonMain/resources/MR/iw/strings.xml | 3 - .../commonMain/resources/MR/ja/strings.xml | 3 - .../commonMain/resources/MR/ko/strings.xml | 3 - .../commonMain/resources/MR/lt/strings.xml | 3 - .../commonMain/resources/MR/nl/strings.xml | 11 +- .../commonMain/resources/MR/pl/strings.xml | 11 +- .../resources/MR/pt-rBR/strings.xml | 3 - .../commonMain/resources/MR/pt/strings.xml | 3 - .../commonMain/resources/MR/ru/strings.xml | 13 +- .../commonMain/resources/MR/th/strings.xml | 3 - .../commonMain/resources/MR/tr/strings.xml | 9 +- .../commonMain/resources/MR/uk/strings.xml | 9 +- .../resources/MR/zh-rCN/strings.xml | 11 +- .../resources/MR/zh-rTW/strings.xml | 3 - .../common/platform/Modifier.desktop.kt | 4 + .../chatlist/ChatListNavLinkView.desktop.kt | 14 +- .../common/views/helpers/Utils.desktop.kt | 35 + .../newchat/ConnectViaLinkView.desktop.kt | 11 - .../views/newchat/QRCodeScanner.desktop.kt | 7 +- .../newchat/ScanToConnectView.desktop.kt | 15 - 75 files changed, 1670 insertions(+), 1466 deletions(-) delete mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt delete mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt delete mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/{ScanToConnectView.kt => ConnectPlan.kt} (71%) delete mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt delete mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt delete mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt 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 ee43da5d4..a8c8b5c1b 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 @@ -1,6 +1,9 @@ package chat.simplex.app import android.app.Application +import android.content.Context +import androidx.compose.ui.platform.ClipboardManager +import chat.simplex.common.platform.Log import android.app.UiModeManager import android.os.* import androidx.lifecycle.* @@ -95,6 +98,13 @@ class SimplexApp: Application(), LifecycleEventObserver { } Lifecycle.Event.ON_RESUME -> { isAppOnForeground = true + /** + * When the app calls [ClipboardManager.shareText] and a user copies text in clipboard, Android denies + * access to clipboard because the app considered in background. + * This will ensure that the app will get the event on resume + * */ + val service = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + chatModel.clipboardHasText.value = service.hasPrimaryClip() if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) { SimplexService.showBackgroundServiceNoticeIfNeeded() } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 26ada2b7e..b103367fe 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -26,3 +26,5 @@ actual fun Modifier.desktopOnExternalDrag( ): Modifier = this actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this + +actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt index 30f5b8138..f8914c665 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt @@ -17,7 +17,8 @@ actual fun ChatListNavLinkLayout( dropdownMenuItems: (@Composable () -> Unit)?, showMenu: MutableState, stopped: Boolean, - selectedChat: State + selectedChat: State, + nextChatSelected: State, ) { var modifier = Modifier.fillMaxWidth() if (!stopped) modifier = modifier diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index d24429476..904f9a555 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -1,5 +1,7 @@ package chat.simplex.common.views.helpers +import android.content.ClipboardManager +import android.content.Context import android.content.res.Resources import android.graphics.* import android.graphics.Typeface @@ -12,6 +14,7 @@ import android.text.SpannedString import android.text.style.* import android.util.Base64 import android.view.WindowManager +import androidx.compose.runtime.* import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* @@ -24,7 +27,6 @@ import androidx.core.text.HtmlCompat import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import java.io.* import java.net.URI @@ -55,6 +57,19 @@ fun keepScreenOn(on: Boolean) { } } +@Composable +actual fun SetupClipboardListener() { + DisposableEffect(Unit) { + val service = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val listener = { chatModel.clipboardHasText.value = service.hasPrimaryClip() } + chatModel.clipboardHasText.value = service.hasPrimaryClip() + service.addPrimaryClipChangedListener(listener) + onDispose { + service.removePrimaryClipChangedListener(listener) + } + } +} + actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString { return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density) } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt deleted file mode 100644 index e5a7ae40a..000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt +++ /dev/null @@ -1,68 +0,0 @@ -package chat.simplex.common.views.newchat - -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.RemoteHostInfo -import chat.simplex.res.MR - -@Composable -actual fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { - // TODO this should close if remote host changes in model - val selection = remember { - mutableStateOf( - runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN) - ) - } - val tabTitles = ConnectViaLinkTab.values().map { - when (it) { - ConnectViaLinkTab.SCAN -> stringResource(MR.strings.scan_QR_code) - ConnectViaLinkTab.PASTE -> stringResource(MR.strings.paste_the_link_you_received) - } - } - Column( - Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween - ) { - Column(Modifier.weight(1f)) { - when (selection.value) { - ConnectViaLinkTab.SCAN -> { - ScanToConnectView(m, rh, close) - } - ConnectViaLinkTab.PASTE -> { - PasteToConnectView(m, rh, close) - } - } - } - TabRow( - selectedTabIndex = selection.value.ordinal, - backgroundColor = Color.Transparent, - contentColor = MaterialTheme.colors.primary, - ) { - tabTitles.forEachIndexed { index, it -> - Tab( - selected = selection.value.ordinal == index, - onClick = { - selection.value = ConnectViaLinkTab.values()[index] - m.controller.appPrefs.connectViaLinkTab.set(selection.value .name) - }, - text = { Text(it, fontSize = 13.sp) }, - icon = { - Icon( - if (ConnectViaLinkTab.SCAN.ordinal == index) painterResource(MR.images.ic_qr_code) else painterResource(MR.images.ic_article), - it - ) - }, - selectedContentColor = MaterialTheme.colors.primary, - unselectedContentColor = MaterialTheme.colors.secondary, - ) - } - } - } -} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index e7453ce20..362d793e8 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -2,16 +2,20 @@ package chat.simplex.common.views.newchat import android.Manifest import android.annotation.SuppressLint +import android.content.pm.PackageManager import android.util.Log import android.view.ViewGroup import androidx.camera.core.* import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.* +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import boofcv.abst.fiducial.QrCodeDetector @@ -20,18 +24,23 @@ import boofcv.android.ConvertCameraImage import boofcv.factory.fiducial.FactoryFiducial import boofcv.struct.image.GrayU8 import chat.simplex.common.platform.TAG +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR import com.google.accompanist.permissions.rememberPermissionState import com.google.common.util.concurrent.ListenableFuture +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource import java.util.concurrent.* // Adapted from learntodroid - https://gist.github.com/learntodroid/8f839be0b29d0378f843af70607bd7f5 @Composable -actual fun QRCodeScanner(onBarcode: (String) -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } +actual fun QRCodeScanner( + showQRCodeScanner: MutableState, + padding: PaddingValues, + onBarcode: (String) -> Unit +) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var preview by remember { mutableStateOf(null) } @@ -48,57 +57,102 @@ actual fun QRCodeScanner(onBarcode: (String) -> Unit) { } } - AndroidView( - factory = { AndroidViewContext -> - PreviewView(AndroidViewContext).apply { - this.scaleType = PreviewView.ScaleType.FILL_CENTER - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - } - }, - modifier = Modifier.clipToBounds() - ) { previewView -> - val cameraSelector: CameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() - cameraProviderFuture?.addListener({ - preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - val detector: QrCodeDetector = FactoryFiducial.qrcode(null, GrayU8::class.java) - fun getQR(imageProxy: ImageProxy) { - val currentTimeStamp = System.currentTimeMillis() - if (currentTimeStamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) { - detector.process(imageProxyToGrayU8(imageProxy)) - val found = detector.detections - val qr = found.firstOrNull() - if (qr != null) { - if (qr.message != contactLink) { - // Make sure link is new and not a repeat - contactLink = qr.message - onBarcode(contactLink) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + val modifier = Modifier + .padding(padding) + .clipToBounds() + .widthIn(max = 400.dp) + .aspectRatio(1f) + val showScanner = remember { showQRCodeScanner } + if (showScanner.value && cameraPermissionState.hasPermission) { + AndroidView( + factory = { AndroidViewContext -> + PreviewView(AndroidViewContext).apply { + this.scaleType = PreviewView.ScaleType.FILL_CENTER + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + }, + modifier = modifier + ) { previewView -> + val cameraSelector: CameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() + cameraProviderFuture?.addListener({ + preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val detector: QrCodeDetector = FactoryFiducial.qrcode(null, GrayU8::class.java) + fun getQR(imageProxy: ImageProxy) { + val currentTimeStamp = System.currentTimeMillis() + if (currentTimeStamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) { + detector.process(imageProxyToGrayU8(imageProxy)) + val found = detector.detections + val qr = found.firstOrNull() + if (qr != null) { + if (qr.message != contactLink) { + // Make sure link is new and not a repeat + contactLink = qr.message + onBarcode(contactLink) + } + } } + imageProxy.close() + } + + val imageAnalyzer = ImageAnalysis.Analyzer { proxy -> getQR(proxy) } + val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setImageQueueDepth(1) + .build() + .also { it.setAnalyzer(cameraExecutor, imageAnalyzer) } + try { + cameraProviderFuture?.get()?.unbindAll() + cameraProviderFuture?.get()?.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) + } catch (e: Exception) { + Log.d(TAG, "CameraPreview: ${e.localizedMessage}") + } + }, ContextCompat.getMainExecutor(context)) + } + } else { + val buttonColors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.9f), + contentColor = MaterialTheme.colors.primary, + disabledBackgroundColor = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.9f), + disabledContentColor = MaterialTheme.colors.primary, + ) + when { + !cameraPermissionState.hasPermission && !cameraPermissionState.permissionRequested && showScanner.value -> { + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } + } + !cameraPermissionState.hasPermission -> { + Button({ withBGApi { cameraPermissionState.launchPermissionRequest() } }, modifier = modifier, colors = buttonColors) { + Icon(painterResource(MR.images.ic_camera_enhance), null) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Text(stringResource(MR.strings.enable_camera_access)) + } + } + cameraPermissionState.hasPermission -> { + Button({ showQRCodeScanner.value = true }, modifier = modifier, colors = buttonColors) { + Icon(painterResource(MR.images.ic_qr_code), null) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Text(stringResource(MR.strings.tap_to_scan)) + } + } + !LocalContext.current.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) -> { + Button({ }, enabled = false, modifier = modifier, colors = buttonColors) { + Text(stringResource(MR.strings.camera_not_available)) } } - imageProxy.close() } - val imageAnalyzer = ImageAnalysis.Analyzer { proxy -> getQR(proxy) } - val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .setImageQueueDepth(1) - .build() - .also { it.setAnalyzer(cameraExecutor, imageAnalyzer) } - try { - cameraProviderFuture?.get()?.unbindAll() - cameraProviderFuture?.get()?.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) - } catch (e: Exception) { - Log.d(TAG, "CameraPreview: ${e.localizedMessage}") - } - }, ContextCompat.getMainExecutor(context)) + } } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt deleted file mode 100644 index f046f44be..000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt +++ /dev/null @@ -1,22 +0,0 @@ -package chat.simplex.common.views.newchat - -import android.Manifest -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.RemoteHostInfo -import com.google.accompanist.permissions.rememberPermissionState - -@Composable -actual fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - ConnectContactLayout( - chatModel = chatModel, - rh = rh, - incognitoPref = chatModel.controller.appPrefs.incognito, - close = close - ) -} 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 d457eb57a..950515f05 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 @@ -107,7 +107,7 @@ fun MainScreen() { val localUserCreated = chatModel.localUserCreated.value var showInitializationView by remember { mutableStateOf(false) } when { - chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView() + chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database)) showChatDatabaseError -> { chatModel.chatDbStatus.value?.let { DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) @@ -125,6 +125,7 @@ fun MainScreen() { } val scaffoldState = rememberScaffoldState() val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) } + SetupClipboardListener() if (appPlatform.isAndroid) { AndroidScreen(settingsState) } else { @@ -342,22 +343,6 @@ fun DesktopScreen(settingsState: SettingsViewState) { } } -@Composable -fun InitializationView() { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator( - Modifier - .padding(bottom = DEFAULT_PADDING) - .size(30.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 2.5.dp - ) - Text(stringResource(MR.strings.opening_database)) - } - } -} - @Composable private fun SwitchingUsersView() { if (remember { chatModel.switchingUsersAndHosts }.value) { 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 708bbb907..f51f6986f 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 @@ -97,8 +97,8 @@ object ChatModel { val showCallView = mutableStateOf(false) val switchingCall = mutableStateOf(false) - // currently showing QR code - val connReqInv = mutableStateOf(null as String?) + // currently showing invitation + val showingInvitation = mutableStateOf(null as ShowingInvitation?) var draft = mutableStateOf(null as ComposeState?) var draftChatId = mutableStateOf(null as String?) @@ -109,6 +109,8 @@ object ChatModel { val filesToDelete = mutableSetOf() val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } + val clipboardHasText = mutableStateOf(false) + var updatingChatsMutex: Mutex = Mutex() val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null @@ -561,15 +563,30 @@ object ChatModel { chats.add(index = 0, chat) } - fun dismissConnReqView(id: String) { - if (connReqInv.value == null) return - val info = getChat(id)?.chatInfo as? ChatInfo.ContactConnection ?: return - if (info.contactConnection.connReqInv == connReqInv.value) { - connReqInv.value = null - ModalManager.center.closeModals() + fun replaceConnReqView(id: String, withId: String) { + if (id == showingInvitation.value?.connId) { + showingInvitation.value = null + chatModel.chatItems.clear() + chatModel.chatId.value = withId + ModalManager.end.closeModals() } } + fun dismissConnReqView(id: String) { + if (id == showingInvitation.value?.connId) { + showingInvitation.value = null + chatModel.chatItems.clear() + chatModel.chatId.value = null + // Close NewChatView + ModalManager.center.closeModals() + ModalManager.end.closeModals() + } + } + + fun markShowingInvitationUsed() { + showingInvitation.value = showingInvitation.value?.copy(connChatUsed = true) + } + fun removeChat(rhId: Long?, id: String) { chats.removeAll { it.id == id && it.remoteHostId == rhId } } @@ -630,6 +647,12 @@ object ChatModel { fun connectedToRemote(): Boolean = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true } +data class ShowingInvitation( + val connId: String, + val connReq: String, + val connChatUsed: Boolean +) + enum class ChatType(val type: String) { Direct("@"), Group("#"), @@ -2664,6 +2687,8 @@ sealed class Format { is Phone -> linkStyle } + val isSimplexLink = this is SimplexLink + companion object { val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline) } 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 e3f565b77..619238f6d 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 @@ -9,7 +9,6 @@ import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* -import chat.simplex.common.views.newchat.ConnectViaLinkTab import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import com.charleskorn.kaml.Yaml @@ -135,7 +134,6 @@ class AppPreferences { val networkTCPKeepIntvl = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_INTVL, KeepAliveOpts.defaults.keepIntvl) val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt) val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false) - val connectViaLinkTab = mkStrPreference(SHARED_PREFS_CONNECT_VIA_LINK_TAB, ConnectViaLinkTab.SCAN.name) val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false) val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true) val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true) @@ -292,7 +290,6 @@ class AppPreferences { private const val SHARED_PREFS_NETWORK_TCP_KEEP_INTVL = "NetworkTCPKeepIntvl" private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt" private const val SHARED_PREFS_INCOGNITO = "Incognito" - private const val SHARED_PREFS_CONNECT_VIA_LINK_TAB = "ConnectViaLinkTab" private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown" private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice" private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert" @@ -890,19 +887,19 @@ object ChatController { - suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair? { + suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiAddContact: no current user") - return null + return null to null } val r = sendCmd(rh, CC.APIAddContact(userId, incognito)) return when (r) { - is CR.Invitation -> r.connReqInvitation to r.connection + is CR.Invitation -> (r.connReqInvitation to r.connection) to null else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) + return null to { apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) } } - null + null to null } } } @@ -981,6 +978,13 @@ object ChatController { } } + suspend fun deleteChat(chat: Chat, notify: Boolean? = null) { + val cInfo = chat.chatInfo + if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, notify = notify)) { + chatModel.removeChat(chat.remoteHostId, cInfo.id) + } + } + suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean { val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify)) when { @@ -1568,7 +1572,7 @@ object ChatController { chatModel.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { - chatModel.dismissConnReqView(conn.id) + chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") chatModel.removeChat(rhId, conn.id) } } @@ -1582,7 +1586,7 @@ object ChatController { chatModel.updateContact(rhId, r.contact) val conn = r.contact.activeConn if (conn != null) { - chatModel.dismissConnReqView(conn.id) + chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") chatModel.removeChat(rhId, conn.id) } } @@ -1717,7 +1721,7 @@ object ChatController { chatModel.updateGroup(rhId, r.groupInfo) val conn = r.hostContact?.activeConn if (conn != null) { - chatModel.dismissConnReqView(conn.id) + chatModel.replaceConnReqView(conn.id, "#${r.groupInfo.groupId}") chatModel.removeChat(rhId, conn.id) } } @@ -1727,7 +1731,7 @@ object ChatController { chatModel.updateGroup(rhId, r.groupInfo) val hostConn = r.hostMember.activeConn if (hostConn != null) { - chatModel.dismissConnReqView(hostConn.id) + chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") chatModel.removeChat(rhId, hostConn.id) } } 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 7d097efb7..52ff269f5 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 @@ -73,11 +73,14 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } else { val savedOnboardingStage = appPreferences.onboardingStage.get() - appPreferences.onboardingStage.set(if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { + val newStage = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { OnboardingStage.Step3_CreateSimpleXAddress } else { savedOnboardingStage - }) + } + if (appPreferences.onboardingStage.get() != newStage) { + appPreferences.onboardingStage.set(newStage) + } if (appPreferences.onboardingStage.get() == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { chatModel.setDeliveryReceipts.value = true } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index 1141ab21a..4a1002774 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -23,3 +23,5 @@ expect fun Modifier.desktopOnExternalDrag( ): Modifier expect fun Modifier.onRightClick(action: () -> Unit): Modifier + +expect fun Modifier.desktopPointerHoverIconHand(): Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 694ec2ba1..d87fce5fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -355,7 +355,7 @@ fun ChatInfoLayout( if (contact.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - SimpleXLinkQRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(contact.contactLink) val clipboard = LocalClipboardManager.current ShareAddressButton { clipboard.shareText(simplexChatLink(contact.contactLink)) } SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 0e2a7c168..69a1b50e2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -33,6 +33,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.platform.AudioPlayer +import chat.simplex.common.views.newchat.ContactConnectionInfoView import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -114,343 +115,371 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } val clipboard = LocalClipboardManager.current - - ChatLayout( - chat, - unreadCount, - composeState, - composeView = { - Column( - Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if ( - chat.chatInfo is ChatInfo.Direct - && !chat.chatInfo.contact.ready - && chat.chatInfo.contact.active - && !chat.chatInfo.contact.nextSendGrpInv - ) { - Text( - generalGetString(MR.strings.contact_connection_pending), - Modifier.padding(top = 4.dp), - fontSize = 14.sp, - color = MaterialTheme.colors.secondary - ) - } - ComposeView( - chatModel, chat, composeState, attachmentOption, - showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } - ) - } - }, - attachmentOption, - attachmentBottomSheetState, - chatModel.chatItems, - searchText, - useLinkPreviews = useLinkPreviews, - linkMode = chatModel.simplexLinkMode.value, - back = { - hideKeyboard(view) - AudioPlayer.stop() - chatModel.chatId.value = null - chatModel.groupMembers.clear() - }, - info = { - if (ModalManager.end.hasModalsOpen()) { - ModalManager.end.closeModals() - return@ChatLayout - } - hideKeyboard(view) - withApi { - // The idea is to preload information before showing a modal because large groups can take time to load all members - var preloadedContactInfo: Pair? = null - var preloadedCode: String? = null - var preloadedLink: Pair? = null - if (chat.chatInfo is ChatInfo.Direct) { - preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) - preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second - } else if (chat.chatInfo is ChatInfo.Group) { - setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) - preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) - } - ModalManager.end.showModalCloseable(true) { close -> - val chat = remember { activeChat }.value - if (chat?.chatInfo is ChatInfo.Direct) { - var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } - var code: String? by remember { mutableStateOf(preloadedCode) } - KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) { - contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) - preloadedContactInfo = contactInfo - code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second - preloadedCode = code + when (chat.chatInfo) { + is ChatInfo.Direct, is ChatInfo.Group -> { + ChatLayout( + chat, + unreadCount, + composeState, + composeView = { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if ( + chat.chatInfo is ChatInfo.Direct + && !chat.chatInfo.contact.ready + && chat.chatInfo.contact.active + && !chat.chatInfo.contact.nextSendGrpInv + ) { + Text( + generalGetString(MR.strings.contact_connection_pending), + Modifier.padding(top = 4.dp), + fontSize = 14.sp, + color = MaterialTheme.colors.secondary + ) } - ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) - } else if (chat?.chatInfo is ChatInfo.Group) { - var link: Pair? by remember(chat.id) { mutableStateOf(preloadedLink) } - KeyChangeEffect(chat.id) { - setGroupMembers(chatRh, (chat.chatInfo as ChatInfo.Group).groupInfo, chatModel) - link = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) - preloadedLink = link + ComposeView( + chatModel, chat, composeState, attachmentOption, + showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } } + ) + } + }, + attachmentOption, + attachmentBottomSheetState, + chatModel.chatItems, + searchText, + useLinkPreviews = useLinkPreviews, + linkMode = chatModel.simplexLinkMode.value, + back = { + hideKeyboard(view) + AudioPlayer.stop() + chatModel.chatId.value = null + chatModel.groupMembers.clear() + }, + info = { + if (ModalManager.end.hasModalsOpen()) { + ModalManager.end.closeModals() + return@ChatLayout + } + hideKeyboard(view) + withApi { + // The idea is to preload information before showing a modal because large groups can take time to load all members + var preloadedContactInfo: Pair? = null + var preloadedCode: String? = null + var preloadedLink: Pair? = null + if (chat.chatInfo is ChatInfo.Direct) { + preloadedContactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) + preloadedCode = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second + } else if (chat.chatInfo is ChatInfo.Group) { + setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) + preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) } - GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, { - link = it - preloadedLink = it - }, close) - } - } - } - }, - showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> - hideKeyboard(view) - withApi { - val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) - val stats = r?.second - val (_, code) = if (member.memberActive) { - val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) - member to memCode?.second - } else { - member to null - } - setGroupMembers(chatRh, groupInfo, chatModel) - ModalManager.end.closeModals() - ModalManager.end.showModalCloseable(true) { close -> - remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) - } - } - } - }, - loadPrevMessages = { - if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout - val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) - val firstId = chatModel.chatItems.firstOrNull()?.id - if (c != null && firstId != null) { - withApi { - Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}") - apiLoadPrevMessages(c, chatModel, firstId, searchText.value) - Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}") - } - } - }, - deleteMessage = { itemId, mode -> - withApi { - val cInfo = chat.chatInfo - val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId } - val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo) - val groupInfo = toModerate?.first - val groupMember = toModerate?.second - val deletedChatItem: ChatItem? - val toChatItem: ChatItem? - if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { - val r = chatModel.controller.apiDeleteMemberChatItem( - chatRh, - groupId = groupInfo.groupId, - groupMemberId = groupMember.groupMemberId, - itemId = itemId - ) - deletedChatItem = r?.first - toChatItem = r?.second - } else { - val r = chatModel.controller.apiDeleteChatItem( - chatRh, - type = cInfo.chatType, - id = cInfo.apiId, - itemId = itemId, - mode = mode - ) - deletedChatItem = r?.deletedChatItem?.chatItem - toChatItem = r?.toChatItem?.chatItem - } - if (toChatItem == null && deletedChatItem != null) { - chatModel.removeChatItem(chatRh, cInfo, deletedChatItem) - } else if (toChatItem != null) { - chatModel.upsertChatItem(chatRh, cInfo, toChatItem) - } - } - }, - deleteMessages = { itemIds -> - if (itemIds.isNotEmpty()) { - val chatInfo = chat.chatInfo - withBGApi { - val deletedItems: ArrayList = arrayListOf() - for (itemId in itemIds) { - val di = chatModel.controller.apiDeleteChatItem( - chatRh, chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal - )?.deletedChatItem?.chatItem - if (di != null) { - deletedItems.add(di) + ModalManager.end.showModalCloseable(true) { close -> + val chat = remember { activeChat }.value + if (chat?.chatInfo is ChatInfo.Direct) { + var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } + var code: String? by remember { mutableStateOf(preloadedCode) } + KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) { + contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) + preloadedContactInfo = contactInfo + code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second + preloadedCode = code + } + ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) + } else if (chat?.chatInfo is ChatInfo.Group) { + var link: Pair? by remember(chat.id) { mutableStateOf(preloadedLink) } + KeyChangeEffect(chat.id) { + setGroupMembers(chatRh, (chat.chatInfo as ChatInfo.Group).groupInfo, chatModel) + link = chatModel.controller.apiGetGroupLink(chatRh, chat.chatInfo.groupInfo.groupId) + preloadedLink = link + } + GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, { + link = it + preloadedLink = it + }, close) + } } } - for (di in deletedItems) { - chatModel.removeChatItem(chatRh, chatInfo, di) + }, + showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> + hideKeyboard(view) + withApi { + val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) + val stats = r?.second + val (_, code) = if (member.memberActive) { + val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) + member to memCode?.second + } else { + member to null + } + setGroupMembers(chatRh, groupInfo, chatModel) + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { close -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> + GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) + } + } } - } - } - }, - receiveFile = { fileId, encrypted -> - withApi { chatModel.controller.receiveFile(chatRh, user, fileId, encrypted) } - }, - cancelFile = { fileId -> - withApi { chatModel.controller.cancelFile(chatRh, user, fileId) } - }, - joinGroup = { groupId, onComplete -> - withApi { - chatModel.controller.apiJoinGroup(chatRh, groupId) - onComplete.invoke() - } - }, - startCall = out@ { media -> - withBGApi { - val cInfo = chat.chatInfo - if (cInfo is ChatInfo.Direct) { - chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) - chatModel.showCallView.value = true - chatModel.callCommand.add(WCallCommand.Capabilities(media)) - } - } - }, - endCall = { - val call = chatModel.activeCall.value - if (call != null) withApi { chatModel.callManager.endCall(call) } - }, - acceptCall = { contact -> - hideKeyboard(view) - val invitation = chatModel.callInvitations.remove(contact.id) - if (invitation == null) { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended)) - } else { - chatModel.callManager.acceptIncomingCall(invitation = invitation) - } - }, - acceptFeature = { contact, feature, param -> - withApi { - chatModel.controller.allowFeatureToContact(chatRh, contact, feature, param) - } - }, - openDirectChat = { contactId -> - withApi { - openDirectChat(chatRh, contactId, chatModel) - } - }, - updateContactStats = { contact -> - withApi { - val r = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) - if (r != null) { - val contactStats = r.first - if (contactStats != null) - chatModel.updateContactConnectionStats(chatRh, contact, contactStats) - } - } - }, - updateMemberStats = { groupInfo, member -> - withApi { - val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) - if (r != null) { - val memStats = r.second - if (memStats != null) { - chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) + }, + loadPrevMessages = { + if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout + val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) + val firstId = chatModel.chatItems.firstOrNull()?.id + if (c != null && firstId != null) { + withApi { + Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}") + apiLoadPrevMessages(c, chatModel, firstId, searchText.value) + Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}") + } } - } - } - }, - syncContactConnection = { contact -> - withApi { - val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) - if (cStats != null) { - chatModel.updateContactConnectionStats(chatRh, contact, cStats) - } - } - }, - syncMemberConnection = { groupInfo, member -> - withApi { - val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false) - if (r != null) { - chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) - } - } - }, - findModelChat = { chatId -> - chatModel.getChat(chatId) - }, - findModelMember = { memberId -> - chatModel.groupMembers.find { it.id == memberId } - }, - setReaction = { cInfo, cItem, add, reaction -> - withApi { - val updatedCI = chatModel.controller.apiChatItemReaction( - rh = chatRh, - type = cInfo.chatType, - id = cInfo.apiId, - itemId = cItem.id, - add = add, - reaction = reaction - ) - if (updatedCI != null) { - chatModel.updateChatItem(cInfo, updatedCI) - } - } - }, - showItemDetails = { cInfo, cItem -> - withApi { - val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) - if (ciInfo != null) { - if (chat.chatInfo is ChatInfo.Group) { - setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) + }, + deleteMessage = { itemId, mode -> + withApi { + val cInfo = chat.chatInfo + val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId } + val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo) + val groupInfo = toModerate?.first + val groupMember = toModerate?.second + val deletedChatItem: ChatItem? + val toChatItem: ChatItem? + if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) { + val r = chatModel.controller.apiDeleteMemberChatItem( + chatRh, + groupId = groupInfo.groupId, + groupMemberId = groupMember.groupMemberId, + itemId = itemId + ) + deletedChatItem = r?.first + toChatItem = r?.second + } else { + val r = chatModel.controller.apiDeleteChatItem( + chatRh, + type = cInfo.chatType, + id = cInfo.apiId, + itemId = itemId, + mode = mode + ) + deletedChatItem = r?.deletedChatItem?.chatItem + toChatItem = r?.toChatItem?.chatItem + } + if (toChatItem == null && deletedChatItem != null) { + chatModel.removeChatItem(chatRh, cInfo, deletedChatItem) + } else if (toChatItem != null) { + chatModel.upsertChatItem(chatRh, cInfo, toChatItem) + } } - ModalManager.end.closeModals() - ModalManager.end.showModal(endButtons = { ShareButton { - clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) - } }) { - ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + }, + deleteMessages = { itemIds -> + if (itemIds.isNotEmpty()) { + val chatInfo = chat.chatInfo + withBGApi { + val deletedItems: ArrayList = arrayListOf() + for (itemId in itemIds) { + val di = chatModel.controller.apiDeleteChatItem( + chatRh, chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal + )?.deletedChatItem?.chatItem + if (di != null) { + deletedItems.add(di) + } + } + for (di in deletedItems) { + chatModel.removeChatItem(chatRh, chatInfo, di) + } + } } - } - } - }, - addMembers = { groupInfo -> - hideKeyboard(view) - withApi { - setGroupMembers(chatRh, groupInfo, chatModel) + }, + receiveFile = { fileId, encrypted -> + withApi { chatModel.controller.receiveFile(chatRh, user, fileId, encrypted) } + }, + cancelFile = { fileId -> + withApi { chatModel.controller.cancelFile(chatRh, user, fileId) } + }, + joinGroup = { groupId, onComplete -> + withApi { + chatModel.controller.apiJoinGroup(chatRh, groupId) + onComplete.invoke() + } + }, + startCall = out@{ media -> + withBGApi { + val cInfo = chat.chatInfo + if (cInfo is ChatInfo.Direct) { + chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) + chatModel.showCallView.value = true + chatModel.callCommand.add(WCallCommand.Capabilities(media)) + } + } + }, + endCall = { + val call = chatModel.activeCall.value + if (call != null) withApi { chatModel.callManager.endCall(call) } + }, + acceptCall = { contact -> + hideKeyboard(view) + val invitation = chatModel.callInvitations.remove(contact.id) + if (invitation == null) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended)) + } else { + chatModel.callManager.acceptIncomingCall(invitation = invitation) + } + }, + acceptFeature = { contact, feature, param -> + withApi { + chatModel.controller.allowFeatureToContact(chatRh, contact, feature, param) + } + }, + openDirectChat = { contactId -> + withApi { + openDirectChat(chatRh, contactId, chatModel) + } + }, + updateContactStats = { contact -> + withApi { + val r = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) + if (r != null) { + val contactStats = r.first + if (contactStats != null) + chatModel.updateContactConnectionStats(chatRh, contact, contactStats) + } + } + }, + updateMemberStats = { groupInfo, member -> + withApi { + val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) + if (r != null) { + val memStats = r.second + if (memStats != null) { + chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) + } + } + } + }, + syncContactConnection = { contact -> + withApi { + val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) + if (cStats != null) { + chatModel.updateContactConnectionStats(chatRh, contact, cStats) + } + } + }, + syncMemberConnection = { groupInfo, member -> + withApi { + val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false) + if (r != null) { + chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) + } + } + }, + findModelChat = { chatId -> + chatModel.getChat(chatId) + }, + findModelMember = { memberId -> + chatModel.groupMembers.find { it.id == memberId } + }, + setReaction = { cInfo, cItem, add, reaction -> + withApi { + val updatedCI = chatModel.controller.apiChatItemReaction( + rh = chatRh, + type = cInfo.chatType, + id = cInfo.apiId, + itemId = cItem.id, + add = add, + reaction = reaction + ) + if (updatedCI != null) { + chatModel.updateChatItem(cInfo, updatedCI) + } + } + }, + showItemDetails = { cInfo, cItem -> + withApi { + val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) + if (ciInfo != null) { + if (chat.chatInfo is ChatInfo.Group) { + setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) + } + ModalManager.end.closeModals() + ModalManager.end.showModal(endButtons = { + ShareButton { + clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) + } + }) { + ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + } + } + } + }, + addMembers = { groupInfo -> + hideKeyboard(view) + withApi { + setGroupMembers(chatRh, groupInfo, chatModel) + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { close -> + AddGroupMembersView(chatRh, groupInfo, false, chatModel, close) + } + } + }, + openGroupLink = { groupInfo -> + hideKeyboard(view) + withApi { + val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId) + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { + GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + } + } + }, + markRead = { range, unreadCountAfter -> + chatModel.markChatItemsRead(chat, range, unreadCountAfter) + ntfManager.cancelNotificationsForChat(chat.id) + withBGApi { + chatModel.controller.apiChatRead( + chatRh, + chat.chatInfo.chatType, + chat.chatInfo.apiId, + range + ) + } + }, + changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) }, + onSearchValueChanged = { value -> + if (searchText.value == value) return@ChatLayout + if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout + val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) ?: return@ChatLayout + withApi { + apiFindMessages(c, chatModel, value) + searchText.value = value + } + }, + onComposed, + developerTools = chatModel.controller.appPrefs.developerTools.get(), + ) + } + is ChatInfo.ContactConnection -> { + val close = { chatModel.chatId.value = null } + ModalView(close, showClose = appPlatform.isAndroid, content = { + ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) + }) + LaunchedEffect(chat.id) { + onComposed(chat.id) ModalManager.end.closeModals() - ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(chatRh, groupInfo, false, chatModel, close) - } + chatModel.chatItems.clear() } - }, - openGroupLink = { groupInfo -> - hideKeyboard(view) - withApi { - val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId) + } + is ChatInfo.InvalidJSON -> { + val close = { chatModel.chatId.value = null } + ModalView(close, showClose = appPlatform.isAndroid, content = { + InvalidJSONView(chat.chatInfo.json) + }) + LaunchedEffect(chat.id) { + onComposed(chat.id) ModalManager.end.closeModals() - ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) - } + chatModel.chatItems.clear() } - }, - markRead = { range, unreadCountAfter -> - chatModel.markChatItemsRead(chat, range, unreadCountAfter) - ntfManager.cancelNotificationsForChat(chat.id) - withBGApi { - chatModel.controller.apiChatRead( - chatRh, - chat.chatInfo.chatType, - chat.chatInfo.apiId, - range - ) - } - }, - changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) }, - onSearchValueChanged = { value -> - if (searchText.value == value) return@ChatLayout - if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout - val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) ?: return@ChatLayout - withApi { - apiFindMessages(c, chatModel, value) - searchText.value = value - } - }, - onComposed, - developerTools = chatModel.controller.appPrefs.developerTools.get(), - ) + } + else -> {} + } } } @@ -733,7 +762,7 @@ fun ChatInfoToolbar( } @Composable -fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) { +fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index bb479d8eb..73017c3d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -13,29 +13,20 @@ import dev.icerock.moko.resources.compose.stringResource @Composable fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { Column( - Modifier - .fillMaxSize() - .padding(horizontal = DEFAULT_PADDING) + Modifier.fillMaxSize() ) { - AppBarTitle(stringResource(MR.strings.scan_code), withPadding = false) - Box( - Modifier - .fillMaxWidth() - .aspectRatio(ratio = 1F) - .padding(bottom = DEFAULT_PADDING) - ) { - QRCodeScanner { text -> - verifyCode(text) { - if (it) { - close() - } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.incorrect_code) - ) - } + AppBarTitle(stringResource(MR.strings.scan_code)) + QRCodeScanner { text -> + verifyCode(text) { + if (it) { + close() + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.incorrect_code) + ) } } } - Text(stringResource(MR.strings.scan_code_from_contacts_app)) + Text(stringResource(MR.strings.scan_code_from_contacts_app), Modifier.padding(horizontal = DEFAULT_PADDING)) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index e1840dd88..57c469adf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -74,9 +74,7 @@ private fun VerifyCodeLayout( } } - SectionView { - QRCode(connectionCode, Modifier.aspectRatio(1f)) - } + QRCode(connectionCode, padding = PaddingValues(vertical = DEFAULT_PADDING_HALF)) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.weight(2f)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 02ce90243..dfb679c08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -153,7 +153,7 @@ fun GroupLinkLayout( } initialLaunch = false } - SimpleXLinkQRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) + SimpleXLinkQRCode(groupLink) Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 00b236c7d..c6654dc81 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -289,7 +289,7 @@ fun GroupMemberInfoLayout( if (member.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - SimpleXLinkQRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(member.contactLink) val clipboard = LocalClipboardManager.current ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) } if (contactId != null) { @@ -506,7 +506,7 @@ fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) { try { val uri = URI(connReqUri) withApi { - planAndConnect(chatModel, rhId, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) + planAndConnect(rhId, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt index 866ad04b0..f36978cd3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt @@ -50,8 +50,8 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { ) Text(stringResource(MR.strings.above_then_preposition_continuation)) } - Text(annotatedStringResource(MR.strings.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp) - Text(annotatedStringResource(MR.strings.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp) + Text(annotatedStringResource(MR.strings.add_contact_button_to_create_link_or_connect_via_link), lineHeight = 22.sp) + Text(annotatedStringResource(MR.strings.create_group_button_to_create_new_group), lineHeight = 22.sp) } Column( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 8d5446aa5..84ad14ed7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -24,17 +24,15 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.group.deleteGroupDialog import chat.simplex.common.views.chat.group.leaveGroupDialog -import chat.simplex.common.views.chat.item.InvalidJSONView import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.datetime.Clock -import java.net.URI @Composable -fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { +fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { val showMenu = remember { mutableStateOf(false) } val showMarkRead = remember(chat.chatStats.unreadCount, chat.chatStats.unreadChat) { chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat @@ -45,7 +43,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { showMenu.value = false delay(500L) } - val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } } + val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } } val showChatPreviews = chatModel.showChatPreviews.value val inProgress = remember { mutableStateOf(false) } var progressByTimeout by rememberSaveable { mutableStateOf(false) } @@ -75,7 +73,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { }, showMenu, stopped, - selectedChat + selectedChat, + nextChatSelected, ) } is ChatInfo.Group -> @@ -93,7 +92,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { }, showMenu, stopped, - selectedChat + selectedChat, + nextChatSelected, ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( @@ -110,7 +110,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { }, showMenu, stopped, - selectedChat + selectedChat, + nextChatSelected, ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( @@ -120,11 +121,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } }, click = { - ModalManager.center.closeModals() - ModalManager.end.closeModals() - ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> - ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) - } + chatModel.chatId.value = chat.id }, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { @@ -133,7 +130,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { }, showMenu, stopped, - selectedChat + selectedChat, + nextChatSelected, ) is ChatInfo.InvalidJSON -> ChatListNavLinkLayout( @@ -143,13 +141,13 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } }, click = { - ModalManager.end.closeModals() - ModalManager.center.showModal(true) { InvalidJSONView(chat.chatInfo.json) } + chatModel.chatId.value = chat.id }, dropdownMenuItems = null, showMenu, stopped, - selectedChat + selectedChat, + nextChatSelected, ) } } @@ -476,10 +474,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection painterResource(MR.images.ic_delete), onClick = { deleteContactConnectionAlert(rhId, chatInfo.contactConnection, chatModel) { - if (chatModel.chatId.value == null) { - ModalManager.center.closeModals() - ModalManager.end.closeModals() - } + chatModel.dismissConnReqView(chatInfo.contactConnection.id) } showMenu.value = false }, @@ -804,7 +799,8 @@ expect fun ChatListNavLinkLayout( dropdownMenuItems: (@Composable () -> Unit)?, showMenu: MutableState, stopped: Boolean, - selectedChat: State + selectedChat: State, + nextChatSelected: State, ) @Preview/*( @@ -846,7 +842,8 @@ fun PreviewChatListNavLinkDirect() { dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, stopped = false, - selectedChat = remember { mutableStateOf(false) } + selectedChat = remember { mutableStateOf(false) }, + nextChatSelected = remember { mutableStateOf(false) } ) } } @@ -890,7 +887,8 @@ fun PreviewChatListNavLinkGroup() { dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, stopped = false, - selectedChat = remember { mutableStateOf(false) } + selectedChat = remember { mutableStateOf(false) }, + nextChatSelected = remember { mutableStateOf(false) } ) } } @@ -911,7 +909,8 @@ fun PreviewChatListNavLinkContactRequest() { dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, stopped = false, - selectedChat = remember { mutableStateOf(false) } + selectedChat = remember { mutableStateOf(false) }, + nextChatSelected = remember { mutableStateOf(false) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index cf12727d7..4280f5136 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 @@ -10,11 +10,15 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.platform.* +import androidx.compose.ui.text.TextRange import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.* import chat.simplex.common.SettingsViewState import chat.simplex.common.model.* @@ -29,6 +33,7 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import java.net.URI @Composable @@ -60,10 +65,10 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } } val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp - var searchInList by rememberSaveable { mutableStateOf("") } + val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } val scope = rememberCoroutineScope() val (userPickerState, scaffoldState ) = settingsState - Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } }, + Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(searchText, scaffoldState.drawerState, userPickerState, stopped)} }, scaffoldState = scaffoldState, drawerContent = { tryOrShowError("Settings", error = { ErrorSettingsView() }) { @@ -73,7 +78,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), drawerGesturesEnabled = appPlatform.isAndroid, floatingActionButton = { - if (searchInList.isEmpty() && !chatModel.desktopNoUserNoRemote) { + if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) { FloatingActionButton( onClick = { if (!stopped) { @@ -101,19 +106,20 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf .fillMaxSize() ) { if (chatModel.chats.isNotEmpty()) { - ChatList(chatModel, search = searchInList) + ChatList(chatModel, searchText = searchText) } else if (!chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { Box(Modifier.fillMaxSize()) { - if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) { + if (!stopped && !newChatSheetState.collectAsState().value.isVisible() && chatModel.chatRunning.value == true) { OnboardingButtons(showNewChatSheet) } - Text(stringResource(MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + Text(stringResource( + if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) } } } } } - if (searchInList.isEmpty()) { + if (searchText.value.text.isEmpty()) { DesktopActiveCallOverlayLayout(newChatSheetState) // TODO disable this button and sheet for the duration of the switch tryOrShowError("NewChatSheet", error = {}) { @@ -168,20 +174,8 @@ private fun ConnectButton(text: String, onClick: () -> Unit) { } @Composable -private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { - var showSearch by rememberSaveable { mutableStateOf(false) } - val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false } - if (showSearch) { - BackHandler(onBack = hideSearchOnBack) - } +private fun ChatListToolbar(searchInList: State, drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean) { val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() - if (chatModel.chats.size > 0) { - barButtons.add { - IconButton({ showSearch = true }) { - Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) - } - } - } if (stopped) { barButtons.add { IconButton(onClick = { @@ -201,9 +195,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user val scope = rememberCoroutineScope() DefaultTopAppBar( navigationButton = { - if (showSearch) { - NavigationButtonBack(hideSearchOnBack) - } else if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { + 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 } } } @@ -227,13 +219,18 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user fontWeight = FontWeight.SemiBold, ) if (chatModel.chats.size > 0) { - ToggleFilterButton() + val enabled = remember { derivedStateOf { searchInList.value.text.isEmpty() } } + if (enabled.value) { + ToggleFilterEnabledButton() + } else { + ToggleFilterDisabledButton() + } } } }, onTitleClick = null, - showSearch = showSearch, - onSearchValueChanged = onSearchValueChanged, + showSearch = false, + onSearchValueChanged = {}, buttons = barButtons ) Divider(Modifier.padding(top = AppBarHeight)) @@ -246,7 +243,8 @@ fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> U Box { ProfileImage( image = image, - size = 37.dp + size = 37.dp, + color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f) ) if (!allRead) { unreadBadge() @@ -281,7 +279,7 @@ private fun BoxScope.unreadBadge(text: String? = "") { } @Composable -private fun ToggleFilterButton() { +private fun ToggleFilterEnabledButton() { val pref = remember { ChatController.appPrefs.showUnreadAndFavorites } IconButton(onClick = { pref.set(!pref.get()) }) { Icon( @@ -290,7 +288,7 @@ private fun ToggleFilterButton() { tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.primary, modifier = Modifier .padding(3.dp) - .background(color = if (pref.state.value) MaterialTheme.colors.primary else MaterialTheme.colors.background, shape = RoundedCornerShape(50)) + .background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) .border(width = 1.dp, color = MaterialTheme.colors.primary, shape = RoundedCornerShape(50)) .padding(3.dp) .size(16.dp) @@ -298,6 +296,22 @@ private fun ToggleFilterButton() { } } +@Composable +private fun ToggleFilterDisabledButton() { + IconButton({}, enabled = false) { + Icon( + painterResource(MR.images.ic_filter_list), + null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .padding(3.dp) + .border(width = 1.dp, color = MaterialTheme.colors.secondary, shape = RoundedCornerShape(50)) + .padding(3.dp) + .size(16.dp) + ) + } +} + @Composable expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) @@ -307,11 +321,115 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { chatModel.appOpenUrl.value = rhId to uri } else { withApi { - planAndConnect(chatModel, rhId, uri, incognito = null, close = null) + planAndConnect(rhId, uri, incognito = null, close = null) } } } +@Composable +private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState) { + Row(verticalAlignment = Alignment.CenterVertically) { + val focusRequester = remember { FocusRequester() } + var focused by remember { mutableStateOf(false) } + Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF), tint = MaterialTheme.colors.secondary) + SearchTextField( + Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), + placeholder = stringResource(MR.strings.search_or_paste_simplex_link), + alwaysVisible = true, + searchText = searchText, + enabled = !remember { searchShowingSimplexLink }.value, + trailingContent = null, + ) { + searchText.value = searchText.value.copy(it) + } + val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } } + if (hasText.value) { + val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() } + BackHandler(onBack = hideSearchOnBack) + KeyChangeEffect(chatModel.currentRemoteHost.value) { + hideSearchOnBack() + } + } else { + Row { + val padding = if (appPlatform.isDesktop) 0.dp else 7.dp + val clipboard = LocalClipboardManager.current + val clipboardHasText = remember(focused) { chatModel.clipboardHasText }.value + if (clipboardHasText) { + IconButton( + onClick = { searchText.value = searchText.value.copy(clipboard.getText()?.text ?: return@IconButton) }, + Modifier.size(30.dp).desktopPointerHoverIconHand() + ) { + Icon(painterResource(MR.images.ic_article), null, tint = MaterialTheme.colors.secondary) + } + } + Spacer(Modifier.width(padding)) + IconButton( + onClick = { + val fixedRhId = chatModel.currentRemoteHost.value + ModalManager.center.closeModals() + ModalManager.center.showModalCloseable { close -> + NewChatView(fixedRhId, selection = NewChatOption.CONNECT, showQRCodeScanner = true, close = close) + } + }, + Modifier.size(30.dp).desktopPointerHoverIconHand() + ) { + Icon(painterResource(MR.images.ic_qr_code), null, tint = MaterialTheme.colors.secondary) + } + Spacer(Modifier.width(padding)) + } + } + val focusManager = LocalFocusManager.current + val keyboardState = getKeyboardState() + LaunchedEffect(keyboardState.value) { + if (keyboardState.value == KeyboardState.Closed && focused) { + focusManager.clearFocus() + } + } + val view = LocalMultiplatformView() + LaunchedEffect(Unit) { + snapshotFlow { searchText.value.text } + .distinctUntilChanged() + .collect { + val link = strHasSingleSimplexLink(it.trim()) + if (link != null) { + // if SimpleX link is pasted, show connection dialogue + hideKeyboard(view) + if (link.format is Format.SimplexLink) { + val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts) + searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero) + } + searchShowingSimplexLink.value = true + searchChatFilteredBySimplexLink.value = null + connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() } + } else if (!searchShowingSimplexLink.value || it.isEmpty()) { + if (it.isNotEmpty()) { + // if some other text is pasted, enter search mode + focusRequester.requestFocus() + } else if (listState.layoutInfo.totalItemsCount > 0) { + listState.scrollToItem(0) + } + searchShowingSimplexLink.value = false + searchChatFilteredBySimplexLink.value = null + } + } + } + } +} + +private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState, cleanup: (() -> Unit)?) { + withBGApi { + planAndConnect( + chatModel.remoteHostId(), + URI.create(link), + incognito = null, + filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id }, + filterKnownGroup = { searchChatFilteredBySimplexLink.value = it.id }, + close = null, + cleanup = cleanup, + ) + } +} + @Composable private fun ErrorSettingsView() { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -322,7 +440,7 @@ private fun ErrorSettingsView() { private var lazyListState = 0 to 0 @Composable -private fun ChatList(chatModel: ChatModel, search: String) { +private fun ChatList(chatModel: ChatModel, searchText: MutableState) { val listState = rememberLazyListState(lazyListState.first, lazyListState.second) DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } @@ -332,13 +450,35 @@ private fun ChatList(chatModel: ChatModel, search: String) { // In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side // which is related to [derivedStateOf]. Using safe alternative instead // val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } } - val chats = filteredChats(showUnreadAndFavorites, search, allChats.toList()) + val searchShowingSimplexLink = remember { mutableStateOf(false) } + val searchChatFilteredBySimplexLink = remember { mutableStateOf(null) } + val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.toList()) LazyColumn( - modifier = Modifier.fillMaxWidth(), + Modifier.fillMaxWidth(), listState ) { - items(chats) { chat -> - ChatListNavLinkView(chat, chatModel) + stickyHeader { + Column( + Modifier + .offset { + val y = if (searchText.value.text.isEmpty()) { + if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000 + } else { + 0 + } + IntOffset(0, y) + } + .background(MaterialTheme.colors.background) + ) { + ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) + Divider() + } + } + itemsIndexed(chats) { index, chat -> + val nextChatSelected = remember(chat.id, chats) { derivedStateOf { + chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value + } } + ChatListNavLinkView(chat, nextChatSelected) } } if (chats.isEmpty() && !chatModel.chats.isEmpty()) { @@ -348,28 +488,39 @@ private fun ChatList(chatModel: ChatModel, search: String) { } } -private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String, chats: List): List { - val s = searchText.trim().lowercase() - return if (s.isEmpty() && !showUnreadAndFavorites) - chats - else { - chats.filter { chat -> - when (val cInfo = chat.chatInfo) { - is ChatInfo.Direct -> if (s.isEmpty()) { - filtered(chat) - } else { - (viewNameContains(cInfo, s) || - cInfo.contact.profile.displayName.lowercase().contains(s) || - cInfo.contact.fullName.lowercase().contains(s)) +private fun filteredChats( + showUnreadAndFavorites: Boolean, + searchShowingSimplexLink: State, + searchChatFilteredBySimplexLink: State, + searchText: String, + chats: List +): List { + val linkChatId = searchChatFilteredBySimplexLink.value + return if (linkChatId != null) { + chats.filter { it.id == linkChatId } + } else { + val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() + if (s.isEmpty() && !showUnreadAndFavorites) + chats + else { + chats.filter { chat -> + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> if (s.isEmpty()) { + chat.id == chatModel.chatId.value || filtered(chat) + } else { + (viewNameContains(cInfo, s) || + cInfo.contact.profile.displayName.lowercase().contains(s) || + cInfo.contact.fullName.lowercase().contains(s)) + } + is ChatInfo.Group -> if (s.isEmpty()) { + chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited + } else { + viewNameContains(cInfo, s) + } + is ChatInfo.ContactRequest -> s.isEmpty() || viewNameContains(cInfo, s) + is ChatInfo.ContactConnection -> (s.isNotEmpty() && cInfo.contactConnection.localAlias.lowercase().contains(s)) || (s.isEmpty() && chat.id == chatModel.chatId.value) + is ChatInfo.InvalidJSON -> chat.id == chatModel.chatId.value } - is ChatInfo.Group -> if (s.isEmpty()) { - (filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited) - } else { - viewNameContains(cInfo, s) - } - is ChatInfo.ContactRequest -> s.isEmpty() || viewNameContains(cInfo, s) - is ChatInfo.ContactConnection -> s.isNotEmpty() && cInfo.contactConnection.localAlias.lowercase().contains(s) - is ChatInfo.InvalidJSON -> false } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 177efbfdd..f518f1c96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -62,7 +62,28 @@ class AlertManager { fun showAlertDialogButtonsColumn( title: String, - text: AnnotatedString? = null, + text: String? = null, + onDismissRequest: (() -> Unit)? = null, + hostDevice: Pair? = null, + buttons: @Composable () -> Unit, + ) { + showAlert { + AlertDialog( + onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, + title = alertTitle(title), + buttons = { + AlertContent(text, hostDevice, extraPadding = true) { + buttons() + } + }, + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) + } + } + + fun showAlertDialogButtonsColumn( + title: String, + text: AnnotatedString, onDismissRequest: (() -> Unit)? = null, hostDevice: Pair? = null, buttons: @Composable () -> Unit, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt new file mode 100644 index 000000000..ec2500ab2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt @@ -0,0 +1,27 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.DEFAULT_PADDING + +@Composable +fun DefaultProgressView(description: String?) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + Modifier + .padding(bottom = DEFAULT_PADDING) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.5.dp + ) + if (description != null) { + Text(description) + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 0162ac7e7..93be24d92 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -36,7 +36,7 @@ fun DefaultTopAppBar( SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = false, onValueChange = onSearchValueChanged) } }, - backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight, + backgroundColor = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f), navigationIcon = navigationButton, buttons = if (!showSearch) buttons else emptyList(), centered = !showSearch, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 703b6f905..7e9cefa31 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -38,6 +38,12 @@ enum class ModalPlacement { START, CENTER, END, FULLSCREEN } +class ModalData { + private val state = mutableMapOf>() + fun stateGetOrPut (key: String, default: () -> T): MutableState = + state.getOrPut(key) { mutableStateOf(default() as Any) } as MutableState +} + class ModalManager(private val placement: ModalPlacement? = null) { private val modalViews = arrayListOf Unit) -> Unit)>>() private val modalCount = mutableStateOf(0) @@ -45,15 +51,17 @@ class ModalManager(private val placement: ModalPlacement? = null) { private var oldViewChanging = AtomicBoolean(false) private var passcodeView: MutableState<(@Composable (close: () -> Unit) -> Unit)?> = mutableStateOf(null) - fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) { + fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { + val data = ModalData() showCustomModal { close -> - ModalView(close, showClose = showClose, endButtons = endButtons, content = content) + ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content() }) } } - fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, content: @Composable (close: () -> Unit) -> Unit) { + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, content: @Composable ModalData.(close: () -> Unit) -> Unit) { + val data = ModalData() showCustomModal { close -> - ModalView(close, showClose = showClose, content = { content(close) }) + ModalView(close, showClose = showClose, content = { data.content(close) }) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index 4b6c70df4..23fac21e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -34,6 +34,8 @@ fun SearchTextField( alwaysVisible: Boolean, searchText: MutableState = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }, placeholder: String = stringResource(MR.strings.search_verb), + enabled: Boolean = true, + trailingContent: @Composable (() -> Unit)? = null, onValueChange: (String) -> Unit ) { val focusRequester = remember { FocusRequester() } @@ -54,12 +56,12 @@ fun SearchTextField( } } - val enabled = true val colors = TextFieldDefaults.textFieldColors( backgroundColor = Color.Unspecified, textColor = MaterialTheme.colors.onBackground, focusedIndicatorColor = Color.Unspecified, unfocusedIndicatorColor = Color.Unspecified, + disabledIndicatorColor = Color.Unspecified, ) val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) val interactionSource = remember { MutableInteractionSource() } @@ -77,6 +79,7 @@ fun SearchTextField( searchText.value = it onValueChange(it.text) }, + enabled = rememberUpdatedState(enabled).value, cursorBrush = SolidColor(colors.cursorColor(false).value), visualTransformation = VisualTransformation.None, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), @@ -105,7 +108,7 @@ fun SearchTextField( }) { Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary,) } - }} else null, + }} else trailingContent, singleLine = true, enabled = enabled, interactionSource = interactionSource, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 9a81b9f9d..cae9523e1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -60,6 +60,9 @@ fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedStr } } +@Composable +expect fun SetupClipboardListener() + // maximum image file size to be auto-accepted const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt index 2913f6ac7..c2523c9b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt @@ -4,14 +4,16 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import chat.simplex.common.platform.chatModel import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.AppBarTitle +import chat.simplex.common.views.helpers.KeyChangeEffect import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.onboarding.ReadableTextWithLink import chat.simplex.res.MR @Composable -fun AddContactLearnMore() { +fun AddContactLearnMore(close: () -> Unit) { Column( Modifier.verticalScroll(rememberScrollState()), ) { @@ -20,4 +22,7 @@ fun AddContactLearnMore() { ReadableText(MR.strings.if_you_cant_meet_in_person) ReadableTextWithLink(MR.strings.read_more_in_user_guide_with_link, "https://simplex.chat/docs/guide/readme.html#connect-to-friends") } + KeyChangeEffect(chatModel.chatId.value) { + close() + } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt deleted file mode 100644 index 84080d5b9..000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ /dev/null @@ -1,186 +0,0 @@ -package chat.simplex.common.views.newchat - -import SectionBottomSpacer -import SectionTextFooter -import SectionView -import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* -import dev.icerock.moko.resources.compose.painterResource -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.dp -import chat.simplex.common.model.* -import chat.simplex.common.platform.shareText -import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR - -@Composable -fun AddContactView( - chatModel: ChatModel, - rh: RemoteHostInfo?, - connReqInvitation: String, - contactConnection: MutableState -) { - val clipboard = LocalClipboardManager.current - AddContactLayout( - rh = rh, - chatModel = chatModel, - incognitoPref = chatModel.controller.appPrefs.incognito, - connReq = connReqInvitation, - contactConnection = contactConnection, - learnMore = { - ModalManager.center.showModal { - Column( - Modifier - .fillMaxHeight() - .padding(horizontal = DEFAULT_PADDING), - verticalArrangement = Arrangement.SpaceBetween - ) { - AddContactLearnMore() - } - } - } - ) -} - -@Composable -fun AddContactLayout( - chatModel: ChatModel, - rh: RemoteHostInfo?, - incognitoPref: SharedPreference, - connReq: String, - contactConnection: MutableState, - learnMore: () -> Unit -) { - val incognito = remember { mutableStateOf(incognitoPref.get()) } - - LaunchedEffect(incognito.value) { - withApi { - val contactConnVal = contactConnection.value - if (contactConnVal != null) { - chatModel.controller.apiSetConnectionIncognito(rh?.remoteHostId, contactConnVal.pccConnId, incognito.value)?.let { - contactConnection.value = it - chatModel.updateContactConnection(rh?.remoteHostId, it) - } - } - } - } - - Column( - Modifier - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.SpaceBetween, - ) { - AppBarTitle(stringResource(MR.strings.add_contact), hostDevice(rh?.remoteHostId)) - - SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) { - if (connReq.isNotEmpty()) { - SimpleXLinkQRCode( - connReq, Modifier - .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) - .aspectRatio(1f) - ) - } else { - CircularProgressIndicator( - Modifier - .size(36.dp) - .padding(4.dp) - .align(Alignment.CenterHorizontally), - color = MaterialTheme.colors.secondary, - strokeWidth = 3.dp - ) - } - - IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } - ShareLinkButton(connReq) - OneTimeLinkLearnMoreButton(learnMore) - } - SectionTextFooter(sharedProfileInfo(chatModel, incognito.value)) - - SectionBottomSpacer() - } -} - -@Composable -fun ShareLinkButton(connReqInvitation: String) { - val clipboard = LocalClipboardManager.current - SettingsActionItem( - painterResource(MR.images.ic_share), - stringResource(MR.strings.share_invitation_link), - click = { clipboard.shareText(simplexChatLink(connReqInvitation)) }, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) -} - -@Composable -fun OneTimeLinkLearnMoreButton(onClick: () -> Unit) { - SettingsActionItem( - painterResource(MR.images.ic_info), - stringResource(MR.strings.learn_more), - onClick, - ) -} - -@Composable -fun IncognitoToggle( - incognitoPref: SharedPreference, - incognito: MutableState, - onClickInfo: () -> Unit -) { - SettingsActionItemWithContent( - icon = if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy), - text = null, - click = onClickInfo, - iconColor = if (incognito.value) Indigo else MaterialTheme.colors.secondary, - extraPadding = false - ) { - SharedPreferenceToggleWithIcon( - stringResource(MR.strings.incognito), - painterResource(MR.images.ic_info), - stopped = false, - onClickInfo = onClickInfo, - preference = incognitoPref, - preferenceState = incognito - ) - } -} - -fun sharedProfileInfo( - chatModel: ChatModel, - incognito: Boolean -): String { - val name = chatModel.currentUser.value?.displayName ?: "" - return if (incognito) { - generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared) - } else { - String.format(generalGetString(MR.strings.connect__your_profile_will_be_shared), name) - } -} - -@Preview/*( - uiMode = Configuration.UI_MODE_NIGHT_YES, - showBackground = true, - name = "Dark Mode" -)*/ -@Composable -fun PreviewAddContactView() { - SimpleXTheme { - AddContactLayout( - rh = null, - chatModel = ChatModel, - incognitoPref = SharedPreference({ false }, {}), - connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", - contactConnection = mutableStateOf(PendingContactConnection.getSampleData()), - learnMore = {}, - ) - } -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt similarity index 71% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 9f28074ae..adbacca6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -1,57 +1,51 @@ package chat.simplex.common.views.newchat -import SectionBottomSpacer import SectionItemView -import SectionTextFooter -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.* -import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.res.MR import java.net.URI -@Composable -expect fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) - enum class ConnectionLinkType { INVITATION, CONTACT, GROUP } suspend fun planAndConnect( - chatModel: ChatModel, rhId: Long?, uri: URI, incognito: Boolean?, - close: (() -> Unit)? + close: (() -> Unit)?, + cleanup: (() -> Unit)? = null, + filterKnownContact: ((Contact) -> Unit)? = null, + filterKnownGroup: ((GroupInfo) -> Unit)? = null, ) { val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri.toString()) if (connectionPlan != null) { + val link = strHasSingleSimplexLink(uri.toString().trim()) + val linkText = if (link?.format is Format.SimplexLink) + "

${link.simplexLinkText(link.format.linkType, link.format.smpHosts)}" + else + "" when (connectionPlan) { is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { InvitationLinkPlan.Ok -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } else { askCurrentOrIncognitoProfileAlert( chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_invitation_link), - text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), - connectDestructive = false + text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, + connectDestructive = false, + cleanup = cleanup, ) } } @@ -60,9 +54,11 @@ suspend fun planAndConnect( if (incognito != null) { AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), - text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), + text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onDismiss = cleanup, + onDismissRequest = cleanup, destructive = true, hostDevice = hostDevice(rhId), ) @@ -70,8 +66,9 @@ suspend fun planAndConnect( askCurrentOrIncognitoProfileAlert( chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), - text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link)), - connectDestructive = true + text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link) + linkText, + connectDestructive = true, + cleanup = cleanup, ) } } @@ -79,42 +76,54 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .InvitationLink, .Connecting, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact_ if (contact != null) { - openKnownContact(chatModel, rhId, close, contact) - AlertManager.privacySensitive.showAlertMsg( - generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), - hostDevice = hostDevice(rhId), - ) + if (filterKnownContact != null) { + filterKnownContact(contact) + } else { + openKnownContact(chatModel, rhId, close, contact) + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + linkText, + hostDevice = hostDevice(rhId), + ) + cleanup?.invoke() + } } else { AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_already_connecting), - generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link), + generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link) + linkText, hostDevice = hostDevice(rhId), ) + cleanup?.invoke() } } is InvitationLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact - openKnownContact(chatModel, rhId, close, contact) - AlertManager.privacySensitive.showAlertMsg( - generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), - hostDevice = hostDevice(rhId), - ) + if (filterKnownContact != null) { + filterKnownContact(contact) + } else { + openKnownContact(chatModel, rhId, close, contact) + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + linkText, + hostDevice = hostDevice(rhId), + ) + cleanup?.invoke() + } } } is ConnectionPlan.ContactAddress -> when (connectionPlan.contactAddressPlan) { ContactAddressPlan.Ok -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } else { askCurrentOrIncognitoProfileAlert( chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_contact_link), - text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), - connectDestructive = false + text = generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) + linkText, + connectDestructive = false, + cleanup, ) } } @@ -123,18 +132,21 @@ suspend fun planAndConnect( if (incognito != null) { AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), - text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), + text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, destructive = true, + onDismiss = cleanup, + onDismissRequest = cleanup, hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_connect_to_yourself), - text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address)), - connectDestructive = true + text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText, + connectDestructive = true, + cleanup = cleanup, ) } } @@ -143,9 +155,11 @@ suspend fun planAndConnect( if (incognito != null) { AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_repeat_connection_request), - text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), + text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText, confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onDismiss = cleanup, + onDismissRequest = cleanup, destructive = true, hostDevice = hostDevice(rhId), ) @@ -153,30 +167,41 @@ suspend fun planAndConnect( askCurrentOrIncognitoProfileAlert( chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_connection_request), - text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address)), - connectDestructive = true + text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText, + connectDestructive = true, + cleanup = cleanup, ) } } is ContactAddressPlan.ConnectingProhibit -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact - openKnownContact(chatModel, rhId, close, contact) - AlertManager.privacySensitive.showAlertMsg( - generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), - hostDevice = hostDevice(rhId), - ) + if (filterKnownContact != null) { + filterKnownContact(contact) + } else { + openKnownContact(chatModel, rhId, close, contact) + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + linkText, + hostDevice = hostDevice(rhId), + ) + cleanup?.invoke() + } } is ContactAddressPlan.Known -> { Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact - openKnownContact(chatModel, rhId, close, contact) - AlertManager.privacySensitive.showAlertMsg( - generalGetString(MR.strings.contact_already_exists), - String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), - hostDevice = hostDevice(rhId), - ) + if (filterKnownContact != null) { + filterKnownContact(contact) + } else { + openKnownContact(chatModel, rhId, close, contact) + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + linkText, + hostDevice = hostDevice(rhId), + ) + cleanup?.invoke() + } } is ContactAddressPlan.ContactViaAddress -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress, incognito=$incognito") @@ -187,6 +212,7 @@ suspend fun planAndConnect( } else { askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false) } + cleanup?.invoke() } } is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) { @@ -195,33 +221,42 @@ suspend fun planAndConnect( if (incognito != null) { AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_via_group_link), - text = generalGetString(MR.strings.you_will_join_group), + text = generalGetString(MR.strings.you_will_join_group) + linkText, confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onDismiss = cleanup, + onDismissRequest = cleanup, hostDevice = hostDevice(rhId), ) } else { askCurrentOrIncognitoProfileAlert( chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_via_group_link), - text = AnnotatedString(generalGetString(MR.strings.you_will_join_group)), - connectDestructive = false + text = generalGetString(MR.strings.you_will_join_group) + linkText, + connectDestructive = false, + cleanup = cleanup, ) } } is GroupLinkPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .GroupLink, .OwnLink, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - ownGroupLinkConfirmConnect(chatModel, rhId, uri, incognito, connectionPlan, groupInfo, close) + if (filterKnownGroup != null) { + filterKnownGroup(groupInfo) + } else { + ownGroupLinkConfirmConnect(chatModel, rhId, uri, linkText, incognito, connectionPlan, groupInfo, close, cleanup) + } } GroupLinkPlan.ConnectingConfirmReconnect -> { Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") if (incognito != null) { AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_repeat_join_request), - text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), + text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText, confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } }, + onConfirm = { withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }, + onDismiss = cleanup, + onDismissRequest = cleanup, destructive = true, hostDevice = hostDevice(rhId), ) @@ -229,8 +264,9 @@ suspend fun planAndConnect( askCurrentOrIncognitoProfileAlert( chatModel, rhId, uri, connectionPlan, close, title = generalGetString(MR.strings.connect_plan_repeat_join_request), - text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link)), - connectDestructive = true + text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText, + connectDestructive = true, + cleanup = cleanup, ) } } @@ -240,37 +276,44 @@ suspend fun planAndConnect( if (groupInfo != null) { AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName) + String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName) + linkText ) } else { AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_already_joining_the_group), - generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), + generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText, hostDevice = hostDevice(rhId), ) } + cleanup?.invoke() } is GroupLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - openKnownGroup(chatModel, rhId, close, groupInfo) - AlertManager.privacySensitive.showAlertMsg( - generalGetString(MR.strings.connect_plan_group_already_exists), - String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName), - hostDevice = hostDevice(rhId), - ) + if (filterKnownGroup != null) { + filterKnownGroup(groupInfo) + } else { + openKnownGroup(chatModel, rhId, close, groupInfo) + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.connect_plan_group_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) + linkText, + hostDevice = hostDevice(rhId), + ) + cleanup?.invoke() + } } } } } else { Log.d(TAG, "planAndConnect, plan error") if (incognito != null) { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan = null, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan = null, close, cleanup) } else { askCurrentOrIncognitoProfileAlert( chatModel, rhId, uri, connectionPlan = null, close, title = generalGetString(MR.strings.connect_plan_connect_via_link), - connectDestructive = false + connectDestructive = false, + cleanup = cleanup, ) } } @@ -282,7 +325,8 @@ suspend fun connectViaUri( uri: URI, incognito: Boolean, connectionPlan: ConnectionPlan?, - close: (() -> Unit)? + close: (() -> Unit)?, + cleanup: (() -> Unit)?, ) { val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION @@ -300,6 +344,7 @@ suspend fun connectViaUri( hostDevice = hostDevice(rhId), ) } + cleanup?.invoke() } fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { @@ -317,8 +362,9 @@ fun askCurrentOrIncognitoProfileAlert( connectionPlan: ConnectionPlan?, close: (() -> Unit)?, title: String, - text: AnnotatedString? = null, + text: String? = null, connectDestructive: Boolean, + cleanup: (() -> Unit)?, ) { AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = title, @@ -329,7 +375,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.privacySensitive.hideAlert() withApi { - connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) @@ -337,18 +383,20 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.privacySensitive.hideAlert() withApi { - connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) } SectionItemView({ AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() }) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } }, + onDismissRequest = cleanup, hostDevice = hostDevice(rhId), ) } @@ -367,20 +415,23 @@ fun ownGroupLinkConfirmConnect( chatModel: ChatModel, rhId: Long?, uri: URI, + linkText: String, incognito: Boolean?, connectionPlan: ConnectionPlan?, groupInfo: GroupInfo, close: (() -> Unit)?, + cleanup: (() -> Unit)?, ) { AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.connect_plan_join_your_group), - text = AnnotatedString(String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName)), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, buttons = { Column { // Open group SectionItemView({ AlertManager.privacySensitive.hideAlert() openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() }) { Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -389,7 +440,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withApi { - connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close, cleanup) } }) { Text( @@ -402,7 +453,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withApi { - connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -411,7 +462,7 @@ fun ownGroupLinkConfirmConnect( SectionItemView({ AlertManager.privacySensitive.hideAlert() withApi { - connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) + connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close, cleanup) } }) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) @@ -420,11 +471,13 @@ fun ownGroupLinkConfirmConnect( // Cancel SectionItemView({ AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() }) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } }, + onDismissRequest = cleanup, hostDevice = hostDevice(rhId), ) } @@ -438,77 +491,3 @@ fun openKnownGroup(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, grou } } } - -@Composable -fun ConnectContactLayout( - chatModel: ChatModel, - rh: RemoteHostInfo?, - incognitoPref: SharedPreference, - close: () -> Unit -) { - val incognito = remember { mutableStateOf(incognitoPref.get()) } - - @Composable - fun QRCodeScanner(close: () -> Unit) { - QRCodeScanner { connReqUri -> - try { - val uri = URI(connReqUri) - withApi { - planAndConnect(chatModel, rh?.remoteHostId, uri, incognito = incognito.value, close) - } - } catch (e: RuntimeException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_QR_code), - text = generalGetString(MR.strings.this_QR_code_is_not_a_link) - ) - } - } - } - - Column( - Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), - verticalArrangement = Arrangement.SpaceBetween - ) { - AppBarTitle(stringResource(MR.strings.scan_QR_code), hostDevice(rh?.remoteHostId), withPadding = false) - Box( - Modifier - .fillMaxWidth() - .aspectRatio(ratio = 1F) - .padding(bottom = 12.dp) - ) { QRCodeScanner(close) } - - IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } - - SectionTextFooter( - buildAnnotatedString { - append(sharedProfileInfo(chatModel, incognito.value)) - append("\n\n") - append(annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link)) - } - ) - - SectionBottomSpacer() - } -} - -fun URI.getQueryParameter(param: String): String? { - if (!query.contains("$param=")) return null - return query.substringAfter("$param=").substringBefore("&") -} - -@Preview/*( - uiMode = Configuration.UI_MODE_NIGHT_YES, - showBackground = true, - name = "Dark Mode" -)*/ -@Composable -fun PreviewConnectContactLayout() { - SimpleXTheme { - ConnectContactLayout( - chatModel = ChatModel, - rh = null, - incognitoPref = SharedPreference({ false }, {}), - close = {}, - ) - } -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt deleted file mode 100644 index 0077e2849..000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt +++ /dev/null @@ -1,12 +0,0 @@ -package chat.simplex.common.views.newchat - -import androidx.compose.runtime.* -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.RemoteHostInfo - -enum class ConnectViaLinkTab { - SCAN, PASTE -} - -@Composable -expect fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 5e9495e86..7fa0e6a70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.views.chatlist.deleteContactConnectionAlert import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel import chat.simplex.common.model.PendingContactConnection -import chat.simplex.common.platform.shareText +import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR @@ -37,16 +37,19 @@ fun ContactConnectionInfoView( close: () -> Unit ) { LaunchedEffect(connReqInvitation) { - chatModel.connReqInv.value = connReqInvitation + if (connReqInvitation != null) { + chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false) + } } - /** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. - * Otherwise, it will be called here AFTER [AddContactView] is launched and will clear the value too soon. + /** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation]. + * Otherwise, it will be called here AFTER [AddContactLearnMore] is launched and will clear the value too soon. * It will be dropped automatically when connection established or when user goes away from this screen. + * It applies only to Android because on Desktop center space will not be overlapped by [AddContactLearnMore] **/ DisposableEffect(Unit) { onDispose { - if (!ModalManager.center.hasModalsOpen()) { - chatModel.connReqInv.value = null + if (!ModalManager.center.hasModalsOpen() || appPlatform.isDesktop) { + chatModel.showingInvitation.value = null } } } @@ -61,14 +64,14 @@ fun ContactConnectionInfoView( onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, learnMore = { - ModalManager.center.showModal { + ModalManager.end.showModalCloseable { close -> Column( Modifier .fillMaxHeight() .padding(horizontal = DEFAULT_PADDING), verticalArrangement = Arrangement.SpaceBetween ) { - AddContactLearnMore() + AddContactLearnMore(close) } } } @@ -135,11 +138,7 @@ private fun ContactConnectionInfoLayout( SectionView { if (!connReq.isNullOrEmpty() && contactConnection.initiated) { - SimpleXLinkQRCode( - connReq, Modifier - .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) - .aspectRatio(1f) - ) + SimpleXLinkQRCode(connReq) incognitoEnabled() ShareLinkButton(connReq) OneTimeLinkLearnMoreButton(learnMore) @@ -158,6 +157,30 @@ private fun ContactConnectionInfoLayout( } } +@Composable +fun ShareLinkButton(connReqInvitation: String) { + val clipboard = LocalClipboardManager.current + SettingsActionItem( + painterResource(MR.images.ic_share), + stringResource(MR.strings.share_invitation_link), + click = { + chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy(connChatUsed = true) + clipboard.shareText(simplexChatLink(connReqInvitation)) + }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + +@Composable +fun OneTimeLinkLearnMoreButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_info), + stringResource(MR.strings.learn_more), + onClick, + ) +} + @Composable fun DeleteButton(onClick: () -> Unit) { SettingsActionItem( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt deleted file mode 100644 index 6f3caf467..000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt +++ /dev/null @@ -1,118 +0,0 @@ -package chat.simplex.common.views.newchat - -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.sp -import chat.simplex.common.model.* -import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.UserAddressView -import chat.simplex.res.MR - -enum class CreateLinkTab { - ONE_TIME, LONG_TERM -} - -@Composable -fun CreateLinkView(m: ChatModel, rh: RemoteHostInfo?, initialSelection: CreateLinkTab) { - val selection = remember { mutableStateOf(initialSelection) } - val connReqInvitation = rememberSaveable { m.connReqInv } - val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } - val creatingConnReq = rememberSaveable { mutableStateOf(false) } - LaunchedEffect(selection.value) { - if ( - selection.value == CreateLinkTab.ONE_TIME - && connReqInvitation.value.isNullOrEmpty() - && contactConnection.value == null - && !creatingConnReq.value - ) { - createInvitation(m, rh?.remoteHostId, creatingConnReq, connReqInvitation, contactConnection) - } - } - /** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. - * Otherwise, it will be called here AFTER [AddContactView] is launched and will clear the value too soon. - * It will be dropped automatically when connection established or when user goes away from this screen. - **/ - DisposableEffect(Unit) { - onDispose { - if (!ModalManager.center.hasModalsOpen()) { - m.connReqInv.value = null - } - } - } - val tabTitles = CreateLinkTab.values().map { - when { - it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() && contactConnection.value == null -> - stringResource(MR.strings.create_one_time_link) - it == CreateLinkTab.ONE_TIME -> - stringResource(MR.strings.one_time_link) - it == CreateLinkTab.LONG_TERM -> - stringResource(MR.strings.your_simplex_contact_address) - else -> "" - } - } - Column( - Modifier - .fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween - ) { - Column(Modifier.weight(1f)) { - when (selection.value) { - CreateLinkTab.ONE_TIME -> { - AddContactView(m, rh,connReqInvitation.value ?: "", contactConnection) - } - CreateLinkTab.LONG_TERM -> { - UserAddressView(m, viaCreateLinkView = true, close = {}) - } - } - } - TabRow( - selectedTabIndex = selection.value.ordinal, - backgroundColor = Color.Transparent, - contentColor = MaterialTheme.colors.primary, - ) { - tabTitles.forEachIndexed { index, it -> - Tab( - selected = selection.value.ordinal == index, - onClick = { - selection.value = CreateLinkTab.values()[index] - }, - text = { Text(it, fontSize = 13.sp) }, - icon = { - Icon( - if (CreateLinkTab.ONE_TIME.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_all_inclusive), - it - ) - }, - selectedContentColor = MaterialTheme.colors.primary, - unselectedContentColor = MaterialTheme.colors.secondary, - ) - } - } - } -} - -private fun createInvitation( - m: ChatModel, - rhId: Long?, - creatingConnReq: MutableState, - connReqInvitation: MutableState, - contactConnection: MutableState -) { - creatingConnReq.value = true - withApi { - val r = m.controller.apiAddContact(rhId, incognito = m.controller.appPrefs.incognito.get()) - if (r != null) { - m.updateContactConnection(rhId, r.second) - connReqInvitation.value = r.first - contactConnection.value = r.second - } else { - creatingConnReq.value = false - } - } -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 86929584c..f2f2e9ec5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -42,12 +42,7 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow ConnectViaLinkView(chatModel, chatModel.currentRemoteHost.value, close) } + ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) } }, createGroup = { closeNewChatSheet(false) @@ -59,18 +54,16 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, addContact: () -> Unit, - connectViaLink: () -> Unit, createGroup: () -> Unit, closeNewChatSheet: (animated: Boolean) -> Unit, ) { @@ -109,7 +102,7 @@ private fun NewChatSheetLayout( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.End ) { - val actions = remember { listOf(addContact, connectViaLink, createGroup) } + val actions = remember { listOf(addContact, createGroup) } val backgroundColor = if (isInDarkTheme()) blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F) else @@ -271,7 +264,6 @@ private fun PreviewNewChatSheet() { MutableStateFlow(AnimatedViewState.VISIBLE), stopped = false, addContact = {}, - connectViaLink = {}, createGroup = {}, closeNewChatSheet = {}, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt new file mode 100644 index 000000000..0686f3c86 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -0,0 +1,436 @@ +package chat.simplex.common.views.newchat + +import SectionBottomSpacer +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.text.BasicTextField +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.input.VisualTransformation +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.sp +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.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.res.MR +import kotlinx.coroutines.launch +import java.net.URI + +enum class NewChatOption { + INVITE, CONNECT +} + +@Composable +fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) { + val selection = remember { stateGetOrPut("selection") { selection } } + val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } } + val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } + val connReqInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connReq ?: "" } } + val creatingConnReq = rememberSaveable { mutableStateOf(false) } + val pastedLink = rememberSaveable { mutableStateOf("") } + LaunchedEffect(selection.value) { + if ( + selection.value == NewChatOption.INVITE + && connReqInvitation.isEmpty() + && contactConnection.value == null + && !creatingConnReq.value + ) { + createInvitation(rh?.remoteHostId, creatingConnReq, connReqInvitation, contactConnection) + } + } + DisposableEffect(Unit) { + onDispose { + /** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation]. + * Otherwise, it will be called here AFTER [AddContactLearnMore] is launched and will clear the value too soon. + * It will be dropped automatically when connection established or when user goes away from this screen. + * It applies only to Android because on Desktop center space will not be overlapped by [AddContactLearnMore] + **/ + if (chatModel.showingInvitation.value != null && (!ModalManager.center.hasModalsOpen() || appPlatform.isDesktop)) { + val conn = contactConnection.value + if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.keep_unused_invitation_question), + text = generalGetString(MR.strings.you_can_view_invitation_link_again), + confirmText = generalGetString(MR.strings.delete_verb), + dismissText = generalGetString(MR.strings.keep_invitation_link), + destructive = true, + onConfirm = { + withBGApi { + val chatInfo = ChatInfo.ContactConnection(conn) + controller.deleteChat(Chat(remoteHostId = rh?.remoteHostId, chatInfo = chatInfo, chatItems = listOf())) + if (chatModel.chatId.value == chatInfo.id) { + chatModel.chatId.value = null + ModalManager.end.closeModals() + } + } + } + ) + } + chatModel.showingInvitation.value = null + } + } + } + val tabTitles = NewChatOption.values().map { + when(it) { + NewChatOption.INVITE -> + stringResource(MR.strings.add_contact_tab) + NewChatOption.CONNECT -> + stringResource(MR.strings.connect_via_link) + } + } + + Column( + Modifier.fillMaxSize(), + ) { + Box(contentAlignment = Alignment.Center) { + val bottomPadding = DEFAULT_PADDING + AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = bottomPadding) + Column(Modifier.align(Alignment.CenterEnd).padding(bottom = bottomPadding, end = DEFAULT_PADDING)) { + AddContactLearnMoreButton() + } + } + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = selection.value.ordinal, + initialPageOffsetFraction = 0f + ) { NewChatOption.values().size } + KeyChangeEffect(pagerState.currentPage) { + selection.value = NewChatOption.values()[pagerState.currentPage] + } + TabRow( + selectedTabIndex = pagerState.currentPage, + backgroundColor = Color.Transparent, + contentColor = MaterialTheme.colors.primary, + ) { + tabTitles.forEachIndexed { index, it -> + LeadingIconTab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(it, fontSize = 13.sp) }, + icon = { + Icon( + if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code), + it + ) + }, + selectedContentColor = MaterialTheme.colors.primary, + unselectedContentColor = MaterialTheme.colors.secondary, + ) + } + } + + HorizontalPager(state = pagerState, Modifier.fillMaxSize(), verticalAlignment = Alignment.Top) { index -> + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connReqInvitation.isEmpty()) Arrangement.Center else Arrangement.Top) { + Spacer(Modifier.height(DEFAULT_PADDING)) + when (index) { + NewChatOption.INVITE.ordinal -> { + PrepareAndInviteView(rh?.remoteHostId, contactConnection, connReqInvitation, creatingConnReq) + } + NewChatOption.CONNECT.ordinal -> { + ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) + } + } + SectionBottomSpacer() + } + } + } +} + +@Composable +private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connReqInvitation: String, creatingConnReq: MutableState) { + if (connReqInvitation.isNotEmpty()) { + InviteView( + rhId, + connReqInvitation = connReqInvitation, + contactConnection = contactConnection, + ) + } else if (creatingConnReq.value) { + CreatingLinkProgressView() + } else { + RetryButton { createInvitation(rhId, creatingConnReq, connReqInvitation, contactConnection) } + } +} + +@Composable +private fun CreatingLinkProgressView() { + DefaultProgressView(stringResource(MR.strings.creating_link)) +} + +@Composable +private fun RetryButton(onClick: () -> Unit) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + IconButton(onClick, Modifier.size(30.dp)) { + Icon(painterResource(MR.images.ic_refresh), null) + } + Spacer(Modifier.height(DEFAULT_PADDING)) + Text(stringResource(MR.strings.retry_verb)) + } +} + +@Composable +private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection: MutableState) { + SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase()) { + LinkTextView(connReqInvitation, true) + } + + Spacer(Modifier.height(10.dp)) + + SectionView(stringResource(MR.strings.or_show_this_qr_code).uppercase()) { + SimpleXLinkQRCode(connReqInvitation, onShare = { chatModel.markShowingInvitationUsed() }) + } + + Spacer(Modifier.height(10.dp)) + val incognito = remember { mutableStateOf(controller.appPrefs.incognito.get()) } + IncognitoToggle(controller.appPrefs.incognito, incognito) { + if (appPlatform.isDesktop) ModalManager.end.closeModals() + ModalManager.end.showModal { IncognitoView() } + } + KeyChangeEffect(incognito.value) { + withBGApi { + val contactConn = contactConnection.value ?: return@withBGApi + val conn = controller.apiSetConnectionIncognito(rhId, contactConn.pccConnId, incognito.value) ?: return@withBGApi + contactConnection.value = conn + chatModel.updateContactConnection(rhId, conn) + } + chatModel.markShowingInvitationUsed() + } + SectionTextFooter(sharedProfileInfo(chatModel, incognito.value)) +} + +@Composable +private fun AddContactLearnMoreButton() { + IconButton( + { + if (appPlatform.isDesktop) ModalManager.end.closeModals() + ModalManager.end.showModalCloseable { close -> + Column( + Modifier + .fillMaxHeight() + .padding(horizontal = DEFAULT_PADDING), + verticalArrangement = Arrangement.SpaceBetween + ) { + AddContactLearnMore(close) + } + } + } + ) { + Icon( + painterResource(MR.images.ic_info), + stringResource(MR.strings.learn_more), + ) + } +} + +@Composable +private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, pastedLink: MutableState, close: () -> Unit) { + SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase()) { + PasteLinkView(rhId, pastedLink, showQRCodeScanner, close) + } + + if (appPlatform.isAndroid) { + Spacer(Modifier.height(10.dp)) + + SectionView(stringResource(MR.strings.or_scan_qr_code).uppercase()) { + QRCodeScanner(showQRCodeScanner) { text -> + withBGApi { + val res = verify(rhId, text, close) + if (!res) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_qr_code), + text = generalGetString(MR.strings.code_you_scanned_is_not_simplex_link_qr_code) + ) + } + } + } + } + } +} + +@Composable +private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRCodeScanner: MutableState, close: () -> Unit) { + if (pastedLink.value.isEmpty()) { + val clipboard = LocalClipboardManager.current + SectionItemView({ + val str = clipboard.getText()?.text ?: return@SectionItemView + val link = strHasSingleSimplexLink(str.trim()) + if (link != null) { + pastedLink.value = link.text + showQRCodeScanner.value = false + withBGApi { connect(rhId, link.text, close) { pastedLink.value = "" } } + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_contact_link), + text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) + ) + } + }) { + Text(stringResource(MR.strings.tap_to_paste_link)) + } + } else { + LinkTextView(pastedLink.value, false) + } +} + +@Composable +private fun LinkTextView(link: String, share: Boolean) { + val clipboard = LocalClipboardManager.current + Row(Modifier.fillMaxWidth().heightIn(min = 46.dp).padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f).clickable { + chatModel.markShowingInvitationUsed() + clipboard.shareText(link) + }) { + BasicTextField( + value = link, + onValueChange = { }, + enabled = false, + textStyle = TextStyle(fontSize = 16.sp, color = MaterialTheme.colors.onBackground), + singleLine = true, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = link, + innerTextField = innerTextField, + contentPadding = PaddingValues(), + label = null, + visualTransformation = VisualTransformation.None, + leadingIcon = null, + trailingIcon = null, + singleLine = true, + enabled = false, + isError = false, + interactionSource = remember { MutableInteractionSource() }, + ) + }) + } + // Element Text() can add ellipsis (...) in random place of the string, sometimes even after half of width of a screen. + // So using BasicTextField + manual ... + Text("…", fontSize = 16.sp) + if (share) { + Spacer(Modifier.width(DEFAULT_PADDING)) + IconButton({ + chatModel.markShowingInvitationUsed() + clipboard.shareText(link) + }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_share_filled), null, tint = MaterialTheme.colors.primary) + } + } + } +} + +private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boolean { + if (text != null && strIsSimplexLink(text)) { + connect(rhId, text, close) + return true + } + return false +} + +private suspend fun connect(rhId: Long?, link: String, close: () -> Unit, cleanup: (() -> Unit)? = null) { + planAndConnect( + rhId, + URI.create(link), + close = close, + cleanup = cleanup, + incognito = null + ) +} + +private fun createInvitation( + rhId: Long?, + creatingConnReq: MutableState, + connReqInvitation: String, + contactConnection: MutableState +) { + if (connReqInvitation.isNotEmpty() || contactConnection.value != null || creatingConnReq.value) return + creatingConnReq.value = true + withApi { + val (r, alert) = controller.apiAddContact(rhId, incognito = controller.appPrefs.incognito.get()) + if (r != null) { + chatModel.updateContactConnection(rhId, r.second) + chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false) + contactConnection.value = r.second + } else { + creatingConnReq.value = false + if (alert != null) { + alert() + } + } + } +} + +fun strIsSimplexLink(str: String): Boolean { + val parsedMd = parseToMarkdown(str) + return parsedMd != null && parsedMd.size == 1 && parsedMd[0].format is Format.SimplexLink +} + +fun strHasSingleSimplexLink(str: String): FormattedText? { + val parsedMd = parseToMarkdown(str) ?: return null + val parsedLinks = parsedMd.filter { it.format?.isSimplexLink ?: false } + if (parsedLinks.size != 1) return null + + return parsedLinks[0] +} + +@Composable +fun IncognitoToggle( + incognitoPref: SharedPreference, + incognito: MutableState, + onClickInfo: () -> Unit +) { + SettingsActionItemWithContent( + icon = if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy), + text = null, + click = onClickInfo, + iconColor = if (incognito.value) Indigo else MaterialTheme.colors.secondary, + extraPadding = false + ) { + SharedPreferenceToggleWithIcon( + stringResource(MR.strings.incognito), + painterResource(MR.images.ic_info), + stopped = false, + onClickInfo = onClickInfo, + preference = incognitoPref, + preferenceState = incognito + ) + } +} + +fun sharedProfileInfo( + chatModel: ChatModel, + incognito: Boolean +): String { + val name = chatModel.currentUser.value?.displayName ?: "" + return if (incognito) { + generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared) + } else { + String.format(generalGetString(MR.strings.connect__your_profile_will_be_shared), name) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt deleted file mode 100644 index dacf93757..000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ /dev/null @@ -1,135 +0,0 @@ -package chat.simplex.common.views.newchat - -import SectionBottomSpacer -import SectionTextFooter -import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.unit.dp -import chat.simplex.common.model.* -import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.IncognitoView -import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR -import java.net.URI - -@Composable -fun PasteToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { - val connectionLink = remember { mutableStateOf("") } - val clipboard = LocalClipboardManager.current - PasteToConnectLayout( - chatModel = chatModel, - rh = rh, - incognitoPref = chatModel.controller.appPrefs.incognito, - connectionLink = connectionLink, - pasteFromClipboard = { - connectionLink.value = clipboard.getText()?.text ?: return@PasteToConnectLayout - }, - close = close - ) -} - -@Composable -fun PasteToConnectLayout( - chatModel: ChatModel, - rh: RemoteHostInfo?, - incognitoPref: SharedPreference, - connectionLink: MutableState, - pasteFromClipboard: () -> Unit, - close: () -> Unit -) { - val incognito = remember { mutableStateOf(incognitoPref.get()) } - val rhId = rh?.remoteHostId - fun connectViaLink(connReqUri: String) { - try { - val uri = URI(connReqUri) - withApi { - planAndConnect(chatModel, rhId, uri, incognito = incognito.value, close) - } - } catch (e: RuntimeException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_connection_link), - text = generalGetString(MR.strings.this_string_is_not_a_connection_link) - ) - } - } - - Column( - Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), - verticalArrangement = Arrangement.SpaceBetween, - ) { - AppBarTitle(stringResource(MR.strings.connect_via_link), hostDevice(rhId), withPadding = false) - - Box(Modifier.padding(top = DEFAULT_PADDING, bottom = 6.dp)) { - TextEditor( - connectionLink, - Modifier.height(180.dp), - contentPadding = PaddingValues(), - placeholder = stringResource(MR.strings.paste_the_link_you_received_to_connect_with_your_contact) - ) - } - - if (connectionLink.value == "") { - SettingsActionItem( - painterResource(MR.images.ic_content_paste), - stringResource(MR.strings.paste_button), - click = pasteFromClipboard, - ) - } else { - SettingsActionItem( - painterResource(MR.images.ic_close), - stringResource(MR.strings.clear_verb), - click = { connectionLink.value = "" }, - ) - } - - SettingsActionItem( - painterResource(MR.images.ic_link), - stringResource(MR.strings.connect_button), - click = { connectViaLink(connectionLink.value) }, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary, - disabled = connectionLink.value.isEmpty() || connectionLink.value.trim().contains(" ") - ) - - IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } - - SectionTextFooter( - buildAnnotatedString { - append(sharedProfileInfo(chatModel, incognito.value)) - append("\n\n") - append(annotatedStringResource(MR.strings.you_can_also_connect_by_clicking_the_link)) - } - ) - - SectionBottomSpacer() - } -} - - -@Preview/*( - uiMode = Configuration.UI_MODE_NIGHT_YES, - name = "Dark Mode" -)*/ -@Composable -fun PreviewPasteToConnectTextbox() { - SimpleXTheme { - PasteToConnectLayout( - chatModel = ChatModel, - rh = null, - incognitoPref = SharedPreference({ false }, {}), - connectionLink = remember { mutableStateOf("") }, - pasteFromClipboard = {}, - close = {} - ) - } -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 7f9fae60a..e38c98348 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -14,7 +14,7 @@ import boofcv.alg.drawing.FiducialImageEngine import boofcv.alg.fiducial.qrcode.* import chat.simplex.common.model.CryptoFile import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -23,14 +23,18 @@ import kotlinx.coroutines.launch fun SimpleXLinkQRCode( connReq: String, modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING * 2f, vertical = DEFAULT_PADDING_HALF), tintColor: Color = Color(0xff062d56), - withLogo: Boolean = true + withLogo: Boolean = true, + onShare: (() -> Unit)? = null, ) { QRCode( simplexChatLink(connReq), modifier, + padding, tintColor, - withLogo + withLogo, + onShare, ) } @@ -46,22 +50,24 @@ fun simplexChatLink(uri: String): String { fun QRCode( connReq: String, modifier: Modifier = Modifier, + padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING * 2f, vertical = DEFAULT_PADDING_HALF), tintColor: Color = Color(0xff062d56), - withLogo: Boolean = true + withLogo: Boolean = true, + onShare: (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() - - BoxWithConstraints(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - val maxWidthInPx = with(LocalDensity.current) { maxWidth.roundToPx() } - val qr = remember(maxWidthInPx, connReq, tintColor, withLogo) { - qrCodeBitmap(connReq, maxWidthInPx).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) - .let { if (withLogo) it.addLogo() else it } - } + val qr = remember(connReq, tintColor, withLogo) { + qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) + .let { if (withLogo) it.addLogo() else it } + } + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Image( bitmap = qr, contentDescription = stringResource(MR.strings.image_descr_qr_code), Modifier - .widthIn(max = 360.dp) + .padding(padding) + .widthIn(max = 400.dp) + .aspectRatio(1f) .then(modifier) .clickable { scope.launch { @@ -70,6 +76,7 @@ fun QRCode( val file = saveTempImageUncompressed(image, true) if (file != null) { shareFile("", CryptoFile.plain(file.absolutePath)) + onShare?.invoke() } } } @@ -81,7 +88,9 @@ fun qrCodeBitmap(content: String, size: Int = 1024): ImageBitmap { val qrCode = QrCodeEncoder().addAutomatic(content).setError(QrCode.ErrorLevel.L).fixate() /** See [QrCodeGeneratorImage.initialize] and [FiducialImageEngine.configure] for size calculation */ val numModules = QrCode.totalModules(qrCode.version) - val borderModule = 1 + // Hide border on light themes to make it fit to the same place as camera in QRCodeScanner. + // On dark themes better to show the border + val borderModule = if (CurrentColors.value.colors.isLight) 0 else 1 // val calculatedFinalWidth = (pixelsPerModule * numModules) + 2 * (borderModule * pixelsPerModule) // size = (x * numModules) + 2 * (borderModule * x) // size / x = numModules + 2 * borderModule diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.kt index 66ba595e1..1e497e058 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.kt @@ -1,6 +1,14 @@ package chat.simplex.common.views.newchat +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.* +import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF @Composable -expect fun QRCodeScanner(onBarcode: (String) -> Unit) +expect fun QRCodeScanner( + showQRCodeScanner: MutableState = remember { mutableStateOf(true) }, + padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING * 2f, vertical = DEFAULT_PADDING_HALF), + onBarcode: (String) -> Unit +) 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 853419802..a5442a5bc 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 @@ -84,7 +84,7 @@ private fun CreateSimpleXAddressLayout( Spacer(Modifier.weight(1f)) if (userAddress != null) { - SimpleXLinkQRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(userAddress.connReqContact) ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } Spacer(Modifier.weight(1f)) ShareViaEmailButton { sendEmail(userAddress) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 44e6969b8..5acb240cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -339,16 +339,9 @@ private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList) { SectionView(stringResource(MR.strings.scan_qr_code_from_desktop).uppercase()) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(ratio = 1F) - .padding(DEFAULT_PADDING) - ) { - QRCodeScanner { text -> - sessionAddress.value = text - processDesktopQRCode(sessionAddress, text) - } + QRCodeScanner { text -> + sessionAddress.value = text + processDesktopQRCode(sessionAddress, text) } } } 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 a3218c961..c06265e70 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 @@ -187,11 +187,7 @@ private fun ConnectMobileViewLayout( SectionView { if (invitation != null && sessionCode == null && port != null) { Box { - QRCode( - invitation, Modifier - .padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF) - .aspectRatio(1f) - ) + QRCode(invitation) 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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt index e264172f9..6da0d34bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.onboarding.ReadableTextWithLink import chat.simplex.res.MR @Composable @@ -31,6 +32,7 @@ fun IncognitoLayout() { Text(generalGetString(MR.strings.incognito_info_protects)) Text(generalGetString(MR.strings.incognito_info_allows)) Text(generalGetString(MR.strings.incognito_info_share)) + ReadableTextWithLink(MR.strings.read_more_in_user_guide_with_link, "https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode") SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt index 4e8da36a7..08ebc4ef1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt @@ -160,7 +160,7 @@ private fun CustomServer( if (valid.value) { SectionDividerSpaced() SectionView(stringResource(MR.strings.smp_servers_add_to_another_device).uppercase()) { - QRCode(serverAddress.value, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) + QRCode(serverAddress.value) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt index 77cb0ead1..502b579d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt @@ -20,25 +20,17 @@ fun ScanProtocolServerLayout(rhId: Long?, onNext: (ServerCfg) -> Unit) { Column( Modifier .fillMaxSize() - .padding(horizontal = DEFAULT_PADDING) ) { - AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr), withPadding = false) - Box( - Modifier - .fillMaxWidth() - .aspectRatio(ratio = 1F) - .padding(bottom = 12.dp) - ) { - QRCodeScanner { text -> - val res = parseServerAddress(text) - if (res != null) { - onNext(ServerCfg(remoteHostId = rhId, text, false, null, true)) - } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.smp_servers_invalid_address), - text = generalGetString(MR.strings.smp_servers_check_address) - ) - } + AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr)) + QRCodeScanner { text -> + val res = parseServerAddress(text) + if (res != null) { + onNext(ServerCfg(remoteHostId = rhId, text, false, null, true)) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_invalid_address), + text = generalGetString(MR.strings.smp_servers_check_address) + ) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 915120d81..299be4322 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -207,7 +207,7 @@ private fun UserAddressLayout( val autoAcceptState = remember { mutableStateOf(AutoAcceptState(userAddress)) } val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) } SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - SimpleXLinkQRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(userAddress.connReqContact) ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } ShareViaEmailButton { sendEmail(userAddress) } ShareWithContactsButton(shareViaProfile, setProfileAddress) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 1e6f1b064..883e5e5f4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -147,8 +147,6 @@ تم تغيير العنوان من أجلك لا يمكن حذف ملف تعريف المستخدم! طلب لاستلام الفيديو - إضافة جهة اتصال جديدة : لإنشاء رمز الاستجابة السريعة الخاص بك لمرة واحدة لجهة اتصالك.]]> - امسح رمز الاستجابة السريعة : للاتصال بجهة الاتصال التي تعرض لك رمز الاستجابة السريعة.]]> مكالمتك تحت الإجراء تغيير عبارة مرور قاعدة البيانات؟ لا يمكن الوصول إلى Keystore لحفظ كلمة مرور قاعدة البيانات @@ -198,7 +196,6 @@ قارن الملف خطأ إنشاء مجموعة سرية - إنشاء رابط دعوة لمرة واحدة خطأ في إحباط تغيير العنوان تفعيل قفل SimpleX تأكد من بيانات الاعتماد الخاصة بك @@ -1324,7 +1321,7 @@ لا يمكنك إرسال رسائل! تحتاج إلى السماح لجهة الاتصال الخاصة بك بإرسال رسائل صوتية لتتمكن من إرسالها. أرسلت جهة اتصالك ملفًا أكبر من الحجم الأقصى المعتمد حاليًا (%1$s). - الاتصال بمطوري SimpleX Chat لطرح أي أسئلة وتلقي التحديثات.]]> + الاتصال بمطوري SimpleX Chat لطرح أي أسئلة وتلقي التحديثات.]]> خادمك يُخزن ملف تعريفك على جهازك ومشاركته فقط مع جهات اتصالك. لا تستطيع خوادم SimpleX رؤية ملف تعريفك. الفيديو مقفل 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 4ad40d7a6..e30b4eb56 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -293,9 +293,11 @@ Tap to start a new chat Chat with the developers You have no chats + Loading chats… No filtered chats Tap to Connect Connect with %1$s? + Search or paste SimpleX link No selected chat @@ -427,6 +429,11 @@ (scan or paste from clipboard) (only stored by group members) + + Enable camera access + Tap to scan + Camera not available + Permission Denied! Camera @@ -442,8 +449,8 @@ To start a new chat Tap button above, then: - Add new contact: to create your one-time QR Code for your contact.]]> - Scan QR code: to connect to your contact who shows QR code to you.]]> + Add contact: to create a new invitation link, or connect via a link you received.]]> + Create group: to create a new group.]]> To connect via link If you received SimpleX Chat invitation link, you can open it in your browser: Scan QR code.]]> @@ -546,11 +553,26 @@ This string is not a connection link! Open in mobile app button.]]> - - Create one-time invitation link + + New chat + Add contact One-time invitation link 1-time link SimpleX address + Or show this code + Or scan QR code + Keep unused invitation? + You can view invitation link again in connection details. + Keep + Creating link… + Retry + Share this 1-time invite link + Paste the link you received + The text you pasted is not a SimpleX link. + Tap to paste link + + Invalid QR code + The code you scanned is not a SimpleX link QR code. Scan code @@ -1708,20 +1730,20 @@ Connect to yourself? This is your own one-time link! - You are already connecting to %1$s. + %1$s.]]> Already connecting! You are already connecting via this one-time link! This is your own SimpleX address! Repeat connection request? You have already requested connection via this address! Join your group? - This is your link for group %1$s! + %1$s!]]> Open group Repeat join request? Group already exists! - You are already joining the group %1$s. + %1$s.]]> Already joining the group! You are already joining the group via this link. - You are already in group %1$s. + %1$s.]]> Connect via link? \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index bdb9b39be..4101b1c93 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -119,8 +119,6 @@ Назад Отказ Спри живото съобщение - Добави нов контакт: за да създадете своя еднократен QR код за вашия контакт.]]> - Сканирай QR код: за да се свържете с вашия контакт, който ви показва QR код.]]> Камера Ако не можете да се срещнете лично, покажете QR код във видеоразговора или споделете линка. спри визуализацията на линка @@ -404,7 +402,6 @@ Изтрий Изтрий Свърване чрез линк - Създай линк за еднократна покана Парола за базата данни и експортиране Допринеси Продължи @@ -794,7 +791,7 @@ Без звук QR код Повече - Ръководство за потребителя.]]> + Ръководство за потребителя.]]> Маркирай като проверено %s не е потвърдено %s е потвърдено @@ -814,7 +811,7 @@ Няма се използват Onion хостове. Нека да поговорим в SimpleX Chat Парола за показване - GitHub хранилище.]]> + GitHub хранилище.]]> Когато приложението работи Периодично Постави получения линк @@ -1328,7 +1325,7 @@ Ще трябва да се идентифицирате, когато стартирате или възобновите приложението след 30 секунди във фонов режим. вие сте наблюдател Видео - се свържете с разработчиците на SimpleX Chat, за да задавате въпроси и да получавате актуализации;.]]> + се свържете с разработчиците на SimpleX Chat, за да задавате въпроси и да получавате актуализации;.]]> иска да се свърже с вас! Отваряне в мобилно приложение.]]> XFTP сървъри diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 83fc06e87..ae1988422 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -191,8 +191,6 @@ Pro zahájení nové konverzace Klepněte na tlačítko potom: - Přidejte nový kontakt: vytvořte jednorázý QR kód pro váš kontakt.]]> - Naskenujte QR kód: připojíte se ke kontaktu, který vám QR kód ukázal.]]> Skenovat QR kód.]]> Vyčistit chat\? Vyčistit @@ -209,7 +207,6 @@ Ke skupině budete připojeni, až bude zařízení hostitele skupiny online, vyčkejte prosím nebo se podívejte později! Budete připojeni, jakmile bude vaše žádost o připojení přijata, vyčkejte prosím nebo se podívejte později! Požadavek na připojení byl odeslán! - Vytvořit jednorázovou pozvánku Jednorázová pozvánka Bezpečnostní kód %s je ověřeno diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 7401c0d4d..20fa5c88a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -262,12 +262,10 @@ Video Danke, dass Sie SimpleX Chat installiert haben! - mit SimpleX-Chat-Entwicklern verbinden, um Fragen zu stellen und Updates zu erhalten.]]> + mit SimpleX-Chat-Entwicklern verbinden, um Fragen zu stellen und Updates zu erhalten.]]> Um einen neuen Chat zu starten Schaltfläche antippen Danach die gewünschte Aktion auswählen: - Neuen Kontakt hinzufügen: Um Ihren Einmal-QR-Code für Ihren Kontakt zu erstellen.]]> - QR-Code scannen: Um sich mit Ihrem Kontakt zu verbinden, der Ihnen seinen QR-Code zeigt.]]> Über Link verbinden Wenn Sie einen SimpleX-Chat-Einladungslink erhalten haben, können Sie ihn in Ihrem Browser öffnen: QR-Code scannen.]]> @@ -341,7 +339,6 @@ Diese Zeichenfolge entspricht keinem gültigen Verbindungslink! In mobiler App öffnen“.]]> - Einmal-Einladungslink erstellen Einmal-Einladungslink Ihre Einstellungen @@ -1488,14 +1485,14 @@ Erweitern Verbindungsanfrage wiederholen? Gelöschter Kontakt - Sie sind bereits mit %1$s verbunden. + %1$s verbunden.]]> Fehler Sie sind über diesen Link bereits Mitglied der Gruppe. Gruppe erstellen Profil erstellen %s und %s Ihrer Gruppe beitreten? - Sie sind bereits Mitglied in der Gruppe %1$s. + %1$s.]]> Das ist Ihr eigener Einmal-Link! %d Nachrichten als gelöscht markiert Gruppe besteht bereits! @@ -1510,7 +1507,7 @@ Mitglied freigeben Mit Ihnen selbst verbinden? Zum Verbinden antippen - Sie sind bereits Mitglied in der Gruppe %1$s. + %1$s.]]> Das ist Ihre eigene SimpleX-Adresse! Richtiger Name für %s? %d Nachrichten löschen? @@ -1531,7 +1528,7 @@ Mitglied blockieren? %d Gruppenereignisse Ungültiger Name! - Das ist Ihr Link für die Gruppe %1$s! + %1$s!]]> Freigeben Ungültiger Datei-Pfad Sie haben über diese Adresse bereits eine Verbindung beantragt! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index a656b932d..928f00763 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -27,7 +27,6 @@ Siempre activo Permitir y después: - Añadir nuevo contacto: para crear tu código QR de un solo uso para tu contacto.]]> ¿Aceptar solicitud de conexión\? Aceptar incógnito Se eliminarán todos los mensajes SOLO para tí. ¡No podrá deshacerse! @@ -79,9 +78,7 @@ ¡Consume más batería! El servicio en segundo plano se ejecuta continuamente y las notificaciones se mostrarán de inmediato.]]> Tanto tú como tu contacto podéis eliminar de forma irreversible los mensajes enviados. Tanto tú como tu contacto podéis enviar mensajes temporales. - Escanear código QR: para conectar con tu contacto mediante su código QR.]]> Crear - Crea enlace de invitación de un uso Crea grupo secreto La contraseña de cifrado de la base de datos será actualizada. ID base de datos diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index be4072c4b..78edeaecd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -188,7 +188,6 @@ Salli kontaktiesi lähettää katoavia viestejä. Katoavat viestit Kontekstikuvake - Skannaa QR-koodi: muodostaaksesi yhteyden kontaktiisi, joka näyttää QR-koodin sinulle.]]> Peruuta live-viesti Määritä ICE-palvelimet Lisää osoite profiiliisi, jotta kontaktisi voivat jakaa sen muiden kanssa. Profiilipäivitys lähetetään kontakteillesi. @@ -231,7 +230,6 @@ Tyhjennä Tyhjennä keskustelu Tyhjennä keskustelu\? - Luo kertaluonteinen kutsulinkki Sovellusversio: v%s soittaa… Poista keskusteluprofiili @@ -271,7 +269,6 @@ Katkaistu Takaisin Yhdistä linkillä / QR-koodilla - Lisää uusi kontakti: luo kertakäyttöinen QR-koodi kontaktille.]]> Tyhjennä Skannaa QR-koodi.]]> Poistetaanko odottava yhteys\? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 5ad0b3cbf..4532e0aff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -206,7 +206,6 @@ Vidéo Pour démarrer une nouvelle discussion Appuyez sur le bouton - Scanner un code QR : pour vous connecter à votre contact qui vous montre un code QR.]]> Pour se connecter via un lien Si vous avez reçu un lien d\'invitation SimpleX Chat, vous pouvez l\'ouvrir dans votre navigateur : Scanner le code QR.]]> @@ -246,7 +245,6 @@ Coller Cette chaîne n\'est pas un lien de connexion ! Ouvrir dans l\'app mobile.]]> - Créer un lien d\'invitation unique Définir le nom du contact… Déconnecté Erreur @@ -296,7 +294,6 @@ Merci d\'avoir installé SimpleX Chat ! vous connecter aux développeurs de SimpleX Chat pour leur poser des questions et recevoir des réponses :.]]> ci-dessus, puis : - Ajouter un nouveau contact : afin de créer un code QR à usage unique pour votre contact.]]> Si vous choisissez de la rejeter, l\'expéditeur·rice NE sera PAS notifié·e. Accepter Muet @@ -1407,14 +1404,14 @@ Développer Répéter la demande de connexion ? contact supprimé - Vous êtes déjà connecté(e) à %1$s. + %1$s.]]> Erreur Vous êtes déjà en train de rejoindre le groupe via ce lien. Créer un groupe Créer le profil %s et %s Rejoindre votre groupe ? - Vous êtes déjà en train de rejoindre le groupe %1$s. + %1$s.]]> Voici votre propre lien unique ! %d messages marqués comme supprimés Ce groupe existe déjà ! @@ -1429,7 +1426,7 @@ Débloquer ce membre Se connecter à soi-même ? Tapez pour vous connecter - Vous êtes déjà dans le groupe %1$s. + %1$s.]]> Voici votre propre adresse SimpleX ! Corriger le nom pour %s ? Supprimer %d messages ? @@ -1450,7 +1447,7 @@ Bloquer ce membre ? %d événements de groupe Nom invalide ! - Voici votre lien pour le groupe %1$s ! + %1$s !]]> Débloquer Chemin du fichier invalide Vous avez déjà demandé une connexion via cette adresse ! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index fac97864f..6e2ba94ea 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -44,7 +44,6 @@ minden chat profilodra az appban.]]> Mindketten, te és az ismerősöd is küldhettek eltűnő üzeneteket. Az Android Keystore-t jelmondat biztonságos tárolására használják - lehetővé teszi az értesítési szolgáltatás működését. - QR-kód beolvasása: kapcsolódás ismerőshöz a megmutatott QR-kódja alapján]]> Téves üzenet hash Felhasználói profil törlése nem lehetséges! Háttér @@ -117,7 +116,6 @@ vastagított Az app számkód helyettesítésre kerül egy önmegsemmisítő számkóddal. Arab, bulgár, finn, héber, thai és ukrán - köszönet a felhasználóknak és a Weblate-nek! - Új ismerős hozzáadása: egyszer használatos QR-kód készítése az ismerős számára.]]> Hangüzenetek engedélyezése? Mindig használt relay szervert mindig @@ -338,7 +336,6 @@ Csatlakoztatva a mobilhoz Jelenlegi jelmondat… Fájl választása - Egyszer használatos meghívó link létrehozása Kép törlése Fájl létrehozása Tikos csoport létrehozása @@ -1432,7 +1429,7 @@ A chat szolgáltatást elindíthatod a beállítások / adatbázis pontban vagy az app újraindításával. Ellenőrizd a kódot a mobilon! Csatlakoztál ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. - a SimpleX Chat fejlesztőivel és kérdezhetsz bármit és értesülhetsz az újdonságokról.]]> + a SimpleX Chat fejlesztőivel és kérdezhetsz bármit és értesülhetsz az újdonságokról.]]> Opcionális üdvözlő szöveggel. Ismeretlen adatbázis hiba: %s Elrejtheted vagy némíthatod egy felhasználó profilját - tartsd lenyomva a menühöz! @@ -1518,13 +1515,13 @@ A titkosítás működik, és új titkosítási egyezményre nincs szükség. Ez kapcsolati hibákat eredményezhet! Ez a művelet nem vonható vissza - profilod, ismerőseid, üzeneteid és fájljaid visszafordíthatatlanul törlésre kerülnek. A bejegyzés frissítve - Felhasználói útmutatóban olvasható.]]> + Felhasználói útmutatóban olvasható.]]> A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban Az előző üzenet hash-e más. Ezek a beállítások a jelenlegi profilodra vonatkoznak Kérjük, várj, amíg a fájl betöltődik az összekapcsolt mobilról. - GitHub tárolónkban.]]> + GitHub tárolónkban.]]> hiba a tartalom megjelenítése közben hiba az üzenet megjelenítésekor \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index f01568c03..21713ba93 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -292,8 +292,6 @@ Elimina I messaggi diretti tra i membri sono vietati in questo gruppo. Inserisci il tuo nome: - Aggiungi un contatto: per creare il tuo codice QR una tantum per il tuo contatto.]]> - Scansiona codice QR: per connetterti al contatto che ti mostra il codice QR.]]> File Svuota chat Svuotare la chat\? @@ -319,7 +317,6 @@ Annulla la verifica Connetti Connetti via link - Crea link di invito una tantum Password del database ed esportazione Inserisci il server manualmente Come si usa @@ -1407,14 +1404,14 @@ Espandi Ripetere la richiesta di connessione? contatto eliminato - Ti stai già connettendo a %1$s. + %1$s.]]> Errore Stai già entrando nel gruppo tramite questo link. Crea gruppo Crea profilo %s e %s Entrare nel tuo gruppo? - Stai già entrando nel gruppo %1$s. + %1$s.]]> Questo è il tuo link una tantum! %d messaggi contrassegnati eliminati Il gruppo esiste già! @@ -1429,7 +1426,7 @@ Sblocca membro Connettersi a te stesso? Tocca per connettere - Sei già nel gruppo %1$s. + %1$s.]]> Questo è il tuo indirizzo SimpleX! Correggere il nome a %s? Eliminare %d messaggi? @@ -1450,7 +1447,7 @@ Bloccare il membro? %d eventi del gruppo Nome non valido! - Questo è il tuo link per il gruppo %1$s! + %1$s!]]> Sblocca Percorso file non valido Hai già richiesto la connessione tramite questo indirizzo! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 58bb6b0a0..e1657ed6d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -93,7 +93,6 @@ אשר אוטומטית בקשות ליצירת קשר. אימות בוטל אימות לא זמין - הוסיפו איש קשר חדש: ליצירת קוד QR חד־פעמי עבור איש הקשר שלכם.]]> הטוב ביותר לסוללה. התראות יוצגו רק כאשר האפליקציה מופעלת (ללא שירות רקע).]]> טוב לסוללה. שירות הרקע ייבדוק הודעות כל 10 דקות. שיחות או הודעות דחופות עלולות להתפספס.]]> גם אתם וגם איש הקשר יכולים למחוק באופן בלתי הפיך הודעות שנשלחו. @@ -103,7 +102,6 @@ ביטול בטל הודעה חיה מצלמה - סירקו קוד QR: כדי להתחבר לאיש קשר המציג לכם קוד QR.]]> בטל תצוגה מקדימה של קישורים שגיאת שיחה שיחה מתמשכת @@ -219,7 +217,6 @@ הועתק ללוח צור קישור הזמנה חד־פעמי צור קבוצה סודית - צור קישור הזמנה חד־פעמי תרומה גרסת ליבה: v%s צור כתובת diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index bebf716e0..419e0c3a6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -73,12 +73,10 @@ 認証不可能 画像を自動的に受信 バックグラウンド機能が常にオンで、メッセージが到着次第に通知が出ます。 - 新しい連絡先を追加:使い捨てのQRコードを発行]]> 電池省エネをオンに、バックグラウンド機能と定期的な受信依頼をオフにします。設定メニューにて変更できます。 電池消費が最少:アプリがアクティブ時のみに通知が出ます(バックグラウンドサービス無し)。]]> 設定メニューにてオフにできます。 アプリがアクティブ時に通知が出ます。]]> あなたと連絡相手が送信済みメッセージを永久削除できます。 - QRコードを読み込み:連絡相手のQRコードをスキャンすると繋がります。]]> チャットのアーカイブ チャットのアーカイブを削除しますか? シークレットモードで参加 @@ -524,7 +522,6 @@ ミュート 接続待ちの繋がりを削除しますか? 接続 - 使い捨てリンクを発行する 使い捨ての招待リンク データベース暗証フレーズとエキスポート 使い方 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index 3c7554c97..b059cc5cc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -27,7 +27,6 @@ 컨텍스트 아이콘 연결됨 뒤로 - 새 대화 상대 추가 : 대화를 위한 일회용 QR 코드 만들기]]> 취소 라이브 메시지 취소 파일 선택 @@ -171,7 +170,6 @@ 배터리에 가장 좋음. 앱이 실행 중일 때만 알림을 받게 됩니다 (백그라운드에서 실행되지 않음).]]> 설정을 통해 비활성화할 수 있습니다. – 앱이 실행되는 동안 알림이 표시됩니다.]]> 당신과 대화 상대 모두 사라지는 메시지를 보낼 수 있습니다. - QR 코드 스캔: QR 코드를 보여주는 사람과 대화할 수 있습니다.]]> 데이터베이스 암호를 저장하고 있는 Keystore에 접근할 수 없습니다. 배터리 더욱 사용! 백그라운드 서비스가 항상 실행됩니다. - 메시지를 수신되는 즉시 알림이 표시됩니다.]]> 통화 종료됨 %1$s @@ -202,7 +200,6 @@ 대화 상대와 종단간 암호화됨 대화 상대와 아직 연결되지 않았습니다! %1$s에 생성 완료 - 일회용 초대 링크 생성 비밀 그룹 생성 익명 수락 1개월 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index 163645c7d..5d74cac33 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -117,7 +117,6 @@ QR kodas pagalba El. paštas - Sukurti vienkartinio pakvietimo nuorodą Skenuoti kodą Duomenų bazės slaptafrazė ir eksportavimas Ištrinti serverį @@ -387,8 +386,6 @@ Gali būti, kad liudijimo kontrolinis kodas serverio adrese yra neteisingas Ištrinti failą Dekodavimo klaida - Pridėti naują adresatą: norėdami sukurti adresatui vienkartinį QR kodą.]]> - Skenuoti QR kodą: norėdami prisijungti prie adresato, kuris jums rodo QR kodą.]]> Išvalyti pokalbį Įjungti pranešimus Neteisingas QR kodas diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index bb5226e3d..b9dfb6866 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -66,7 +66,6 @@ Spraak berichten toestaan\? Goed voor de batterij. Achtergrondservice controleert berichten elke 10 minuten. Mogelijk mist u oproepen of dringende berichten.]]> Onjuiste bericht hash - Scan QR-code: om verbinding te maken met uw contact die u de QR-code laat zien.]]> Onjuiste bericht-ID Oproep al beëindigd! 1 maand @@ -94,7 +93,6 @@ voor elk chat profiel dat je in de app hebt .]]> audio oproep (niet e2e versleuteld) Achtergrondservice is altijd actief, meldingen worden weergegeven zodra de berichten beschikbaar zijn. - Nieuw contact toevoegen: om een eenmalige QR-code voor uw contact te maken.]]> Oproep beëindigd Batterijoptimalisatie is actief, waardoor achtergrondservice en periodieke verzoeken om nieuwe berichten worden uitgeschakeld. Je kunt ze weer inschakelen via instellingen. Het beste voor de batterij. U ontvangt alleen meldingen wanneer de app wordt uitgevoerd (GEEN achtergrondservice).]]> @@ -203,7 +201,6 @@ Verwijderd verificatie Verbind Maak verbinding via link - Maak een eenmalige uitnodiging link gekleurd Oproep verbinden… Maak @@ -1442,17 +1439,17 @@ Console in nieuw venster weergeven Alle nieuwe berichten van %s worden verborgen! geblokkeerd - Je bent al verbonden met %1$s. + %1$s.]]> Je wordt al lid van de groep via deze link. - Je bent al lid van de groep %1$s. + %1$s.]]> Dit is uw eigen eenmalige link! Lid deblokkeren - Je zit al in groep %1$s. + %1$s.]]> Dit is uw eigen SimpleX adres! Lid deblokkeren? Je maakt al verbinding via deze eenmalige link! Je hebt een ongeldig bestandslocatie gedeeld. Rapporteer het probleem aan de app-ontwikkelaars. - Dit is jouw link voor groep %1$s! + %1$s!]]> Deblokkeren U heeft al een verbinding aangevraagd via dit adres! Fout bij heronderhandeling van codering diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index e811e47ea..6e1fdfbbb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -229,7 +229,6 @@ Akceptuj Akceptuj incognito Wszystkie wiadomości zostaną usunięte - nie można tego cofnąć! Wiadomości zostaną usunięte TYLKO dla Ciebie. - Zeskanuj kod QR: aby połączyć się z kontaktem, który pokaże Ci kod QR.]]> anuluj podgląd linku Wyczyść Wyczyść @@ -917,7 +916,6 @@ Uwierzytelnianie niedostępne Dołącz Wstecz - Dodaj nowy kontakt: aby stworzyć swój jednorazowy kod QR dla kontaktu.]]> Optymalizacja baterii jest aktywna, wyłącza usługi w tle i okresowe żądania nowych wiadomości. Możesz je ponownie włączyć za pośrednictwem ustawień. Można je wyłączyć poprzez ustawienia - powiadomienia nadal będą pokazywane podczas działania aplikacji.]]> Najlepsze dla baterii. Będziesz otrzymywać powiadomienia tylko wtedy, gdy aplikacja jest uruchomiona (NIE w tle).]]> @@ -933,7 +931,6 @@ Błąd połączenia (UWIERZYTELNIANIE) Połącz się przez link / kod QR Utworzony na %1$s - Utwórz jednorazowy link do zaproszenia Utwórz tajną grupę Utwórz tajną grupę Baza danych jest zaszyfrowana przy użyciu losowego hasła. Proszę zmienić je przed eksportem. @@ -1427,12 +1424,12 @@ zablokowany Rozszerz Powtórzyć prośbę połączenia? - Już jesteś połączony z %1$s. + %1$s.]]> Błąd Już dołączasz do grupy przez ten link. %s i %s Dołączyć do twojej grupy? - Już dołączasz do grupy %1$s. + %1$s.]]> To jest twój jednorazowy link! Grupa już istnieje! Wideo nie może zostać zdekodowane, spróbuj inne wideo lub skontaktuj się z deweloperami. @@ -1440,7 +1437,7 @@ %s, %s i %d członków Odblokuj członka Dotknij aby połączyć - Już jesteś w grupie %1$s. + %1$s.]]> To jest twój własny adres SimpleX! Usuń członka Odblokować członka? @@ -1452,7 +1449,7 @@ Błąd wysyłania zaproszenia Udostępniłeś nieprawidłową ścieżkę pliku. Zgłoś problem do deweloperów aplikacji. Nieprawidłowa nazwa! - To jest twój link zaproszenia do grupy %1$s! + %1$s!]]> Odblokuj Nieprawidłowa ścieżka pliku Już prosiłeś o połączenie na ten adres! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 622ad8b2d..769375604 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -24,8 +24,6 @@ Cancelar mensagem ao vivo Voltar Arquivo - Adicionar novo contato: para criar seu QR code de uso único para seu contato.]]> - Escanear código QR: para se conectar ao seu contato que mostra o código QR para você.]]> Aceitar Limpar chat\? Limpar @@ -374,7 +372,6 @@ A autenticação do dispositivo está desativada. Desativando o bloqueio SimpleX. Para todos Oculto - Gerar um link de convite de uso único. Como usar seus servidores Importar Importar banco de dados de chat\? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index 072eb97eb..e755d9aab 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -211,7 +211,6 @@ Grupo Áudio ligado Autenticação cancelada - Adicionar novo contato: para criar o seu código QR de utilização única para o seu contato.]]> Bom para a bateria . O serviço em segundo plano verifica se há mensagens a cada 10 minutos. Você pode perder chamadas ou mensagens urgentes.]]> A otimização da bateria está ativa, desativando o serviço em segundo plano e os pedidos periódicos de novas mensagens. Você pode reativá-los através das definições. Melhor para a bateria. Apenas receberá notificações enquanto a app estiver em execução (SEM serviço em segundo plano)]]> @@ -225,7 +224,6 @@ Chamadas de áudio/vídeo são proibidas. O serviço em segundo plano está sempre em execução - as notificações serão exibidas assim que as mensagens estiverem disponíveis. Autenticar - Leia o código QR : para se conectar ao seu contato que lhe mostra o código QR.]]> chamada finalizada %1$s a chamar… erro de chamada @@ -366,7 +364,6 @@ Erro de conexão conexão %1$d O contato já existe - Criar convite de ligação de utilização única Convite de ligação de utilização única Salvar Modo anónimo diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 76a49f678..aa0e3282f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -267,12 +267,10 @@ Чтобы начать новый чат Нажмите кнопку сверху, затем: - Добавить новый контакт: чтобы создать одноразовый QR код/ссылку для Вашего контакта.]]> - Сканировать QR код: чтобы соединиться с контактом, который показывает Вам QR код.]]> Чтобы соединиться через ссылку Если Вы получили ссылку с приглашением из SimpleX Chat, Вы можете открыть ее в браузере: Сканировать QR код.]]> - Open in mobile app на веб странице, затем нажмите Соединиться в приложении.]]> + Open in mobile app на веб странице, затем нажмите Соединиться в приложении.]]> Принять запрос на соединение? Отправителю НЕ будет послано уведомление, если Вы отклоните запрос на соединение. @@ -339,7 +337,6 @@ Соединиться Вставить - Создать одноразовую ссылку Одноразовая ссылка Настройки @@ -1566,9 +1563,9 @@ Все новые сообщения от %s будут скрыты! Версия настольного приложения %s несовместима с этим приложением. заблокировано - Вы уже соединяетесь с %1$s. + %1$s.]]> Вы уже вступаете в группу по этой ссылке. - Вы уже вступаете в группу %1$s. + %1$s.]]> Это ваша собственная одноразовая ссылка! Через безопасный квантово-устойчивый протокол. Чтобы скрыть нежелательные сообщения. @@ -1580,7 +1577,7 @@ Разблокировать члена группы Нажмите чтобы соединиться Имя этого устройства - Вы уже состоите в группе %1$s. + %1$s.]]> Это ваш собственный адрес SimpleX! Разблокировать члена группы? Использовать с компьютера @@ -1590,7 +1587,7 @@ Имя устройства будет доступно подключенному мобильному клиенту. Сверьте код на мобильном Указан неверный путь к файлу. Сообщите о проблеме разработчикам приложения. - Это ваша ссылка на группу %1$s! + %1$s!]]> Сверьте код с компьютером Сканировать QR код с компьютера Разблокировать diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index 91330717c..3eec7777f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -112,8 +112,6 @@ ทั้งคุณและผู้ติดต่อของคุณสามารถเพิ่มปฏิกิริยาต่อข้อความได้ ทั้งคุณและผู้ติดต่อของคุณสามารถลบข้อความที่ส่งแล้วอย่างถาวรได้ กล้อง - เพิ่มผู้ติดต่อใหม่ : เพื่อสร้างรหัส QR แบบใช้ครั้งเดียวสําหรับผู้ติดต่อของคุณ]]> - สแกนรหัส QR: เพื่อเชื่อมต่อกับผู้ติดต่อที่แสดงรหัส QR ให้คุณ]]> เกี่ยวกับที่อยู่ SimpleX ตัวหนา กำลังโทร… @@ -205,7 +203,6 @@ ปุ่มปิด เชื่อมต่อ เชื่อมต่อผ่านลิงก์ - สร้างลิงก์เชิญแบบใช้ครั้งเดียว ล้างการยืนยัน คอนโซลแชท ตรวจสอบที่อยู่เซิร์ฟเวอร์แล้วลองอีกครั้ง diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 8a3eeef61..1cffad1df 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -3,7 +3,6 @@ Bildirimler Bağlantı ya da karekod ile bağlan Gizli grup oluştur - Tek seferlik davet bağlantısı oluştur SimpleX addresin cevapsız çağrı Gelen görüntülü arama @@ -736,7 +735,6 @@ Geri alınamaz mesaj silme İtalyanca arayüz Dosya seç - QR kodunu tara: size QR kodunu gösteren kişiyle bağlantı kurmak için.]]> Arkadaşlarınızı davet edin kalın İtalik @@ -790,7 +788,6 @@ Mesajlar silinecek - bu geri alınamaz! Alıcı adresini değiştir\? Geri - Yeni kişi ekle: Kişiniz için tek seferlik QR Kodunuzu oluşturmak için.]]> Bağlantı isteğiniz kabul edildiğinde bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin! Kişinizin cihazı çevrimiçi olduğunda bağlanacaksınız, lütfen bekleyin veya daha sonra kontrol edin! Daha fazla bilgi edinin @@ -902,7 +899,7 @@ Kişiniz desteklenen maksimum boyuttan (%1$s) daha büyük bir dosya gönderdi. Kişiniz yüklemeyi tamamladığında video alınacaktır. mobil uygulamada aç seçeneğine tıklayın.]]> - SimpleX Chat geliştiricilerine bağlanabilirsiniz.]]> + SimpleX Chat geliştiricilerine bağlanabilirsiniz.]]> Bir kullanıcının profilini gizleyebilir veya sessize alabilirsiniz - menü için basılı tutun. Sohbet veritabanınızın en son sürümünü SADECE bir cihazda kullanmalısınız, aksi takdirde bazı kişilerden daha fazla mesaj alamayabilirsiniz. Yanlış veritabanı parolası @@ -976,7 +973,7 @@ Link ile bağlanmak için Bu geçerli bir bağlantı linki değil Bu QR kodu bir bağlantı değil! - Kullanıcı Kılavuzu.]]> + Kullanıcı Kılavuzu.]]> Yapıştır Bu dize bir bağlantı linki değil! Uygulamaya puan verin @@ -1020,7 +1017,7 @@ Favorilerden çıkar Sohbeti gizli yap! Profil güncellemesi kişilerinize gönderilecektir. - GitHub repomuzda daha fazlasını okuyun.]]> + GitHub repomuzda daha fazlasını okuyun.]]> Lütfen geliştiricilere bildirin. Profil ve sunucu bağlantıları gizlemeyi kaldır diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index faf289582..beb5584b9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -104,7 +104,6 @@ поганий хеш повідомлення поганий ідентифікатор повідомлення Фон - Додайте новий контакт: щоб створити одноразовий QR-код для вашого контакту.]]> Це можна вимкнути у налаштуваннях – сповіщення все одно будуть відображатися, коли програма працює. Служба фонового режиму завжди активна – сповіщення відображатимуться, як тільки повідомлення будуть доступні. Запит на отримання зображення @@ -356,7 +355,6 @@ Це посилання не є дійсним з\'єднувальним посиланням! Запит на з\'єднання відправлено! Відкрити у мобільному додатку.]]> - Створити одноразове запрошення Сканувати код Скануйте код безпеки з додатка вашого контакту. Невірна адреса сервера! @@ -529,7 +527,7 @@ зображення профілю Більше Створити профіль - GitHub.]]> + GitHub.]]> Відео увімкнено Це може трапитися, якщо ви або ваше з\'єднання використовували застарілу резервну копію бази даних. Відновити резервну копію бази даних @@ -606,7 +604,6 @@ Доступ відхилено! Камера Дякуємо за установку SimpleX Chat! - Сканувати QR-код: щоб підключитися до вашого контакту, який вам показує QR-код.]]> Якщо ви отримали запрошення від SimpleX Chat, ви можете відкрити його у вашому браузері: Очистити Видалити @@ -1028,7 +1025,7 @@ Вас підключать, коли пристрій вашого контакту буде в мережі, зачекайте або перевірте пізніше! Ви не втратите свої контакти, якщо ви пізніше видалите свою адресу. Коли люди просять про з\'єднання, ви можете його прийняти чи відхилити. - Посібнику користувача.]]> + Посібнику користувача.]]> SimpleX-адреса Очистити перевірку %s перевірено @@ -1200,7 +1197,7 @@ Створити секретну групу (щоб поділитися з вашим контактом) (сканувати або вставити з буферу обміну) - підключитися до розробників SimpleX Chat, щоб задати будь-які питання і отримувати оновлення.]]> + підключитися до розробників SimpleX Chat, щоб задати будь-які питання і отримувати оновлення.]]> Сканувати QR-код.]]> Адреса SimpleX Показати QR-код diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 7b8d8ec7e..449f98360 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -118,7 +118,6 @@ 每个联系人和群组成员 将使用单独的 TCP 连接(和 SOCKS 凭证)。 \n请注意:如果您有很多连接,您的电池和流量消耗可能会大大增加,并且某些连接可能会失败。 返回 - 添加新联系人:为您的联系人创建一次性二维码。]]> 最长续航 。您只会在应用程序运行时收到通知(无后台服务)。]]> 较长续航 。后台服务每 10 分钟检查一次消息。您可能会错过来电或者紧急信息。]]> 加粗 @@ -129,7 +128,6 @@ 使用更多电量 !后台服务始终运行——一旦收到消息,就会显示通知。]]> 请注意:如果您丢失密码,您将无法恢复或者更改密码。]]> 通话已结束! - 扫描二维码 :与向您展示二维码的联系人联系。]]> 无法邀请联系人! 无法邀请联系人! 取消 @@ -338,7 +336,6 @@ 创建私密群组 不同的名字、头像和传输隔离。 法语界面 - 创建一次性邀请链接 如何使用它 错误 连接中…… @@ -1407,14 +1404,14 @@ 展开 重复连接请求吗? 已删除联系人 - 你已经在连接到 %1$s。 + %1$s。]]> 错误 你已经在通过此链接加入该群。 建群 创建个人资料 %s 和 %s 加入你的群吗? - 你已经在加入 %1$s 群。 + %1$s 群。]]> 这是你自己的一次性链接! %d 条消息被标记为删除 群已存在! @@ -1428,7 +1425,7 @@ 解封成员 连接到你自己? 轻按连接 - 你已经在%1$s 群内。 + %1$s 群内。]]> 这是你自己的 SimpleX 地址! 更正名称为 %s? 删除 %d 条消息吗? @@ -1449,7 +1446,7 @@ 封禁成员吗? %d 个群事件 无效名称! - 这是给你的 %1$s 群链接! + %1$s 群链接!]]> 解封 无效的文件路径 你已经请求通过此地址进行连接! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 7ab98ca32..f5b6e6987 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -47,8 +47,6 @@ 允許使用語音訊息? 取消 取消實況訊息 - 新增新的聯絡人:建立你的一次性二維碼給你的聯絡人。]]> - 掃描二維碼:連接到向你出示二維碼的聯絡人。]]> 選擇檔案 相機 從圖片庫選擇圖片 @@ -424,7 +422,6 @@ 掃描二維碼。]]> 設定 這個二維碼不是一個連結! - 建立一次性邀請連結 當可行的時候 核心版本:v%s simplexmq: v%s (%2s) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index 6f317acb9..9245f2b95 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.contextMenuOpenDetector import androidx.compose.runtime.Composable import androidx.compose.ui.* import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import java.io.File import java.net.URI @@ -36,3 +38,5 @@ onExternalDrag(enabled) { } actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() } + +actual fun Modifier.desktopPointerHoverIconHand(): Modifier = Modifier.pointerHoverIcon(PointerIcon.Hand) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 6c37c93cc..b2fc45196 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chatlist +import SectionDivider import androidx.compose.foundation.* import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.* @@ -33,11 +34,11 @@ actual fun ChatListNavLinkLayout( dropdownMenuItems: (@Composable () -> Unit)?, showMenu: MutableState, stopped: Boolean, - selectedChat: State + selectedChat: State, + nextChatSelected: State, ) { var modifier = Modifier.fillMaxWidth() if (!stopped) modifier = modifier - .background(color = if (selectedChat.value) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) .onRightClick { showMenu.value = true } CompositionLocalProvider( @@ -52,10 +53,17 @@ actual fun ChatListNavLinkLayout( ) { chatLinkPreview() } + if (selectedChat.value) { + Box(Modifier.matchParentSize().background(MaterialTheme.colors.onBackground.copy(0.05f))) + } if (dropdownMenuItems != null) { DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems) } } } - Divider() + if (selectedChat.value || nextChatSelected.value) { + Divider() + } else { + SectionDivider() + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 19c9fc0fd..9fa93cdfd 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -1,14 +1,20 @@ package chat.simplex.common.views.helpers +import androidx.compose.runtime.* import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Density import chat.simplex.common.model.CIFile import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState +import kotlinx.coroutines.delay +import java.io.ByteArrayInputStream +import java.io.File import java.io.* import java.net.URI import javax.imageio.ImageIO @@ -17,6 +23,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi private val bStyle = SpanStyle(fontWeight = FontWeight.Bold) private val iStyle = SpanStyle(fontStyle = FontStyle.Italic) +private val uStyle = SpanStyle(textDecoration = TextDecoration.Underline) private fun fontStyle(color: String) = SpanStyle(color = Color(color.replace("#", "ff").toLongOrNull(16) ?: Color.White.toArgb().toLong())) @@ -54,6 +61,22 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat } break } + text.substringSafe(innerI, 2) == "u>" -> { + val textStart = innerI + 2 + for (insideTagI in textStart until text.length) { + if (text[insideTagI] == '<') { + withStyle(uStyle) { append(text.substring(textStart, insideTagI)) } + skipTil = insideTagI + 4 + break + } + } + break + } + text.substringSafe(innerI, 3) == "br>" -> { + val textStart = innerI + 3 + append("\n") + skipTil = textStart + } text.substringSafe(innerI, 4) == "font" -> { var textStart = innerI + 5 var color = "#000000" @@ -85,6 +108,18 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat AnnotatedString(text) } +@Composable +actual fun SetupClipboardListener() { + val clipboard = LocalClipboardManager.current + chatModel.clipboardHasText.value = clipboard.hasText() + LaunchedEffect(Unit) { + while (true) { + delay(1000) + chatModel.clipboardHasText.value = clipboard.hasText() + } + } +} + actual fun getAppFileUri(fileName: String): URI { val rh = chatModel.currentRemoteHost.value return if (rh == null) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt deleted file mode 100644 index 72d967815..000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt +++ /dev/null @@ -1,11 +0,0 @@ -package chat.simplex.common.views.newchat - -import androidx.compose.runtime.* -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.RemoteHostInfo - -@Composable -actual fun ConnectViaLinkView(m: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { - // TODO this should close if remote host changes in model - PasteToConnectView(m, rh, close) -} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.desktop.kt index 16d35b5b8..0142afb4a 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.desktop.kt @@ -1,8 +1,13 @@ package chat.simplex.common.views.newchat +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.* @Composable -actual fun QRCodeScanner(onBarcode: (String) -> Unit) { +actual fun QRCodeScanner( + showQRCodeScanner: MutableState, + padding: PaddingValues, + onBarcode: (String) -> Unit +) { //LALAL } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt deleted file mode 100644 index 7579f09fa..000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt +++ /dev/null @@ -1,15 +0,0 @@ -package chat.simplex.common.views.newchat - -import androidx.compose.runtime.Composable -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.RemoteHostInfo - -@Composable -actual fun ScanToConnectView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { - ConnectContactLayout( - chatModel = chatModel, - rh = rh, - incognitoPref = chatModel.controller.appPrefs.incognito, - close = close - ) -}