Merge branch 'master' into master-ios
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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<String>) -> Unit,
|
||||
onImage: (Painter) -> Unit,
|
||||
onText: (String) -> Unit
|
||||
): Modifier = this
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Boolean>) {
|
||||
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<URI>) -> Unit): FileChooserMultipleLauncher
|
||||
|
||||
|
||||
@@ -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<String>) -> Unit = {},
|
||||
onImage: (Painter) -> Unit = {},
|
||||
onText: (String) -> Unit = {}
|
||||
): Modifier
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<URI>, String?) -> Unit
|
||||
)
|
||||
|
||||
fun MutableState<ComposeState>.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<ComposeState>.processPickedMedia(uris: List<URI>, text: String?) {
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
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<String?>(null) }
|
||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||
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<URI>, text: String? ->
|
||||
val content = ArrayList<UploadContent>()
|
||||
val imagesPreview = ArrayList<String>()
|
||||
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<RecordingState> = 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
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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<Boolean>)
|
||||
|
||||
|
||||
@@ -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 = "^(" +
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -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<URI>) -> Unit): FileChooserMultipleLauncher =
|
||||
|
||||
@@ -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<String>) -> 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Boolean>) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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/*") }
|
||||
}
|
||||
|
||||
@@ -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.]"), "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <invitation> " <> " - accept invitation using incognito profile",
|
||||
indent <> highlight "/accept incognito <name> " <> " - 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 <invitation>", "/ac i <name>"],
|
||||
"To find the profile used for an incognito connection, use " <> highlight "/info <contact>" <> "."
|
||||
]
|
||||
|
||||
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 <path> " <> " - set files folder path to include app files in the exported archive",
|
||||
"",
|
||||
green "Database encryption:",
|
||||
indent <> highlight "/db encrypt <key> " <> " - encrypt chat database with key/passphrase",
|
||||
indent <> highlight "/db key <current> <new>" <> " - change the key of the encrypted app database",
|
||||
indent <> highlight "/db decrypt <key> " <> " - 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 <path> " <> " - set files folder path to include app files in the exported archive",
|
||||
"",
|
||||
green "Database encryption:",
|
||||
indent <> highlight "/db encrypt <key> " <> " - encrypt chat database with key/passphrase",
|
||||
indent <> highlight "/db key <current> <new>" <> " - change the key of the encrypted app database",
|
||||
indent <> highlight "/db decrypt <key> " <> " - decrypt chat database"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 <> " <name> 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 <> "...")
|
||||
|
||||
@@ -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 <> " <whispering> 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 </)
|
||||
alice `hasContactProfiles` ["alice"]
|
||||
-- /_accept api
|
||||
cath ##> ("/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 <name> 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_
|
||||
|
||||
Reference in New Issue
Block a user