diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 45a963b05..d2ca49a89 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -114,6 +114,15 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } + val isAndroid = gradle.startParameter.taskNames.find { + val lower = it.toLowerCase() + lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install") + } != null + if (isAndroid) { + // This is not needed on Android but can't be moved to desktopMain because MR lib don't support this. + // No other ways to exclude a file work but it's large and should be excluded + kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf") + } } multiplatformResources { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt index 213d05dcb..35c29371e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -24,7 +24,7 @@ actual val agentDatabaseFileName: String = "files_agent.db" actual val databaseExportDir: File = androidAppContext.cacheDir @Composable -actual fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) -> Unit): FileChooserLauncher { +actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher { val launcher = rememberLauncherForActivityResult( contract = if (getContent) ActivityResultContracts.GetContent() else ActivityResultContracts.CreateDocument(), onResult = { onResult(it?.toURI()) } 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 f4f748ef0..41349654b 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 @@ -2,6 +2,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import com.google.accompanist.insets.navigationBarsWithImePadding actual fun Modifier.navigationBarsWithImePadding(): Modifier = navigationBarsWithImePadding() @@ -14,3 +15,11 @@ actual fun ProvideWindowInsets( ) { com.google.accompanist.insets.ProvideWindowInsets(content = content) } + +@Composable +actual fun Modifier.desktopOnExternalDrag( + enabled: Boolean, + onFiles: (List) -> Unit, + onImage: (Painter) -> Unit, + onText: (String) -> Unit +): Modifier = this diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt index 056ae7495..2e7404f24 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt @@ -11,3 +11,5 @@ actual val Inter: FontFamily = FontFamily( Font(MR.fonts.Inter.medium.fontResourceId, FontWeight.Medium), Font(MR.fonts.Inter.light.fontResourceId, FontWeight.Light) ) + +actual val EmojiFont: FontFamily = FontFamily.Default diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index c05672864..15421299a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -2,18 +2,27 @@ package chat.simplex.common.views.chat.item import android.Manifest import android.os.Build +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.FileChooserLauncher import chat.simplex.common.platform.saveImage +import chat.simplex.common.views.helpers.SharedContent import chat.simplex.common.views.helpers.withApi import chat.simplex.res.MR import com.google.accompanist.permissions.rememberPermissionState import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +@Composable +actual fun ReactionIcon(text: String, fontSize: TextUnit) { + Text(text, fontSize = fontSize) +} + @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.android.kt new file mode 100644 index 000000000..f0e8c1295 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.android.kt @@ -0,0 +1,10 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable + +@Composable +actual fun EmojiText(text: String) { + val s = text.trim() + Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index b02c86579..9bc26d445 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -68,8 +68,11 @@ fun getLoadedFilePath(file: CIFile?): String? { } } +/** +* [rememberedValue] is used in `remember(rememberedValue)`. So when the value changes, file saver will update a callback function +* */ @Composable -expect fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) -> Unit): FileChooserLauncher +expect fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any? = null, onResult: (URI?) -> Unit): FileChooserLauncher expect fun rememberFileChooserMultipleLauncher(onResult: (List) -> Unit): FileChooserMultipleLauncher 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 a5c455d98..543444d0e 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 @@ -2,6 +2,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter expect fun Modifier.navigationBarsWithImePadding(): Modifier @@ -11,3 +12,11 @@ expect fun ProvideWindowInsets( windowInsetsAnimationsEnabled: Boolean = true, content: @Composable () -> Unit ) + +@Composable +expect fun Modifier.desktopOnExternalDrag( + enabled: Boolean = true, + onFiles: (List) -> Unit = {}, + onImage: (Painter) -> Unit = {}, + onText: (String) -> Unit = {} +): Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index 17ff695e7..4a7521efb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -12,6 +12,7 @@ import chat.simplex.common.views.helpers.generalGetString // https://github.com/rsms/inter // I place it here because IDEA shows an error (but still works anyway) when this declaration inside Type.kt expect val Inter: FontFamily +expect val EmojiFont: FontFamily object ThemeManager { private val appPrefs: AppPreferences = ChatController.appPrefs 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 0f7dd1a08..ce7765bcd 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 @@ -11,8 +11,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.ui.* import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.* @@ -410,6 +409,31 @@ fun ChatLayout( Box( Modifier .fillMaxWidth() + .desktopOnExternalDrag( + enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value, + onFiles = { paths -> + val uris = paths.map { URI.create(it) } + val groups = uris.groupBy { isImage(it) } + val images = groups[true] ?: emptyList() + val files = groups[false] ?: emptyList() + if (images.isNotEmpty()) { + composeState.processPickedMedia(images, null) + } else if (files.isNotEmpty()) { + composeState.processPickedFile(uris.first(), null) + } + }, + onImage = { + val tmpFile = File.createTempFile("image", ".bmp", tmpDir) + tmpFile.deleteOnExit() + chatModel.filesToDelete.add(tmpFile) + val uri = tmpFile.toURI() + composeState.processPickedMedia(listOf(uri), null) + }, + onText = { + // Need to parse HTML in order to correctly display the content + //composeState.value = composeState.value.copy(message = composeState.value.message + it) + }, + ) ) { ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { ModalBottomSheetLayout( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index d4c84624a..677465349 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -124,6 +124,8 @@ data class ComposeState( } } +private val maxFileSize = getMaxFileSize(FileProtocol.XFTP) + sealed class RecordingState { object NotStarted: RecordingState() class Started(val filePath: String, val progressMs: Int = 0): RecordingState() @@ -155,6 +157,66 @@ expect fun AttachmentSelection( processPickedMedia: (List, String?) -> Unit ) +fun MutableState.processPickedFile(uri: URI?, text: String?) { + if (uri != null) { + val fileSize = getFileSize(uri) + if (fileSize != null && fileSize <= maxFileSize) { + val fileName = getFileName(uri) + if (fileName != null) { + value = value.copy(message = text ?: value.message, preview = ComposePreview.FilePreview(fileName, uri)) + } + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.large_file), + String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) + ) + } + } +} + +fun MutableState.processPickedMedia(uris: List, text: String?) { + val content = ArrayList() + val imagesPreview = ArrayList() + uris.forEach { uri -> + var bitmap: ImageBitmap? + when { + isImage(uri) -> { + // Image + val drawable = getDrawableFromUri(uri) + bitmap = getBitmapFromUri(uri) + if (isAnimImage(uri, drawable)) { + // It's a gif or webp + val fileSize = getFileSize(uri) + if (fileSize != null && fileSize <= maxFileSize) { + content.add(UploadContent.AnimatedImage(uri)) + } else { + bitmap = null + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.large_file), + String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) + ) + } + } else { + content.add(UploadContent.SimpleImage(uri)) + } + } + else -> { + // Video + val res = getBitmapFromVideo(uri) + bitmap = res.preview + val durationMs = res.duration + content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) + } + } + if (bitmap != null) { + imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) + } + } + if (imagesPreview.isNotEmpty()) { + value = value.copy(message = text ?: value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) + } +} + @Composable fun ComposeView( chatModel: ChatModel, @@ -168,70 +230,11 @@ fun ComposeView( val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } val cancelledLinks = rememberSaveable { mutableSetOf() } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() - val maxFileSize = getMaxFileSize(FileProtocol.XFTP) val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } - val processPickedMedia = { uris: List, text: String? -> - val content = ArrayList() - val imagesPreview = ArrayList() - uris.forEach { uri -> - var bitmap: ImageBitmap? - when { - isImage(uri) -> { - // Image - val drawable = getDrawableFromUri(uri) - bitmap = getBitmapFromUri(uri) - if (isAnimImage(uri, drawable)) { - // It's a gif or webp - val fileSize = getFileSize(uri) - if (fileSize != null && fileSize <= maxFileSize) { - content.add(UploadContent.AnimatedImage(uri)) - } else { - bitmap = null - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) - ) - } - } else { - content.add(UploadContent.SimpleImage(uri)) - } - } - else -> { - // Video - val res = getBitmapFromVideo(uri) - bitmap = res.preview - val durationMs = res.duration - content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) - } - } - if (bitmap != null) { - imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) - } - } - if (imagesPreview.isNotEmpty()) { - composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) - } - } - val processPickedFile = { uri: URI?, text: String? -> - if (uri != null) { - val fileSize = getFileSize(uri) - if (fileSize != null && fileSize <= maxFileSize) { - val fileName = getFileName(uri) - if (fileName != null) { - composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri)) - } - } else { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) - ) - } - } - } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } - AttachmentSelection(composeState, attachmentOption, processPickedFile, processPickedMedia) + AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile, composeState::processPickedMedia) fun isSimplexLink(link: String): Boolean = link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) @@ -620,8 +623,8 @@ fun ComposeView( when (val shared = chatModel.sharedContent.value) { is SharedContent.Text -> onMessageChange(shared.text) - is SharedContent.Media -> processPickedMedia(shared.uris, shared.text) - is SharedContent.File -> processPickedFile(shared.uri, shared.text) + is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) + is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) null -> {} } chatModel.sharedContent.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 54e6ffbd6..773533ca7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -208,7 +208,7 @@ fun CIFileView( @Composable fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = - rememberFileChooserLauncher(false) { to: URI? -> + rememberFileChooserLauncher(false, ciFile) { to: URI? -> val filePath = getLoadedFilePath(ciFile) if (filePath != null && to != null) { copyFileToFile(File(filePath), to) {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 188b89efd..0597e2d13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -17,8 +17,7 @@ import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -84,7 +83,7 @@ fun ChatItemView( @Composable fun ChatItemReactions() { - Row { + Row(verticalAlignment = Alignment.CenterVertically) { cItem.reactions.forEach { r -> var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) if (cInfo.featureEnabled(ChatFeature.Reactions) && (cItem.allowAddReaction || r.userReacted)) { @@ -93,13 +92,14 @@ fun ChatItemView( } } Row(modifier.padding(2.dp)) { - Text(r.reaction.text, fontSize = 12.sp) + ReactionIcon(r.reaction.text, fontSize = 12.sp) if (r.totalReacted > 1) { Spacer(Modifier.width(4.dp)) Text("${r.totalReacted}", fontSize = 11.5.sp, fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal, color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = if (appPlatform.isAndroid) Modifier else Modifier.padding(top = 4.dp) ) } } @@ -145,7 +145,7 @@ fun ChatItemView( } } if (rs.isNotEmpty()) { - Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState())) { + Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { rs.forEach() { r -> Box( Modifier.size(36.dp).clickable { @@ -154,7 +154,7 @@ fun ChatItemView( }, contentAlignment = Alignment.Center ) { - Text(r.text) + ReactionIcon(r.text, 12.sp) } } } @@ -191,7 +191,7 @@ fun ChatItemView( clipboard.setText(AnnotatedString(cItem.content.text)) showMenu.value = false }) - if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice && getLoadedFilePath(cItem.file) != null) { + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { @@ -324,6 +324,9 @@ fun ChatItemView( } } +@Composable +expect fun ReactionIcon(text: String, fontSize: TextUnit = TextUnit.Unspecified) + @Composable expect fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index d99de4019..3ede737ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -10,9 +10,11 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.MREmojiChar +import chat.simplex.common.ui.theme.EmojiFont -val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp) -val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp) +val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont) +val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont) @Composable fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) { @@ -26,10 +28,7 @@ fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) { } @Composable -fun EmojiText(text: String) { - val s = text.trim() - Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont) -} +expect fun EmojiText(text: String) // https://stackoverflow.com/a/46279500 private const val emojiStr = "^(" + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 710437c4f..faa7dfdbf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -233,7 +233,9 @@ fun FramedItemView( } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview) - CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick) + Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { + CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick) + } } else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index 3d59aac44..93d0a5676 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -122,7 +122,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel @Composable fun ChatItemLinkView(linkPreview: LinkPreview) { - Column { + Column(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { Image( base64ToBitmap(linkPreview.image), stringResource(MR.strings.image_descr_link_preview), diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf b/apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf new file mode 100644 index 000000000..42799e84e Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png new file mode 100644 index 000000000..96536ef63 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png differ diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 3e60a1c9b..b26023951 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -20,8 +20,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir @Composable -actual fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) -> Unit): FileChooserLauncher = - remember { FileChooserLauncher(getContent, onResult) } +actual fun rememberFileChooserLauncher(getContent: Boolean, rememberedValue: Any?, onResult: (URI?) -> Unit): FileChooserLauncher = + remember(rememberedValue) { FileChooserLauncher(getContent, onResult) } @Composable actual fun rememberFileChooserMultipleLauncher(onResult: (List) -> Unit): FileChooserMultipleLauncher = 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 72e163c2e..0185a50fc 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 @@ -1,7 +1,8 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier +import androidx.compose.ui.* +import androidx.compose.ui.graphics.painter.Painter actual fun Modifier.navigationBarsWithImePadding(): Modifier = this @@ -13,3 +14,18 @@ actual fun ProvideWindowInsets( ) { content() } + +@Composable +actual fun Modifier.desktopOnExternalDrag( + enabled: Boolean, + onFiles: (List) -> Unit, + onImage: (Painter) -> Unit, + onText: (String) -> Unit +): Modifier = +onExternalDrag(enabled) { + when(val data = it.dragData) { + is DragData.FilesList -> onFiles(data.readFiles()) + is DragData.Image -> onImage(data.readImage()) + is DragData.Text -> onText(data.readText()) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index 8fabcf828..2121b9cfd 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -16,6 +16,7 @@ enum class DesktopPlatform(val libPath: String, val libExtension: String, val co MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath); fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64 + fun isMac() = this == MAC_X86_64 || this == MAC_AARCH64 } private fun detectDesktopPlatform(): DesktopPlatform { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt index 902675236..082036558 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt @@ -2,6 +2,7 @@ package chat.simplex.common.ui.theme import androidx.compose.ui.text.font.* import androidx.compose.ui.text.platform.Font +import chat.simplex.common.platform.desktopPlatform import chat.simplex.res.MR actual val Inter: FontFamily = FontFamily( @@ -12,3 +13,16 @@ actual val Inter: FontFamily = FontFamily( Font(MR.fonts.Inter.medium.file, FontWeight.Medium), Font(MR.fonts.Inter.light.file, FontWeight.Light) ) + +actual val EmojiFont: FontFamily = if (desktopPlatform.isMac()) { + FontFamily.Default +} else { + FontFamily( + Font(MR.fonts.NotoColorEmoji.regular.file), + Font(MR.fonts.NotoColorEmoji.regular.file, style = FontStyle.Italic), + Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Bold), + Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.SemiBold), + Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Medium), + Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Light) + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 6f4878cfe..9b265a5f5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -1,21 +1,39 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.FileChooserLauncher +import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +@Composable +actual fun ReactionIcon(text: String, fontSize: TextUnit) { + if (desktopPlatform.isMac() && isHeartEmoji(text)) { + val sp = with(LocalDensity.current) { (fontSize.value + 8).sp.toDp() } + Image(painterResource(MR.images.ic_heart), null, Modifier.size(sp).padding(top = 4.dp, bottom = 2.dp)) + } else { + Text(text, fontSize = fontSize, fontFamily = EmojiFont) + } +} + @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { when (cItem.content.msgContent) { - is MsgContent.MCImage -> saveImage(getAppFileUri(cItem.file?.fileName ?: return@ItemAction)) - is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } + is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } else -> {} } showMenu.value = false diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt new file mode 100644 index 000000000..05ed7002e --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt @@ -0,0 +1,27 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.MREmojiChar +import chat.simplex.common.platform.desktopPlatform +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +@Composable +actual fun EmojiText(text: String) { + val s = text.trim() + if (desktopPlatform.isMac() && isHeartEmoji(s)) { + Image(painterResource(MR.images.ic_heart), null, Modifier.height(62.dp).width(54.dp).padding(vertical = 8.dp)) + } else { + Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont) + } +} + +/** [MREmojiChar.Heart.value] */ +fun isHeartEmoji(s: String): Boolean = + (s.codePoints().count() == 2L && s.codePointAt(0) == 10084 && s.codePointAt(1) == 65039) || + s.codePoints().count() == 1L && s.codePointAt(0) == 10084 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt index 544c15abe..979857f04 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt @@ -43,7 +43,7 @@ actual fun GetImageBottomSheet( hideBottomSheet() } } - val pickImageLauncher = rememberFileChooserLauncher(true, processPickedImage) + val pickImageLauncher = rememberFileChooserLauncher(true, null, processPickedImage) ActionButton(null, stringResource(MR.strings.from_gallery_button), icon = painterResource(MR.images.ic_image)) { withApi { pickImageLauncher.launch("image/*") } } diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index c3c18b132..1d463a9de 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -1,4 +1,6 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.util.capitalizeDecapitalize.toLowerCaseAsciiOnly +import java.util.* plugins { kotlin("multiplatform") @@ -64,7 +66,12 @@ compose { appCategory = "public.app-category.social-networking" bundleID = "chat.simplex.app" } - packageName = "simplex" + val os = System.getProperty("os.name", "generic").toLowerCaseAsciiOnly() + if (os.contains("mac") || os.contains("win")) { + packageName = "SimpleX" + } else { + packageName = "simplex" + } // Packaging requires to have version like MAJOR.MINOR.PATCH var adjustedVersion = rootProject.extra["desktop.version_name"] as String adjustedVersion = adjustedVersion.replace(Regex("[^0-9.]"), "") diff --git a/package.yaml b/package.yaml index 9b588b1ba..80749cdc0 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.3.0.1 +version: 5.3.0.2 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f2ff5f8cc..2c7d84ab7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.3.0.1 +version: 5.3.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index aa896cb35..79745f198 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -193,6 +193,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty filesFolder <- newTVarIO optFilesFolder + incognitoMode <- newTVarIO False chatStoreChanged <- newTVarIO False expireCIThreads <- newTVarIO M.empty expireCIFlags <- newTVarIO M.empty @@ -201,7 +202,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} where configServers :: DefaultAgentServers configServers = @@ -472,6 +473,9 @@ processChatCommand = \case APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ + SetIncognito onOff -> do + asks incognitoMode >>= atomically . (`writeTVar` onOff) + ok_ APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -926,9 +930,10 @@ processChatCommand = \case pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - APIAcceptContact incognito connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do + APIAcceptContact connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do (user, cReq) <- withStore $ \db -> getContactRequest' db connReqId -- [incognito] generate profile to send, create connection with incognito profile + incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequest user cReq incognitoProfile pure $ CRAcceptingContactRequest user ct @@ -1234,45 +1239,32 @@ processChatCommand = \case EnableGroupMember gName mName -> withMemberName gName mName $ \gId mId -> APIEnableGroupMember gId mId ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - APIAddContact userId incognito -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do + APIAddContact userId -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do -- [incognito] generate profile for connection + incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile toView $ CRNewContactConnection user conn - pure $ CRInvitation user cReq conn - AddContact incognito -> withUser $ \User {userId} -> - processChatCommand $ APIAddContact userId incognito - APISetConnectionIncognito connId incognito -> withUser $ \user@User {userId} -> do - conn'_ <- withStore $ \db -> do - conn@PendingContactConnection {pccConnStatus, customUserProfileId} <- getPendingContactConnection db userId connId - case (pccConnStatus, customUserProfileId, incognito) of - (ConnNew, Nothing, True) -> liftIO $ do - incognitoProfile <- generateRandomProfile - pId <- createIncognitoProfile db user incognitoProfile - Just <$> updatePCCIncognito db user conn (Just pId) - (ConnNew, Just pId, False) -> liftIO $ do - deletePCCIncognitoProfile db user pId - Just <$> updatePCCIncognito db user conn Nothing - _ -> pure Nothing - case conn'_ of - Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' - Nothing -> throwChatError CEConnectionIncognitoChangeProhibited - APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do + pure $ CRInvitation user cReq + AddContact -> withUser $ \User {userId} -> + processChatCommand $ APIAddContact userId + APIConnect userId (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do -- [incognito] generate profile to send + incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq . directMessage $ XInfo profileToSend conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend toView $ CRNewContactConnection user conn pure $ CRSentConfirmation user - APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq - APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq - Connect incognito cReqUri -> withUser $ \User {userId} -> - processChatCommand $ APIConnect userId incognito cReqUri - ConnectSimplex incognito -> withUser $ \user -> + APIConnect userId (Just (ACR SCMContact cReq)) -> withUserId userId (`connectViaContact` cReq) + APIConnect _ Nothing -> throwChatError CEInvalidConnReq + Connect cReqUri -> withUser $ \User {userId} -> + processChatCommand $ APIConnect userId cReqUri + ConnectSimplex -> withUser $ \user -> -- [incognito] generate profile to send - connectViaContact user incognito adminContactReq + connectViaContact user adminContactReq DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> @@ -1316,9 +1308,9 @@ processChatCommand = \case pure $ CRUserContactLinkUpdated user contactLink AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> processChatCommand $ APIAddressAutoAccept userId autoAccept_ - AcceptContact incognito cName -> withUser $ \User {userId} -> do + AcceptContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName - processChatCommand $ APIAcceptContact incognito connReqId + processChatCommand $ APIAcceptContact connReqId RejectContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName processChatCommand $ APIRejectContact connReqId @@ -1762,8 +1754,8 @@ processChatCommand = \case CTDirect -> withStore $ \db -> getDirectChatItemIdByText' db user cId msg CTGroup -> withStore $ \db -> getGroupChatItemIdByText' db user cId msg _ -> throwChatError $ CECommandError "not supported" - connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> m ChatResponse - connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do + connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse + connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case (Just contact, _) -> pure $ CRContactAlreadyExists user contact @@ -1771,6 +1763,11 @@ processChatCommand = \case let randomXContactId = XContactId <$> drgRandomBytes 16 xContactId <- maybe randomXContactId pure xContactId_ -- [incognito] generate profile to send + -- if user makes a contact request using main profile, then turns on incognito mode and repeats the request, + -- an incognito profile will be sent even though the address holder will have user's main profile received as well; + -- we ignore this edge case as we already allow profile updates on repeat contact requests; + -- alternatively we can re-send the main profile even if incognito mode is enabled + incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId) @@ -3439,7 +3436,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" - probeMatchingContacts :: Contact -> IncognitoEnabled -> m () + probeMatchingContacts :: Contact -> Bool -> m () probeMatchingContacts ct connectedIncognito = do gVar <- asks idsDrg (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId ct @@ -5036,7 +5033,7 @@ chatCommandP = "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), "/_delete " *> (APIDeleteChat <$> chatRefP), "/_clear chat " *> (APIClearChat <$> chatRefP), - "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), + "/_accept " *> (APIAcceptContact <$> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayName <*> pure defaultCallType), @@ -5115,7 +5112,6 @@ chatCommandP = ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, ("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts, ("/help address" <|> "/ha") $> ChatHelp HSMyAddress, - "/help incognito" $> ChatHelp HSIncognito, ("/help messages" <|> "/hm") $> ChatHelp HSMessages, ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, @@ -5149,11 +5145,10 @@ chatCommandP = (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, - "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), - "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), - "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), - ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), - ("/connect" <|> "/c") *> (AddContact <$> incognitoP), + "/_connect " *> (APIConnect <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + "/_connect " *> (APIAddContact <$> A.decimal), + ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + ("/connect" <|> "/c") $> AddContact, SendMessage <$> chatNameP <* A.space <*> msgTextP, "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")), (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), @@ -5179,7 +5174,7 @@ chatCommandP = "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal), ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), - "/simplex" *> (ConnectSimplex <$> incognitoP), + "/simplex" $> ConnectSimplex, "/_address " *> (APICreateMyAddress <$> A.decimal), ("/address" <|> "/ad") $> CreateMyAddress, "/_delete_address " *> (APIDeleteMyAddress <$> A.decimal), @@ -5190,7 +5185,7 @@ chatCommandP = ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), "/auto_accept " *> (AddressAutoAccept <$> autoAcceptP), - ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayName), + ("/accept " <|> "/ac ") *> char_ '@' *> (AcceptContact <$> displayName), ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName), ("/markdown" <|> "/m") $> ChatHelp HSMarkdown, ("/welcome" <|> "/w") $> Welcome, @@ -5212,7 +5207,7 @@ chatCommandP = "/set disappear #" *> (SetGroupTimedMessages <$> displayName <*> (A.space *> timedTTLOnOffP)), "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), - ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, + "/incognito " *> (SetIncognito <$> onOffP), ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, @@ -5221,8 +5216,6 @@ chatCommandP = ] where choice = A.choice . map (\p -> p <* A.takeWhile (== ' ') <* A.endOfInput) - incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False - incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P)) chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup <|> A.char ':' $> CTContactConnection diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 06e200954..90f90fdcb 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -176,6 +176,7 @@ data ChatController = ChatController currentCalls :: TMap ContactId Call, config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, + incognitoMode :: TVar Bool, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), @@ -186,7 +187,7 @@ data ChatController = ChatController logFilePath :: Maybe FilePath } -data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase +data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSMarkdown | HSMessages | HSSettings | HSDatabase deriving (Show, Generic) instance ToJSON HelpSection where @@ -222,6 +223,7 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) + | SetIncognito Bool | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig @@ -242,7 +244,7 @@ data ChatCommand | APIChatUnread ChatRef Bool | APIDeleteChat ChatRef | APIClearChat ChatRef - | APIAcceptContact IncognitoEnabled Int64 + | APIAcceptContact Int64 | APIRejectContact Int64 | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType @@ -320,12 +322,11 @@ data ChatCommand | EnableGroupMember GroupName ContactName | ChatHelp HelpSection | Welcome - | APIAddContact UserId IncognitoEnabled - | AddContact IncognitoEnabled - | APISetConnectionIncognito Int64 IncognitoEnabled - | APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri) - | Connect IncognitoEnabled (Maybe AConnectionRequestUri) - | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) + | APIAddContact UserId + | AddContact + | APIConnect UserId (Maybe AConnectionRequestUri) + | Connect (Maybe AConnectionRequestUri) + | ConnectSimplex -- UserId (not used in UI) | DeleteContact ContactName | ClearContact ContactName | APIListContacts UserId @@ -340,7 +341,7 @@ data ChatCommand | SetProfileAddress Bool | APIAddressAutoAccept UserId (Maybe AutoAccept) | AddressAutoAccept (Maybe AutoAccept) - | AcceptContact IncognitoEnabled ContactName + | AcceptContact ContactName | RejectContact ContactName | SendMessage ChatName Text | SendLiveMessage ChatName Text @@ -466,8 +467,7 @@ data ChatResponse | CRUserProfileNoChange {user :: User} | CRUserPrivacy {user :: User, updatedUser :: User} | CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]} - | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} - | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} + | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation} | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} @@ -876,7 +876,6 @@ data ChatErrorType | CEServerProtocol {serverProtocol :: AProtocolType} | CEAgentCommandError {message :: String} | CEInvalidFileDescription {message :: String} - | CEConnectionIncognitoChangeProhibited | CEInternalError {message :: String} | CEException {message :: String} deriving (Show, Exception, Generic) diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index c2a572063..c83e81a9e 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -8,7 +8,6 @@ module Simplex.Chat.Help groupsHelpInfo, contactsHelpInfo, myAddressHelpInfo, - incognitoHelpInfo, messagesHelpInfo, markdownInfo, settingsInfo, @@ -49,7 +48,7 @@ chatWelcome user = "Welcome " <> green userName <> "!", "Thank you for installing SimpleX Chat!", "", - "Connect to SimpleX Chat developers for any questions - just type " <> highlight "/simplex", + "Connect to SimpleX Chat lead developer for any questions - just type " <> highlight "/simplex", "", "Follow our updates:", "> Reddit: https://www.reddit.com/r/SimpleXChat/", @@ -214,26 +213,6 @@ myAddressHelpInfo = "The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"] ] -incognitoHelpInfo :: [StyledString] -incognitoHelpInfo = - map - styleMarkdown - [ markdown (colored Red) "/incognito" <> " command is deprecated, use commands below instead.", - "", - "Incognito mode protects the privacy of your main profile — you can choose to create a new random profile for each new contact.", - "It allows having many anonymous connections without any shared data between them in a single chat profile.", - "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.", - "", - green "Incognito commands:", - indent <> highlight "/connect incognito " <> " - create new invitation link using incognito profile", - indent <> highlight "/connect incognito " <> " - accept invitation using incognito profile", - indent <> highlight "/accept incognito " <> " - accept contact request using incognito profile", - indent <> highlight "/simplex incognito " <> " - connect to SimpleX Chat developers using incognito profile", - "", - "The commands may be abbreviated: " <> listHighlight ["/c i", "/c i ", "/ac i "], - "To find the profile used for an incognito connection, use " <> highlight "/info " <> "." - ] - messagesHelpInfo :: [StyledString] messagesHelpInfo = map @@ -290,6 +269,7 @@ settingsInfo = map styleMarkdown [ green "Chat settings:", + indent <> highlight "/incognito on/off " <> " - enable/disable incognito mode", indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/smp " <> " - show / set configured SMP servers", indent <> highlight "/xftp " <> " - show / set configured XFTP servers", @@ -305,12 +285,12 @@ databaseHelpInfo :: [StyledString] databaseHelpInfo = map styleMarkdown - [ green "Database export:", - indent <> highlight "/db export " <> " - create database export file that can be imported in mobile apps", - indent <> highlight "/files_folder " <> " - set files folder path to include app files in the exported archive", - "", - green "Database encryption:", - indent <> highlight "/db encrypt " <> " - encrypt chat database with key/passphrase", - indent <> highlight "/db key " <> " - change the key of the encrypted app database", - indent <> highlight "/db decrypt " <> " - decrypt chat database" - ] + [ green "Database export:", + indent <> highlight "/db export " <> " - create database export file that can be imported in mobile apps", + indent <> highlight "/files_folder " <> " - set files folder path to include app files in the exported archive", + "", + green "Database encryption:", + indent <> highlight "/db encrypt " <> " - encrypt chat database with key/passphrase", + indent <> highlight "/db key " <> " - change the key of the encrypted app database", + indent <> highlight "/db decrypt " <> " - decrypt chat database" + ] diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 9c18350b8..944527ae4 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -17,7 +17,6 @@ module Simplex.Chat.Store.Direct getPendingContactConnection, deletePendingContactConnection, createDirectConnection, - createIncognitoProfile, createConnReqConnection, getProfileById, getConnReqContactXContactId, @@ -34,8 +33,6 @@ module Simplex.Chat.Store.Direct updateContactUserPreferences, updateContactAlias, updateContactConnectionAlias, - updatePCCIncognito, - deletePCCIncognitoProfile, updateContactUsed, updateContactUnreadChat, updateGroupUnreadChat, @@ -174,11 +171,6 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} -createIncognitoProfile :: DB.Connection -> User -> Profile -> IO Int64 -createIncognitoProfile db User {userId} p = do - createdAt <- getCurrentTime - createIncognitoProfile_ db userId createdAt p - createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, image} = do DB.execute @@ -315,30 +307,7 @@ updateContactConnectionAlias db userId conn localAlias = do WHERE user_id = ? AND connection_id = ? |] (localAlias, updatedAt, userId, pccConnId conn) - pure (conn :: PendingContactConnection) {localAlias, updatedAt} - -updatePCCIncognito :: DB.Connection -> User -> PendingContactConnection -> Maybe ProfileId -> IO PendingContactConnection -updatePCCIncognito db User {userId} conn customUserProfileId = do - updatedAt <- getCurrentTime - DB.execute - db - [sql| - UPDATE connections - SET custom_user_profile_id = ?, updated_at = ? - WHERE user_id = ? AND connection_id = ? - |] - (customUserProfileId, updatedAt, userId, pccConnId conn) - pure (conn :: PendingContactConnection) {customUserProfileId, updatedAt} - -deletePCCIncognitoProfile :: DB.Connection -> User -> ProfileId -> IO () -deletePCCIncognitoProfile db User {userId} profileId = - DB.execute - db - [sql| - DELETE FROM contact_profiles - WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 - |] - (userId, profileId) + pure (conn :: PendingContactConnection) {localAlias} updateContactUsed :: DB.Connection -> User -> Contact -> IO () updateContactUsed db User {userId} Contact {contactId} = do diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 4577712f0..ddbe665d7 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -397,14 +397,14 @@ data UserContactLink = UserContactLink instance ToJSON UserContactLink where toEncoding = J.genericToEncoding J.defaultOptions data AutoAccept = AutoAccept - { acceptIncognito :: IncognitoEnabled, + { acceptIncognito :: Bool, autoReply :: Maybe MsgContent } deriving (Show, Generic) instance ToJSON AutoAccept where toEncoding = J.genericToEncoding J.defaultOptions -toUserContactLink :: (ConnReqContact, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink +toUserContactLink :: (ConnReqContact, Bool, Bool, Maybe MsgContent) -> UserContactLink toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) = UserContactLink connReq $ if autoAccept then Just AutoAccept {acceptIncognito, autoReply} else Nothing @@ -452,6 +452,9 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) _ -> (False, False, Nothing) + + + getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p] getProtocolServers db User {userId} = map toServerCfg diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index ad3116695..9e4d3c0e0 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -203,7 +203,7 @@ createContact_ db userId connId Profile {displayName, fullName, image, contactLi pure $ Right (ldn, contactId, profileId) deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO () -deleteUnusedIncognitoProfileById_ db User {userId} profileId = +deleteUnusedIncognitoProfileById_ db User {userId} profile_id = DB.executeNamed db [sql| @@ -218,7 +218,7 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = WHERE user_id = :user_id AND member_profile_id = :profile_id LIMIT 1 ) |] - [":user_id" := userId, ":profile_id" := profileId] + [":user_id" := userId, ":profile_id" := profile_id] type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 379061750..9d9791f1a 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -184,9 +184,7 @@ contactConn = activeConn contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn -type IncognitoEnabled = Bool - -contactConnIncognito :: Contact -> IncognitoEnabled +contactConnIncognito :: Contact -> Bool contactConnIncognito = connIncognito . contactConn contactDirect :: Contact -> Bool @@ -597,7 +595,7 @@ memberConnId GroupMember {activeConn} = aConnId <$> activeConn groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId -memberIncognito :: GroupMember -> IncognitoEnabled +memberIncognito :: GroupMember -> Bool memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId memberSecurityCode :: GroupMember -> Maybe SecurityCode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d1e47b1d4..d6e443401 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -115,7 +115,6 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView HSGroups -> groupsHelpInfo HSContacts -> contactsHelpInfo HSMyAddress -> myAddressHelpInfo - HSIncognito -> incognitoHelpInfo HSMessages -> messagesHelpInfo HSMarkdown -> markdownInfo HSSettings -> settingsInfo @@ -139,8 +138,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRUserPrivacy u u' -> ttyUserPrefix u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info - CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq - CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c + CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] @@ -1150,11 +1148,6 @@ viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias} | localAlias == "" = ["connection " <> sShow pccConnId <> " alias removed"] | otherwise = ["connection " <> sShow pccConnId <> " alias updated: " <> plain localAlias] -viewConnectionIncognitoUpdated :: PendingContactConnection -> [StyledString] -viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserProfileId} - | isJust customUserProfileId = ["connection " <> sShow pccConnId <> " changed to incognito"] - | otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"] - viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated Contact {localDisplayName = n, profile = LocalProfile {fullName, contactLink}} @@ -1546,7 +1539,6 @@ viewChatError logLevel = \case CECommandError e -> ["bad chat command: " <> plain e] CEAgentCommandError e -> ["agent command error: " <> plain e] CEInvalidFileDescription e -> ["invalid file description: " <> plain e] - CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] CEInternalError e -> ["internal chat error: " <> plain e] CEException e -> ["exception: " <> plain e] -- e -> ["chat error: " <> sShow e] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e4cc94d81..6e1e76120 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1817,7 +1817,8 @@ testGroupLinkIncognitoMembership = -- bob connected incognito to alice alice ##> "/c" inv <- getInvitation alice - bob ##> ("/c i " <> inv) + bob #$> ("/incognito on", id, "ok") + bob ##> ("/c " <> inv) bob <## "confirmation sent!" bobIncognito <- getTermLine bob concurrentlyN_ @@ -1826,6 +1827,7 @@ testGroupLinkIncognitoMembership = bob <## "use /i alice to print out this incognito profile again", alice <## (bobIncognito <> ": contact is connected") ] + bob #$> ("/incognito off", id, "ok") -- alice creates group alice ##> "/g team" alice <## "group #team is created" @@ -1868,7 +1870,8 @@ testGroupLinkIncognitoMembership = cath #> ("@" <> bobIncognito <> " hey, I'm cath") bob ?<# "cath> hey, I'm cath" -- dan joins incognito - dan ##> ("/c i " <> gLink) + dan #$> ("/incognito on", id, "ok") + dan ##> ("/c " <> gLink) danIncognito <- getTermLine dan dan <## "connection request sent incognito!" bob <## (danIncognito <> ": accepting request to join group #team...") @@ -1895,6 +1898,7 @@ testGroupLinkIncognitoMembership = cath <## ("#team: " <> bobIncognito <> " added " <> danIncognito <> " to the group (connecting...)") cath <## ("#team: new member " <> danIncognito <> " is connected") ] + dan #$> ("/incognito off", id, "ok") bob ?#> ("@" <> danIncognito <> " hi, I'm incognito") dan ?<# (bobIncognito <> "> hi, I'm incognito") dan ?#> ("@" <> bobIncognito <> " hey, me too") @@ -2002,6 +2006,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => FilePath -> I testGroupLinkIncognitoUnusedHostContactsDeleted = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + bob #$> ("/incognito on", id, "ok") bobIncognitoTeam <- createGroupBobIncognito alice bob "team" "alice" bobIncognitoClub <- createGroupBobIncognito alice bob "club" "alice_1" bobIncognitoTeam `shouldNotBe` bobIncognitoClub @@ -2031,7 +2036,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = alice <## ("to add members use /a " <> group <> " or /create link #" <> group) alice ##> ("/create link #" <> group) gLinkTeam <- getGroupLink alice group GRMember True - bob ##> ("/c i " <> gLinkTeam) + bob ##> ("/c " <> gLinkTeam) bobIncognito <- getTermLine bob bob <## "connection request sent incognito!" alice <## (bobIncognito <> ": accepting request to join group #" <> group <> "...") diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 1229aede0..08d33df1d 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -27,15 +27,10 @@ chatProfileTests = do it "delete connection requests when contact link deleted" testDeleteConnectionRequests it "auto-reply message" testAutoReplyMessage it "auto-reply message in incognito" testAutoReplyMessageInIncognito - describe "incognito" $ do + describe "incognito mode" $ do it "connect incognito via invitation link" testConnectIncognitoInvitationLink it "connect incognito via contact address" testConnectIncognitoContactAddress it "accept contact request incognito" testAcceptContactRequestIncognito - it "set connection incognito" testSetConnectionIncognito - it "reset connection incognito" testResetConnectionIncognito - it "set connection incognito prohibited during negotiation" testSetConnectionIncognitoProhibitedDuringNegotiation - it "connection incognito unchanged errors" testConnectionIncognitoUnchangedErrors - it "set, reset, set connection incognito" testSetResetSetConnectionIncognito it "join group incognito" testJoinGroupIncognito it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito @@ -494,9 +489,11 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - alice ##> "/connect incognito" + alice #$> ("/incognito on", id, "ok") + bob #$> ("/incognito on", id, "ok") + alice ##> "/c" inv <- getInvitation alice - bob ##> ("/connect incognito " <> inv) + bob ##> ("/c " <> inv) bob <## "confirmation sent!" bobIncognito <- getTermLine bob aliceIncognito <- getTermLine alice @@ -508,6 +505,9 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi alice <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> aliceIncognito) alice <## ("use /i " <> bobIncognito <> " to print out this incognito profile again") ] + -- after turning incognito mode off conversation is incognito + alice #$> ("/incognito off", id, "ok") + bob #$> ("/incognito off", id, "ok") alice ?#> ("@" <> bobIncognito <> " psst, I'm incognito") bob ?<# (aliceIncognito <> "> psst, I'm incognito") bob ?#> ("@" <> aliceIncognito <> " me too") @@ -569,7 +569,8 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" cLink <- getContactLink alice True - bob ##> ("/c i " <> cLink) + bob #$> ("/incognito on", id, "ok") + bob ##> ("/c " <> cLink) bobIncognito <- getTermLine bob bob <## "connection request sent incognito!" alice <## (bobIncognito <> " wants to connect to you!") @@ -584,7 +585,9 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ bob <## "use /i alice to print out this incognito profile again", alice <## (bobIncognito <> ": contact is connected") ] - -- conversation is incognito + -- after turning incognito mode off conversation is incognito + alice #$> ("/incognito off", id, "ok") + bob #$> ("/incognito off", id, "ok") alice #> ("@" <> bobIncognito <> " who are you?") bob ?<# "alice> who are you?" bob ?#> "@alice I'm Batman" @@ -602,162 +605,39 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ bob `hasContactProfiles` ["bob"] testAcceptContactRequestIncognito :: HasCallStack => FilePath -> IO () -testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do +testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $ + \alice bob -> do alice ##> "/ad" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) alice <#? bob - alice ##> "/accept incognito bob" + alice #$> ("/incognito on", id, "ok") + alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request..." - aliceIncognitoBob <- getTermLine alice + aliceIncognito <- getTermLine alice concurrentlyN_ - [ bob <## (aliceIncognitoBob <> ": contact is connected"), + [ bob <## (aliceIncognito <> ": contact is connected"), do - alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognitoBob) + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) alice <## "use /i bob to print out this incognito profile again" ] - -- conversation is incognito + -- after turning incognito mode off conversation is incognito + alice #$> ("/incognito off", id, "ok") + bob #$> ("/incognito off", id, "ok") alice ?#> "@bob my profile is totally inconspicuous" - bob <# (aliceIncognitoBob <> "> my profile is totally inconspicuous") - bob #> ("@" <> aliceIncognitoBob <> " I know!") + bob <# (aliceIncognito <> "> my profile is totally inconspicuous") + bob #> ("@" <> aliceIncognito <> " I know!") alice ?<# "bob> I know!" -- list contacts alice ##> "/contacts" alice <## "i bob (Bob)" - alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognitoBob] + alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] -- delete contact, incognito profile is deleted alice ##> "/d bob" alice <## "bob: contact is deleted" alice ##> "/contacts" (alice ("/c " <> cLink) - alice <#? cath - alice ##> "/_accept incognito=on 1" - alice <## "cath (Catherine): accepting contact request..." - aliceIncognitoCath <- getTermLine alice - concurrentlyN_ - [ cath <## (aliceIncognitoCath <> ": contact is connected"), - do - alice <## ("cath (Catherine): contact is connected, your incognito profile for this contact is " <> aliceIncognitoCath) - alice <## "use /i cath to print out this incognito profile again" - ] - alice `hasContactProfiles` ["alice", "cath", T.pack aliceIncognitoCath] - cath `hasContactProfiles` ["cath", T.pack aliceIncognitoCath] - -testSetConnectionIncognito :: HasCallStack => FilePath -> IO () -testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ - \alice bob -> do - alice ##> "/connect" - inv <- getInvitation alice - alice ##> "/_set incognito :1 on" - alice <## "connection 1 changed to incognito" - bob ##> ("/connect " <> inv) - bob <## "confirmation sent!" - aliceIncognito <- getTermLine alice - concurrentlyN_ - [ bob <## (aliceIncognito <> ": contact is connected"), - do - alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) - alice <## ("use /i bob to print out this incognito profile again") - ] - alice ?#> ("@bob hi") - bob <# (aliceIncognito <> "> hi") - bob #> ("@" <> aliceIncognito <> " hey") - alice ?<# ("bob> hey") - alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] - bob `hasContactProfiles` ["bob", T.pack aliceIncognito] - -testResetConnectionIncognito :: HasCallStack => FilePath -> IO () -testResetConnectionIncognito = testChat2 aliceProfile bobProfile $ - \alice bob -> do - alice ##> "/_connect 1 incognito=on" - inv <- getInvitation alice - alice ##> "/_set incognito :1 off" - alice <## "connection 1 changed to non incognito" - bob ##> ("/c " <> inv) - bob <## "confirmation sent!" - concurrently_ - (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") - alice <##> bob - alice `hasContactProfiles` ["alice", "bob"] - bob `hasContactProfiles` ["alice", "bob"] - -testSetConnectionIncognitoProhibitedDuringNegotiation :: HasCallStack => FilePath -> IO () -testSetConnectionIncognitoProhibitedDuringNegotiation tmp = do - inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do - threadDelay 250000 - alice ##> "/connect" - getInvitation alice - withNewTestChat tmp "bob" bobProfile $ \bob -> do - threadDelay 250000 - bob ##> ("/c " <> inv) - bob <## "confirmation sent!" - withTestChat tmp "alice" $ \alice -> do - threadDelay 250000 - alice ##> "/_set incognito :1 on" - alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" - withTestChat tmp "bob" $ \bob -> do - concurrently_ - (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") - alice <##> bob - alice `hasContactProfiles` ["alice", "bob"] - bob `hasContactProfiles` ["alice", "bob"] - -testConnectionIncognitoUnchangedErrors :: HasCallStack => FilePath -> IO () -testConnectionIncognitoUnchangedErrors = testChat2 aliceProfile bobProfile $ - \alice bob -> do - alice ##> "/connect" - inv <- getInvitation alice - alice ##> "/_set incognito :1 off" - alice <## "incognito mode change prohibited" - alice ##> "/_set incognito :1 on" - alice <## "connection 1 changed to incognito" - alice ##> "/_set incognito :1 on" - alice <## "incognito mode change prohibited" - alice ##> "/_set incognito :1 off" - alice <## "connection 1 changed to non incognito" - alice ##> "/_set incognito :1 off" - alice <## "incognito mode change prohibited" - bob ##> ("/c " <> inv) - bob <## "confirmation sent!" - concurrently_ - (bob <## "alice (Alice): contact is connected") - (alice <## "bob (Bob): contact is connected") - alice <##> bob - alice `hasContactProfiles` ["alice", "bob"] - bob `hasContactProfiles` ["alice", "bob"] - -testSetResetSetConnectionIncognito :: HasCallStack => FilePath -> IO () -testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ - \alice bob -> do - alice ##> "/_connect 1 incognito=off" - inv <- getInvitation alice - alice ##> "/_set incognito :1 on" - alice <## "connection 1 changed to incognito" - alice ##> "/_set incognito :1 off" - alice <## "connection 1 changed to non incognito" - alice ##> "/_set incognito :1 on" - alice <## "connection 1 changed to incognito" - bob ##> ("/_connect 1 incognito=off " <> inv) - bob <## "confirmation sent!" - aliceIncognito <- getTermLine alice - concurrentlyN_ - [ bob <## (aliceIncognito <> ": contact is connected"), - do - alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) - alice <## ("use /i bob to print out this incognito profile again") - ] - alice ?#> ("@bob hi") - bob <# (aliceIncognito <> "> hi") - bob #> ("@" <> aliceIncognito <> " hey") - alice ?<# ("bob> hey") - alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] - bob `hasContactProfiles` ["bob", T.pack aliceIncognito] testJoinGroupIncognito :: HasCallStack => FilePath -> IO () testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $ @@ -771,7 +651,8 @@ testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfil -- cath connected incognito to alice alice ##> "/c" inv <- getInvitation alice - cath ##> ("/c i " <> inv) + cath #$> ("/incognito on", id, "ok") + cath ##> ("/c " <> inv) cath <## "confirmation sent!" cathIncognito <- getTermLine cath concurrentlyN_ @@ -804,8 +685,10 @@ testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfil cath <## "#secret_club: alice invites you to join the group as admin" cath <## ("use /j secret_club to join incognito as " <> cathIncognito) ] - -- cath uses the same incognito profile when joining group, cath and bob don't merge contacts + -- cath uses the same incognito profile when joining group, disabling incognito mode doesn't affect it + cath #$> ("/incognito off", id, "ok") cath ##> "/j secret_club" + -- cath and bob don't merge contacts concurrentlyN_ [ alice <## ("#secret_club: " <> cathIncognito <> " joined the group"), do @@ -951,7 +834,8 @@ testCantInviteContactIncognito :: HasCallStack => FilePath -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- alice connected incognito to bob - alice ##> "/c i" + alice #$> ("/incognito on", id, "ok") + alice ##> "/c" inv <- getInvitation alice bob ##> ("/c " <> inv) bob <## "confirmation sent!" @@ -963,6 +847,7 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ alice <## "use /i bob to print out this incognito profile again" ] -- alice creates group non incognito + alice #$> ("/incognito off", id, "ok") alice ##> "/g club" alice <## "group #club is created" alice <## "to add members use /a club or /create link #club" @@ -974,8 +859,10 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ testCantSeeGlobalPrefsUpdateIncognito :: HasCallStack => FilePath -> IO () testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - alice ##> "/c i" + alice #$> ("/incognito on", id, "ok") + alice ##> "/c" invIncognito <- getInvitation alice + alice #$> ("/incognito off", id, "ok") alice ##> "/c" inv <- getInvitation alice bob ##> ("/c " <> invIncognito) @@ -1028,7 +915,8 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr -- bob connects incognito to alice alice ##> "/c" inv <- getInvitation alice - bob ##> ("/c i " <> inv) + bob #$> ("/incognito on", id, "ok") + bob ##> ("/c " <> inv) bob <## "confirmation sent!" bobIncognito <- getTermLine bob concurrentlyN_ @@ -1079,7 +967,8 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr -- bob connects incognito to alice alice ##> "/c" inv <- getInvitation alice - bob ##> ("/c i " <> inv) + bob #$> ("/incognito on", id, "ok") + bob ##> ("/c " <> inv) bob <## "confirmation sent!" bobIncognito <- getTermLine bob concurrentlyN_