diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 77969cc338..7ae480de72 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 12 - versionName "1.2" + versionCode 16 + versionName "1.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { @@ -41,6 +41,7 @@ android { kotlinOptions { jvmTarget = '1.8' freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi" + freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi" freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi" freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi" freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets" diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 20fff5ec83..18288bc4f1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -112,9 +112,8 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) { } //fun testJson() { -// val str = """ -// {} +// val str: String = """ // """.trimIndent() // -// println(json.decodeFromString(str)) +// println(json.decodeFromString(str)) //} diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/BGManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/BGManager.kt index d274a98f6d..9894713752 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/BGManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/BGManager.kt @@ -4,10 +4,8 @@ import android.content.Context import android.util.Log import androidx.work.* import chat.simplex.app.TAG -import chat.simplex.app.chatRecvMsg import kotlinx.datetime.Clock import java.time.Duration -import java.util.concurrent.TimeUnit class BGManager(appContext: Context, workerParams: WorkerParameters): //, ctrl: ChatCtrl): Worker(appContext, workerParams) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index e8202f5c0b..d91262a249 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -241,9 +241,9 @@ data class Chat ( val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…" val statusExplanation: String get() = when { - this is Connected -> "You are connected to the server you use to receve messages from this contact." - this is Error -> "Trying to connect to the server you use to receve messages from this contact (error: $error)." - else -> "Trying to connect to the server you use to receve messages from this contact." + this is Connected -> "You are connected to the server used to receive messages from this contact." + this is Error -> "Trying to connect to the server used to receive messages from this contact (error: $error)." + else -> "Trying to connect to the server used to receive messages from this contact." } @Serializable @SerialName("unknown") class Unknown: NetworkStatus() @@ -466,24 +466,31 @@ data class ChatItem ( val chatDir: CIDirection, val meta: CIMeta, val content: CIContent, - val formattedText: List? = null + val formattedText: List? = null, + val quotedItem: CIQuote? = null ) { val id: Long get() = meta.itemId val timestampText: String get() = meta.timestampText val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew + val memberDisplayName: String? get() = + if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName + else null + companion object { fun getSampleData( id: Long = 1, dir: CIDirection = CIDirection.DirectSnd(), ts: Instant = Clock.System.now(), text: String = "hello\nthere", - status: CIStatus = CIStatus.SndNew() + status: CIStatus = CIStatus.SndNew(), + quotedItem: CIQuote? = null ) = ChatItem( chatDir = dir, meta = CIMeta.getSample(id, ts, text, status), - content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)) + content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), + quotedItem = quotedItem ) } } @@ -566,9 +573,13 @@ sealed class CIStatus { class RcvRead: CIStatus() } +interface ItemContent { + val text: String +} + @Serializable -sealed class CIContent { - abstract val text: String +sealed class CIContent: ItemContent { + abstract override val text: String @Serializable @SerialName("sndMsgContent") class SndMsgContent(val msgContent: MsgContent): CIContent() { @@ -591,6 +602,31 @@ sealed class CIContent { } } +@Serializable +class CIQuote ( + val chatDir: CIDirection? = null, + val itemId: Long? = null, + val sharedMsgId: String? = null, + val sentAt: Instant, + val content: MsgContent, + val formattedText: List? = null +): ItemContent { + override val text: String get() = content.text + + fun sender(user: User): String? = when (chatDir) { + is CIDirection.DirectSnd -> "you" + is CIDirection.DirectRcv -> null + is CIDirection.GroupSnd -> user.displayName + is CIDirection.GroupRcv -> chatDir.groupMember.memberProfile.displayName + null -> null + } + + companion object { + fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote = + CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text)) + } +} + @Serializable(with = MsgContentSerializer::class) sealed class MsgContent { abstract val text: String diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index f5b54fd8eb..d95da55ccc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -36,7 +36,7 @@ class NtfManager(val context: Context) { val notification = NotificationCompat.Builder(context, MessageChannel) .setContentTitle(cInfo.displayName) - .setContentText(cItem.content.text) + .setContentText(hideSecrets(cItem)) .setPriority(NotificationCompat.PRIORITY_HIGH) .setGroup(MessageGroup) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) @@ -62,6 +62,19 @@ class NtfManager(val context: Context) { } } + private fun hideSecrets(cItem: ChatItem): String { + val md = cItem.formattedText + return if (md == null) { + cItem.content.text + } else { + var res = "" + for (ft in md) { + res += if (ft.format is Format.Secret) "..." else ft.text + } + res + } + } + private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{ Log.d(TAG, "getMsgPendingIntent ${cInfo.id}") val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt() diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 147cf888b0..820a955304 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -122,8 +122,10 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap return null } - suspend fun apiSendMessage(type: ChatType, id: Long, mc: MsgContent): AChatItem? { - val r = sendCmd(CC.ApiSendMessage(type, id, mc)) + suspend fun apiSendMessage(type: ChatType, id: Long, quotedItemId: Long? = null, mc: MsgContent): AChatItem? { + val cmd = if (quotedItemId == null) CC.ApiSendMessage(type, id, mc) + else CC.ApiSendMessageQuote(type, id, quotedItemId, mc) + val r = sendCmd(cmd) if (r is CR.NewChatItem ) return r.chatItem Log.e(TAG, "apiSendMessage bad response: ${r.responseType} ${r.details}") return null @@ -343,6 +345,7 @@ sealed class CC { class ApiGetChats: CC() class ApiGetChat(val type: ChatType, val id: Long): CC() class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC() + class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() class GetUserSMPServers(): CC() class SetUserSMPServers(val smpServers: List): CC() class AddContact: CC() @@ -364,6 +367,7 @@ sealed class CC { is ApiGetChats -> "/_get chats" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}" + is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}" is GetUserSMPServers -> "/smp_servers" is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}" is AddContact -> "/connect" @@ -386,6 +390,7 @@ sealed class CC { is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" + is ApiSendMessageQuote -> "apiSendMessageQuote" is GetUserSMPServers -> "getUserSMPServers" is SetUserSMPServers -> "setUserSMPServers" is AddContact -> "addContact" @@ -422,6 +427,7 @@ class APIResponse(val resp: CR, val corr: String? = null) { json.decodeFromString(str) } catch(e: Exception) { try { + Log.d(TAG, e.localizedMessage) val data = json.parseToJsonElement(str).jsonObject APIResponse( resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index b5061bb55b..0d0e2b146d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -3,18 +3,19 @@ package chat.simplex.app.views.chat import android.content.res.Configuration import android.util.Log import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.* 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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -22,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.TAG import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.ChatItemView import chat.simplex.app.views.helpers.* @@ -35,9 +37,11 @@ import kotlinx.datetime.Clock @Composable fun ChatView(chatModel: ChatModel) { val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } - if (chat == null) { + val user = chatModel.currentUser.value + if (chat == null || user == null) { chatModel.chatId.value = null } else { + val quotedItem = remember { mutableStateOf(null) } BackHandler { chatModel.chatId.value = null } // TODO a more advanced version would mark as read only if in view LaunchedEffect(chat.chatItems) { @@ -54,7 +58,7 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(chat, chatModel.chatItems, + ChatLayout(user, chat, chatModel.chatItems, quotedItem, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, sendMessage = { msg -> @@ -64,8 +68,10 @@ fun ChatView(chatModel: ChatModel) { val newItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, + quotedItemId = quotedItem.value?.meta?.itemId, mc = MsgContent.MCText(msg) ) + quotedItem.value = null // hide "in progress" if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) } @@ -76,7 +82,10 @@ fun ChatView(chatModel: ChatModel) { @Composable fun ChatLayout( - chat: Chat, chatItems: List, + user: User, + chat: Chat, + chatItems: List, + quotedItem: MutableState, back: () -> Unit, info: () -> Unit, sendMessage: (String) -> Unit @@ -88,11 +97,11 @@ fun ChatLayout( ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { SendMsgView(sendMessage) }, + bottomBar = { ComposeView(quotedItem, sendMessage) }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(chatItems) + ChatItemsList(user, chatItems, quotedItem) } } } @@ -152,7 +161,7 @@ val CIListStateSaver = run { } @Composable -fun ChatItemsList(chatItems: List) { +fun ChatItemsList(user: User, chatItems: List, quotedItem: MutableState) { val listState = rememberLazyListState() val keyboardState by getKeyboardState() val ciListState = rememberSaveable(stateSaver = CIListStateSaver) { @@ -160,9 +169,10 @@ fun ChatItemsList(chatItems: List) { } val scope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current + val cxt = LocalContext.current LazyColumn(state = listState) { items(chatItems) { cItem -> - ChatItemView(cItem, uriHandler) + ChatItemView(user, cItem, quotedItem, cxt, uriHandler) } val len = chatItems.count() if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) { @@ -201,12 +211,14 @@ fun PreviewChatLayout() { ) ) ChatLayout( + user = User.sampleData, chat = Chat( chatInfo = ChatInfo.Direct.sampleData, chatItems = chatItems, chatStats = Chat.ChatStats() ), chatItems = chatItems, + quotedItem = remember { mutableStateOf(null) }, back = {}, info = {}, sendMessage = {} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt new file mode 100644 index 0000000000..ebea19ef94 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -0,0 +1,14 @@ +package chat.simplex.app.views.chat + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import chat.simplex.app.model.ChatItem + +@Composable +fun ComposeView(quotedItem: MutableState, sendMessage: (String) -> Unit) { + Column { + QuotedItemView(quotedItem) + SendMsgView(sendMessage) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt new file mode 100644 index 0000000000..5f8f1ac54f --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt @@ -0,0 +1,77 @@ +package chat.simplex.app.views.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.CIDirection +import chat.simplex.app.model.ChatItem +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.item.* +import kotlinx.datetime.Clock + +@Composable +fun QuotedItemView(quotedItem: MutableState) { + val qi = quotedItem.value + if (qi != null) { + val sent = qi.chatDir.sent + Row( + Modifier.padding(top = 8.dp) + .background(if (sent) SentColorLight else ReceivedColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier.padding(start = 16.dp) + .padding(vertical = 12.dp) + .fillMaxWidth() + .weight(1F) + ) { + QuoteText(qi) + } + IconButton(onClick = { quotedItem.value = null }) { + Icon( + Icons.Outlined.Close, + "Remove quote", + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } + } +} + +@Composable +private fun QuoteText(qi: ChatItem) { + val member = qi.memberDisplayName + if (member == null) { + Text(qi.content.text, maxLines = 3) + } else { + val annotatedText = buildAnnotatedString { + withStyle(boldFont) { append(member) } + append(": ${qi.content.text}") + } + Text(annotatedText, maxLines = 3) + } +} + +@Preview +@Composable +fun PreviewTextItemViewEmoji() { + SimpleXTheme { + QuotedItemView( + quotedItem = remember { + mutableStateOf(ChatItem.getSampleData( + 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" + )) + } + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 639933dde5..90248d8818 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -21,14 +21,24 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.item.* @Composable fun SendMsgView(sendMessage: (String) -> Unit) { - var cmd by remember { mutableStateOf("") } + var msg by remember { mutableStateOf("") } + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + var textStyle by remember { mutableStateOf(smallFont) } BasicTextField( - value = cmd, - onValueChange = { cmd = it }, - textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), + value = msg, + onValueChange = { + msg = it + textStyle = if(isShortEmoji(it)) { + if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + } else { + smallFont + } + }, + textStyle = textStyle, maxLines = 16, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -54,7 +64,7 @@ fun SendMsgView(sendMessage: (String) -> Unit) { ) { innerTextField() } - val color = if (cmd.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray + val color = if (msg.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray Icon( Icons.Outlined.ArrowUpward, "Send Message", @@ -65,9 +75,10 @@ fun SendMsgView(sendMessage: (String) -> Unit) { .clip(CircleShape) .background(color) .clickable { - if (cmd.isNotEmpty()) { - sendMessage(cmd) - cmd = "" + if (msg.isNotEmpty()) { + sendMessage(msg) + msg = "" + textStyle = smallFont } } ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index ddcbbdc1a5..acc249bdf7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -1,33 +1,74 @@ package chat.simplex.app.views.chat.item +import android.content.Context +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import chat.simplex.app.model.CIDirection -import chat.simplex.app.model.ChatItem +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.copyText +import chat.simplex.app.views.helpers.shareText import kotlinx.datetime.Clock @Composable -fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) { - val sent = chatItem.chatDir.sent +fun ChatItemView(user: User, cItem: ChatItem, quotedItem: MutableState, cxt: Context, uriHandler: UriHandler? = null) { + val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart - + var showMenu by remember { mutableStateOf(false) } Box( modifier = Modifier .padding(bottom = 4.dp) .fillMaxWidth() .padding( - start = if (sent) 60.dp else 16.dp, - end = if (sent) 16.dp else 60.dp, + start = if (sent) 86.dp else 16.dp, + end = if (sent) 16.dp else 86.dp, ), contentAlignment = alignment, ) { - TextItemView(chatItem, uriHandler) + Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) { + if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem) + } else { + FramedItemView(user, cItem, uriHandler) + } + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + ItemAction("Reply", Icons.Outlined.Reply, onClick = { + quotedItem.value = cItem + showMenu = false + }) + ItemAction("Share", Icons.Outlined.Share, onClick = { + shareText(cxt, cItem.content.text) + showMenu = false + }) + ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = { + copyText(cxt, cItem.content.text) + showMenu = false + }) + } + } + } +} + +@Composable +private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit) { + DropdownMenuItem(onClick) { + Row { + Text(text, modifier = Modifier + .fillMaxWidth() + .weight(1F)) + Icon(icon, text, tint = HighOrLowlight) + } } } @@ -36,9 +77,12 @@ fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) { fun PreviewChatItemView() { SimpleXTheme { ChatItemView( - chatItem = ChatItem.getSampleData( + User.sampleData, + ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" - ) + ), + quotedItem = remember { mutableStateOf(null) }, + cxt = LocalContext.current ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt new file mode 100644 index 0000000000..8d6e59edc1 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt @@ -0,0 +1,42 @@ +package chat.simplex.app.views.chat.item + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.ChatItem + +val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp) +val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp) + +@Composable +fun EmojiItemView(chatItem: ChatItem) { + Column( + Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmojiText(chatItem.content.text) + CIMetaView(chatItem) + } +} + +@Composable +fun EmojiText(text: String) { + val s = text.trim() + Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont) +} + +private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C + +fun isEmoji(c: Int): Boolean = isSimpleEmoji(c) // || isCombinedIntoEmoji(c) + +// TODO count perceived emojis, possibly using icu4j +fun isShortEmoji(str: String): Boolean { + val s = str.trim() + return s.codePoints().count() in 1..5 && s.codePoints().allMatch(::isEmoji) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt new file mode 100644 index 0000000000..abdc15effc --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -0,0 +1,146 @@ +package chat.simplex.app.views.chat.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.SimpleXTheme +import kotlinx.datetime.Clock + +val SentColorLight = Color(0x1E45B8FF) +val ReceivedColorLight = Color(0x20B1B0B5) +val SentQuoteColorLight = Color(0x2545B8FF) +val ReceivedQuoteColorLight = Color(0x25B1B0B5) + +@Composable +fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) { + val sent = ci.chatDir.sent + Surface( + shape = RoundedCornerShape(18.dp), + color = if (sent) SentColorLight else ReceivedColorLight + ) { + Box(contentAlignment = Alignment.BottomEnd) { + Column(Modifier.width(IntrinsicSize.Max)) { + val qi = ci.quotedItem + if (qi != null) { + Box( + Modifier + .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) + .padding(vertical = 6.dp, horizontal = 12.dp) + .fillMaxWidth() + ) { + MarkdownText( + qi, sender = qi.sender(user), senderBold = true, maxLines = 3, + style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) + ) + } + } + Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + if (ci.formattedText == null && isShortEmoji(ci.content.text)) { + Column( + Modifier.padding(bottom = 2.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmojiText(ci.content.text) + Text("") + } + } else { + MarkdownText( + ci.content, ci.formattedText, ci.memberDisplayName, + metaText = ci.timestampText, uriHandler = uriHandler, senderBold = true + ) + } + } + } + Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) { + CIMetaView(ci) + } + } + } +} + +@Preview +@Composable +fun PreviewTextItemViewSnd() { + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" + ) + ) + } +} + +@Preview +@Composable +fun PreviewTextItemViewRcv() { + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" + ) + ) + } +} + +@Preview +@Composable +fun PreviewTextItemViewLong() { + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, + CIDirection.DirectSnd(), + Clock.System.now(), + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + ) + ) + } +} + +@Preview +@Composable +fun PreviewTextItemViewQuote() { + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "https://simplex.chat", + CIStatus.SndSent(), + quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()) + ) + ) + } +} + +@Preview +@Composable +fun PreviewTextItemViewEmoji() { + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "πŸ‘", + CIStatus.SndSent(), + quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()) + ) + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index d21d0749ee..6789833fe2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -1,48 +1,17 @@ package chat.simplex.app.views.chat.item -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.CIDirection -import chat.simplex.app.model.ChatItem -import chat.simplex.app.ui.theme.SimpleXTheme -import kotlinx.datetime.Clock - -// TODO move to theme -val SentColorLight = Color(0x1E45B8FF) -val ReceivedColorLight = Color(0x1EB1B0B5) - -@Composable -fun TextItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) { - val sent = chatItem.chatDir.sent - Surface( - shape = RoundedCornerShape(18.dp), - color = if (sent) SentColorLight else ReceivedColorLight - ) { - Box( - modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp) - ) { - Box(contentAlignment = Alignment.BottomEnd) { - MarkdownText(chatItem, uriHandler = uriHandler, groupMemberBold = true) - CIMetaView(chatItem) - } - } - } -} +import chat.simplex.app.model.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) @@ -56,33 +25,44 @@ fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMembe } } +fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) { + if (sender != null) { + if (senderBold) b.withStyle(boldFont) { append(sender) } + else b.append(sender) + b.append(": ") + } +} + @Composable fun MarkdownText ( - chatItem: ChatItem, + content: ItemContent, + formattedText: List? = null, + sender: String? = null, + metaText: String? = null, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, uriHandler: UriHandler? = null, - groupMemberBold: Boolean = false, + senderBold: Boolean = false, modifier: Modifier = Modifier ) { - if (chatItem.formattedText == null) { + if (formattedText == null) { val annotatedText = buildAnnotatedString { - appendGroupMember(this, chatItem, groupMemberBold) - append(chatItem.content.text) - withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") } - } - SelectionContainer { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) + appendSender(this, sender, senderBold) + append(content.text) + if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") } } + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) } else { + var hasLinks = false val annotatedText = buildAnnotatedString { - appendGroupMember(this, chatItem, groupMemberBold) - for (ft in chatItem.formattedText) { + appendSender(this, sender, senderBold) + for (ft in formattedText) { if (ft.format == null) append(ft.text) else { val link = ft.link if (link != null) { + hasLinks = true withAnnotation(tag = "URL", annotation = link) { withStyle(ft.format.style) { append(ft.text) } } @@ -91,60 +71,17 @@ fun MarkdownText ( } } } - withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") } + if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") } } - if (uriHandler != null) { - SelectionContainer { - ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, - onClick = { offset -> - annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) } - } - ) - } + if (hasLinks && uriHandler != null) { + ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, + onClick = { offset -> + annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) } + } + ) } else { - SelectionContainer { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) - } + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) } } } - -@Preview -@Composable -fun PreviewTextItemViewSnd() { - SimpleXTheme { - TextItemView( - chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" - ) - ) - } -} - -@Preview -@Composable -fun PreviewTextItemViewRcv() { - SimpleXTheme { - TextItemView( - chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" - ) - ) - } -} - -@Preview -@Composable -fun PreviewTextItemViewLong() { - SimpleXTheme { - TextItemView( - chatItem = ChatItem.getSampleData( - 1, - CIDirection.DirectSnd(), - Clock.System.now(), - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." - ) - ) - } -} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt index c12239f580..306797e628 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt @@ -32,7 +32,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { buildAnnotatedString { append("You can ") withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { - append("connect to SimpleX team") + append("connect to SimpleX Chat founder") } append(".") }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 95fc05f7c2..45f7fe5322 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -39,9 +39,11 @@ fun ChatPreviewView(chat: Chat) { fontWeight = FontWeight.Bold ) - if (chat.chatItems.count() > 0) { + val ci = chat.chatItems.lastOrNull() + if (ci != null) { MarkdownText( - chat.chatItems.last(), + ci.content, ci.formattedText, ci.memberDisplayName, + metaText = ci.timestampText, maxLines = 2, overflow = TextOverflow.Ellipsis ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt index be51412768..5cac836f14 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt @@ -1,7 +1,7 @@ package chat.simplex.app.views.helpers -import android.content.Context -import android.content.Intent +import android.content.* +import androidx.core.content.ContextCompat fun shareText(cxt: Context, text: String) { val sendIntent: Intent = Intent().apply { @@ -12,3 +12,8 @@ fun shareText(cxt: Context, text: String) { val shareIntent = Intent.createChooser(sendIntent, null) cxt.startActivity(shareIntent) } + +fun copyText(cxt: Context, text: String) { + val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java) + clipboard?.setPrimaryClip(ClipData.newPlainText("text", text)) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index 548762886b..8c593e1fb0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -21,7 +21,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.* +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.AlertManager import chat.simplex.app.views.helpers.withApi diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 0013f2df35..0734965898 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import chat.simplex.app.BuildConfig import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile @@ -116,7 +117,7 @@ fun SettingsLayout( ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - "Get help & advice via chat", + "Chat with the founder", color = MaterialTheme.colors.primary ) } @@ -128,7 +129,7 @@ fun SettingsLayout( ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - "Ask questions via email", + "Send us email", color = MaterialTheme.colors.primary ) } @@ -167,6 +168,10 @@ fun SettingsLayout( } ) } + Divider(Modifier.padding(horizontal = 8.dp)) + SettingsSectionView(click = {}) { + Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + } } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8e7c6090a4..c553543b3a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -360,9 +360,9 @@ final class Chat: ObservableObject, Identifiable { var statusExplanation: String { get { switch self { - case .connected: return "You are connected to the server you use to receve messages from this contact." - case let .error(err): return "Trying to connect to the server you use to receve messages from this contact (error: \(err))." - default: return "Trying to connect to the server you use to receve messages from this contact." + case .connected: return "You are connected to the server used to receive messages from this contact." + case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: return "Trying to connect to the server used to receive messages from this contact." } } } @@ -527,7 +527,8 @@ struct ChatItem: Identifiable, Decodable { var meta: CIMeta var content: CIContent var formattedText: [FormattedText]? - + var quotedItem: CIQuote? + var id: Int64 { get { meta.itemId } } var timestampText: Text { get { meta.timestampText } } @@ -537,11 +538,22 @@ struct ChatItem: Identifiable, Decodable { return false } - static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> ChatItem { + var memberDisplayName: String? { + get { + if case let .groupRcv(groupMember) = chatDir { + return groupMember.memberProfile.displayName + } else { + return nil + } + } + } + + static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil) -> ChatItem { ChatItem( chatDir: dir, meta: CIMeta.getSample(id, ts, text, status), - content: .sndMsgContent(msgContent: .text(text)) + content: .sndMsgContent(msgContent: .text(text)), + quotedItem: quotedItem ) } } @@ -603,7 +615,11 @@ enum CIStatus: Decodable { case rcvRead } -enum CIContent: Decodable { +protocol ItemContent { + var text: String { get } +} + +enum CIContent: Decodable, ItemContent { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) case sndFileInvitation(fileId: Int64, filePath: String) @@ -625,6 +641,33 @@ struct RcvFileTransfer: Decodable { } +struct CIQuote: Decodable, ItemContent { + var chatDir: CIDirection? + var itemId: Int64? + var sharedMsgId: String? = nil + var sentAt: Date + var content: MsgContent + var formattedText: [FormattedText]? + + var text: String { get { content.text } } + + var sender: String? { + get { + switch (chatDir) { + case .directSnd: return "you" + case .directRcv: return nil + case .groupSnd: return ChatModel.shared.currentUser?.displayName + case let .groupRcv(member): return member.memberProfile.displayName + case nil: return nil + } + } + } + + static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?) -> CIQuote { + CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: .text(text)) + } +} + enum MsgContent { case text(String) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 3f0bbca448..5d53cb31a9 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -162,11 +162,27 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification( categoryIdentifier: ntfCategoryMessageReceived, title: "\(cInfo.chatViewName):", - body: cItem.content.text, + body: hideSecrets(cItem), targetContentIdentifier: cInfo.id // userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] ) } + + func hideSecrets(_ cItem: ChatItem) -> String { + if let md = cItem.formattedText { + var res = "" + for ft in md { + if case .secret = ft.format { + res = res + "..." + } else { + res = res + ft.text + } + } + return res + } else { + return cItem.content.text + } + } private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0893c1c8a3..c7111a3541 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -22,6 +22,7 @@ enum ChatCommand { case apiGetChats case apiGetChat(type: ChatType, id: Int64) case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) + case apiSendMessageQuote(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) case getUserSMPServers case setUserSMPServers(smpServers: [String]) case addContact @@ -45,6 +46,7 @@ enum ChatCommand { case .apiGetChats: return "/_get chats" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)" + case let .apiSendMessageQuote(type, id, itemId, mc): return "/_send_quote \(ref(type, id)) \(itemId) \(mc.cmdString)" case .getUserSMPServers: return "/smp_servers" case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case .addContact: return "/connect" @@ -71,6 +73,7 @@ enum ChatCommand { case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiSendMessage: return "apiSendMessage" + case .apiSendMessageQuote: return "apiSendMessageQuote" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" case .addContact: return "addContact" @@ -362,9 +365,14 @@ func apiGetChat(type: ChatType, id: Int64) async throws -> Chat { throw r } -func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) async throws -> ChatItem { +func apiSendMessage(type: ChatType, id: Int64, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem { let chatModel = ChatModel.shared - let cmd = ChatCommand.apiSendMessage(type: type, id: id, msg: msg) + let cmd: ChatCommand + if let itemId = quotedItemId { + cmd = .apiSendMessageQuote(type: type, id: id, itemId: itemId, msg: msg) + } else { + cmd = .apiSendMessage(type: type, id: id, msg: msg) + } let r: ChatResponse if type == .direct { var cItem: ChatItem! diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 7a4ff3b5f2..260e6e9fe6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -12,25 +12,22 @@ struct EmojiItemView: View { var chatItem: ChatItem var body: some View { - let sent = chatItem.chatDir.sent - let s = chatItem.content.text.trimmingCharacters(in: .whitespaces) - VStack(spacing: 1) { - Text(s) - .font(s.count < 4 ? largeEmojiFont : mediumEmojiFont) + emojiText(chatItem.content.text) .padding(.top, 8) .padding(.horizontal, 6) - .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) CIMetaView(chatItem: chatItem) .padding(.bottom, 8) .padding(.horizontal, 12) - .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) } - .padding(.horizontal) - .frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading) } } +func emojiText(_ text: String) -> Text { + let s = text.trimmingCharacters(in: .whitespaces) + return Text(s).font(s.count < 4 ? largeEmojiFont : mediumEmojiFont) +} + struct EmojiItemView_Previews: PreviewProvider { static var previews: some View { Group{ diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift new file mode 100644 index 0000000000..90e5d4e2a6 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -0,0 +1,112 @@ +// +// FramedItemView.swift +// SimpleX +// +// Created by Evgeny Poberezkin on 04/02/2022. +// Copyright Β© 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) +private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) +private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11) +private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09) + +struct FramedItemView: View { + @Environment(\.colorScheme) var colorScheme + var chatItem: ChatItem + @State var msgWidth: CGFloat = 0 + + var body: some View { + let v = ZStack(alignment: .bottomTrailing) { + VStack(alignment: .leading, spacing: 0) { + if let qi = chatItem.quotedItem { + MsgContentView( + content: qi, + sender: qi.sender + ) + .lineLimit(3) + .font(.subheadline) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .frame(minWidth: msgWidth, alignment: .leading) + .background( + chatItem.chatDir.sent + ? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark) + : Color(uiColor: .quaternarySystemFill) + ) + .overlay(DetermineWidth()) + } + + if chatItem.formattedText == nil && isShortEmoji(chatItem.content.text) { + VStack { + emojiText(chatItem.content.text) + Text("") + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .overlay(DetermineWidth()) + .frame(minWidth: msgWidth, alignment: .center) + .padding(.bottom, 2) + } else { + MsgContentView( + content: chatItem.content, + formattedText: chatItem.formattedText, + sender: chatItem.memberDisplayName, + metaText: chatItem.timestampText + ) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .overlay(DetermineWidth()) + .frame(minWidth: 0, alignment: .leading) + .textSelection(.enabled) + } + } + .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } + + CIMetaView(chatItem: chatItem) + .padding(.trailing, 12) + .padding(.bottom, 6) + } + .background(chatItemFrameColor(chatItem, colorScheme)) + .cornerRadius(18) + + switch chatItem.meta.itemStatus { + case .sndErrorAuth: + v.onTapGesture { msgDeliveryError("Most likely this contact has deleted the connection with you.") } + case let .sndError(agentError): + v.onTapGesture { msgDeliveryError("Unexpected error: \(String(describing: agentError))") } + default: v + } + } + + private func msgDeliveryError(_ err: String) { + AlertManager.shared.showAlertMsg( + title: "Message delivery error", + message: err + ) + } +} + +func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color { + ci.chatDir.sent + ? (colorScheme == .light ? sentColorLight : sentColorDark) + : Color(uiColor: .tertiarySystemGroupedBackground) +} + +struct FramedItemView_Previews: PreviewProvider { + static var previews: some View { + Group{ + FramedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello")) + FramedItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd))) + FramedItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv))) + FramedItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "πŸ‘", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv))) + FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -")) + FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ")) + FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat")) + FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat")) + } + .previewLayout(.fixed(width: 360, height: 200)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift new file mode 100644 index 0000000000..4620fb34c8 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -0,0 +1,97 @@ +// +// MsgContentView.swift +// SimpleX +// +// Created by Evgeny on 13/03/2022. +// Copyright Β© 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) +private let linkColor = Color(uiColor: uiLinkColor) + +struct MsgContentView: View { + var content: ItemContent + var formattedText: [FormattedText]? = nil + var sender: String? = nil + var metaText: Text? = nil + + var body: some View { + let v = messageText(content, formattedText, sender) + if let mt = metaText { + return v + reserveSpaceForMeta(mt) + } else { + return v + } + } + + private func reserveSpaceForMeta(_ meta: Text) -> Text { + (Text(" ") + meta) + .font(.caption) + .foregroundColor(.clear) + } +} + +func messageText(_ content: ItemContent, _ formattedText: [FormattedText]?, _ sender: String?, preview: Bool = false) -> Text { + let s = content.text + var res: Text + if let ft = formattedText, ft.count > 0 { + res = formattText(ft[0], preview) + var i = 1 + while i < ft.count { + res = res + formattText(ft[i], preview) + i = i + 1 + } + } else { + res = Text(s) + } + + if let s = sender { + let t = Text(s) + return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res + } else { + return res + } +} + +private func formattText(_ ft: FormattedText, _ preview: Bool) -> Text { + let t = ft.text + if let f = ft.format { + switch (f) { + case .bold: return Text(t).bold() + case .italic: return Text(t).italic() + case .strikeThrough: return Text(t).strikethrough() + case .snippet: return Text(t).font(.body.monospaced()) + case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary) + case let .colored(color): return Text(t).foregroundColor(color.uiColor) + case .uri: return linkText(t, t, preview, prefix: "") + case .email: return linkText(t, t, preview, prefix: "mailto:") + case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") + } + } else { + return Text(t) + } +} + +private func linkText(_ s: String, _ link: String, + _ preview: Bool, prefix: String) -> Text { + preview + ? Text(s).foregroundColor(linkColor).underline(color: linkColor) + : Text(AttributedString(s, attributes: AttributeContainer([ + .link: NSURL(string: prefix + link) as Any, + .foregroundColor: uiLinkColor as Any + ]))).underline() +} + +struct MsgContentView_Previews: PreviewProvider { + static var previews: some View { + let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") + return MsgContentView( + content: chatItem.content, + formattedText: chatItem.formattedText, + sender: chatItem.memberDisplayName, + metaText: chatItem.timestampText + ) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift deleted file mode 100644 index 4a79c1f423..0000000000 --- a/apps/ios/Shared/Views/Chat/ChatItem/TextItemView.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// TextItemView.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 04/02/2022. -// Copyright Β© 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) -private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) -private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) -private let linkColor = Color(uiColor: uiLinkColor) - -struct TextItemView: View { - @Environment(\.colorScheme) var colorScheme - var chatItem: ChatItem - var width: CGFloat - private let codeFont = Font.custom("Courier", size: UIFont.preferredFont(forTextStyle: .body).pointSize) - - var body: some View { - let sent = chatItem.chatDir.sent - let maxWidth = width * 0.78 - - return ZStack(alignment: .bottomTrailing) { - (messageText(chatItem) + reserveSpaceForMeta(chatItem.timestampText)) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .frame(minWidth: 0, alignment: .leading) - .textSelection(.enabled) - - CIMetaView(chatItem: chatItem) - .padding(.trailing, 12) - .padding(.bottom, 6) - } - .background( - sent - ? (colorScheme == .light ? sentColorLight : sentColorDark) - : Color(uiColor: .tertiarySystemGroupedBackground) - ) - .cornerRadius(18) - .padding(.horizontal) - .frame( - maxWidth: maxWidth, - maxHeight: .infinity, - alignment: sent ? .trailing : .leading - ) - .onTapGesture { - switch chatItem.meta.itemStatus { - case .sndErrorAuth: msgDeliveryError("Most likely this contact has deleted the connection with you.") - case let .sndError(agentError): msgDeliveryError("Unexpected error: \(String(describing: agentError))") - default: return - } - } - } - - private func reserveSpaceForMeta(_ meta: Text) -> Text { - (Text(" ") + meta) - .font(.caption) - .foregroundColor(.clear) - } - - private func msgDeliveryError(_ err: String) { - AlertManager.shared.showAlertMsg( - title: "Message delivery error", - message: err - ) - } -} - -func messageText(_ chatItem: ChatItem, preview: Bool = false) -> Text { - let s = chatItem.content.text - var res: Text - if let ft = chatItem.formattedText, ft.count > 0 { - res = formattedText(ft[0], preview) - var i = 1 - while i < ft.count { - res = res + formattedText(ft[i], preview) - i = i + 1 - } - } else { - res = Text(s) - } - - if case let .groupRcv(groupMember) = chatItem.chatDir { - let m = Text(groupMember.memberProfile.displayName) - return (preview ? m : m.font(.headline)) + Text(": ") + res - } else { - return res - } -} - -private func formattedText(_ ft: FormattedText, _ preview: Bool) -> Text { - let t = ft.text - if let f = ft.format { - switch (f) { - case .bold: return Text(t).bold() - case .italic: return Text(t).italic() - case .strikeThrough: return Text(t).strikethrough() - case .snippet: return Text(t).font(.body.monospaced()) - case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary) - case let .colored(color): return Text(t).foregroundColor(color.uiColor) - case .uri: return linkText(t, t, preview, prefix: "") - case .email: return linkText(t, t, preview, prefix: "mailto:") - case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") - } - } else { - return Text(t) - } -} - -private func linkText(_ s: String, _ link: String, - _ preview: Bool, prefix: String) -> Text { - preview - ? Text(s).foregroundColor(linkColor).underline(color: linkColor) - : Text(AttributedString(s, attributes: AttributeContainer([ - .link: NSURL(string: prefix + link) as Any, - .foregroundColor: uiLinkColor as Any - ]))).underline() -} - -struct TextItemView_Previews: PreviewProvider { - static var previews: some View { - Group{ - TextItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360) - TextItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello"), width: 360) - TextItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent), width: 360) - TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), width: 360) - TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), width: 360) - TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), width: 360) - TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), width: 360) - } - .previewLayout(.fixed(width: 360, height: 70)) - } -} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index e287e8c1ae..c778a6b08b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -10,13 +10,12 @@ import SwiftUI struct ChatItemView: View { var chatItem: ChatItem - var width: CGFloat var body: some View { - if (isShortEmoji(chatItem.content.text)) { + if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) { EmojiItemView(chatItem: chatItem) } else { - TextItemView(chatItem: chatItem, width: width) + FramedItemView(chatItem: chatItem) } } } @@ -24,11 +23,11 @@ struct ChatItemView: View { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360) - ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), width: 360) - ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "πŸ™‚"), width: 360) - ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "πŸ™‚πŸ™‚πŸ™‚πŸ™‚πŸ™‚"), width: 360) - ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "πŸ™‚πŸ™‚πŸ™‚πŸ™‚πŸ™‚πŸ™‚"), width: 360) + ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello")) + ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too")) + ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "πŸ™‚")) + ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "πŸ™‚πŸ™‚πŸ™‚πŸ™‚πŸ™‚")) + ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "πŸ™‚πŸ™‚πŸ™‚πŸ™‚πŸ™‚πŸ™‚")) } .previewLayout(.fixed(width: 360, height: 70)) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 540a88af62..5860cfdf6a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -12,6 +12,7 @@ struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme @ObservedObject var chat: Chat + @State var quotedItem: ChatItem? = nil @State private var inProgress: Bool = false @FocusState private var keyboardVisible: Bool @State private var showChatInfo = false @@ -21,12 +22,27 @@ struct ChatView: View { return VStack { GeometryReader { g in + let maxWidth = g.size.width * 0.78 ScrollViewReader { proxy in ScrollView { - VStack(spacing: 5) { - ForEach(chatModel.chatItems, id: \.id) { - ChatItemView(chatItem: $0, width: g.size.width) - .frame(minWidth: 0, maxWidth: .infinity, alignment: $0.chatDir.sent ? .trailing : .leading) + LazyVStack(spacing: 5) { + ForEach(chatModel.chatItems) { ci in + let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading + ChatItemView(chatItem: ci) + .contextMenu { + Button { + withAnimation { quotedItem = ci } + } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } + Button { + showShareSheet(items: [ci.content.text]) + } label: { Label("Share", systemImage: "square.and.arrow.up") } + Button { + UIPasteboard.general.string = ci.content.text + } label: { Label("Copy", systemImage: "doc.on.doc") } + } + .padding(.horizontal) + .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) + .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) } .onAppear { DispatchQueue.main.async { @@ -54,7 +70,8 @@ struct ChatView: View { Spacer(minLength: 0) - SendMessageView( + ComposeView( + quotedItem: $quotedItem, sendMessage: sendMessage, inProgress: inProgress, keyboardVisible: $keyboardVisible @@ -115,8 +132,14 @@ struct ChatView: View { func sendMessage(_ msg: String) { Task { do { - let chatItem = try await apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg)) + let chatItem = try await apiSendMessage( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + quotedItemId: quotedItem?.meta.itemId, + msg: .text(msg) + ) DispatchQueue.main.async { + quotedItem = nil chatModel.addChatItem(chat.chatInfo, chatItem) } } catch { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift new file mode 100644 index 0000000000..1e02ee3eeb --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -0,0 +1,42 @@ +// +// ComposeView.swift +// SimpleX +// +// Created by Evgeny on 13/03/2022. +// Copyright Β© 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ComposeView: View { + @Binding var quotedItem: ChatItem? + var sendMessage: (String) -> Void + var inProgress: Bool = false + @FocusState.Binding var keyboardVisible: Bool + + var body: some View { + VStack(spacing: 0) { + QuotedItemView(quotedItem: $quotedItem) + .transition(.move(edge: .bottom)) + SendMessageView( + sendMessage: sendMessage, + inProgress: inProgress, + keyboardVisible: $keyboardVisible + ) + .background(.background) + } + } +} + +struct ComposeView_Previews: PreviewProvider { + static var previews: some View { + @FocusState var keyboardVisible: Bool + @State var quotedItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") + + return ComposeView( + quotedItem: $quotedItem, + sendMessage: { print ($0) }, + keyboardVisible: $keyboardVisible + ) + } +} diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift new file mode 100644 index 0000000000..36a49637ac --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift @@ -0,0 +1,49 @@ +// +// QuotedItemView.swift +// SimpleX +// +// Created by Evgeny on 13/03/2022. +// Copyright Β© 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct QuotedItemView: View { + @Environment(\.colorScheme) var colorScheme + @Binding var quotedItem: ChatItem? + + var body: some View { + if let qi = quotedItem { + HStack { + quoteText(qi).lineLimit(3) + Spacer() + Button { + withAnimation { quotedItem = nil } + } label: { + Image(systemName: "multiply") + } + } + .padding(12) + .frame(maxWidth: .infinity) + .background(chatItemFrameColor(qi, colorScheme)) + .padding(.top, 8) + } else { + EmptyView() + } + } + + func quoteText(_ qi: ChatItem) -> some View { + if let s = qi.memberDisplayName { + return (Text(s).fontWeight(.medium) + Text(": \(qi.content.text)")) + } else { + return Text(qi.content.text) + } + } +} + +struct QuotedItemView_Previews: PreviewProvider { + static var previews: some View { + @State var quotedItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") + return QuotedItemView(quotedItem: $quotedItem) + } +} diff --git a/apps/ios/Shared/Views/Chat/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift similarity index 100% rename from apps/ios/Shared/Views/Chat/SendMessageView.swift rename to apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index c1b5e1492c..58a43eb6cf 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -18,7 +18,7 @@ struct ChatHelp: View { HStack(spacing: 4) { Text("You can") - Button("connect to SimpleX team.") { + Button("connect to SimpleX Chat founder.") { showSettings = false DispatchQueue.main.async { UIApplication.shared.open(simplexTeamURL) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 385c909dfd..89694d1529 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -51,7 +51,7 @@ struct ChatPreviewView: View { if let cItem = cItem { ZStack(alignment: .topTrailing) { - (itemStatusMark(cItem) + messageText(cItem, preview: true)) + (itemStatusMark(cItem) + messageText(cItem.content, cItem.formattedText, cItem.memberDisplayName, preview: true)) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding(.leading, 8) .padding(.trailing, 36) diff --git a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift new file mode 100644 index 0000000000..ba6577a9b0 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift @@ -0,0 +1,35 @@ +// +// DetermineWidth.swift +// SimpleX +// +// Created by Evgeny on 14/03/2022. +// Copyright Β© 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct DetermineWidth: View { + typealias Key = MaximumWidthPreferenceKey + var body: some View { + GeometryReader { + proxy in + Color.clear + .anchorPreference(key: Key.self, value: .bounds) { + anchor in proxy[anchor].size.width + } + } + } +} + +struct MaximumWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +struct DetermineWidth_Previews: PreviewProvider { + static var previews: some View { + DetermineWidth() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 193e014db6..f16c8cf5a5 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -10,6 +10,10 @@ import SwiftUI let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")! +let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + +let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @@ -93,13 +97,13 @@ struct SettingsView: View { UIApplication.shared.open(simplexTeamURL) } } label: { - Text("Get help & advice via chat") + Text("Chat with the founder") } } HStack { Image(systemName: "envelope") .padding(.trailing, 4) - Text("[Ask questions via email](mailto:chat@simplex.chat)") + Text("[Send us email](mailto:chat@simplex.chat)") } } @@ -121,11 +125,8 @@ struct SettingsView: View { .padding(.trailing, 8) Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)") } + Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") } - -// Section("Your SimpleX servers") { -// -// } } .navigationTitle("Your settings") } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 28f9c46b5e..c1a27a3222 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -9,6 +9,16 @@ /* Begin PBXBuildFile section */ 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; + 5C0E5EF627E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */; }; + 5C0E5EF727E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */; }; + 5C0E5EF827E24676003DE3D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF227E24676003DE3D0 /* libffi.a */; }; + 5C0E5EF927E24676003DE3D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF227E24676003DE3D0 /* libffi.a */; }; + 5C0E5EFA27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */; }; + 5C0E5EFB27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */; }; + 5C0E5EFC27E24676003DE3D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF427E24676003DE3D0 /* libgmp.a */; }; + 5C0E5EFD27E24676003DE3D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF427E24676003DE3D0 /* libgmp.a */; }; + 5C0E5EFE27E24676003DE3D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */; }; + 5C0E5EFF27E24676003DE3D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; @@ -25,6 +35,10 @@ 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; + 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; + 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; + 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; + 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; @@ -45,16 +59,6 @@ 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; - 5C79C23E27DB673900C829D6 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23927DB673800C829D6 /* libffi.a */; }; - 5C79C23F27DB673900C829D6 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23927DB673800C829D6 /* libffi.a */; }; - 5C79C24027DB673900C829D6 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23A27DB673800C829D6 /* libgmp.a */; }; - 5C79C24127DB673900C829D6 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23A27DB673800C829D6 /* libgmp.a */; }; - 5C79C24227DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */; }; - 5C79C24327DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */; }; - 5C79C24427DB673900C829D6 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23C27DB673900C829D6 /* libgmpxx.a */; }; - 5C79C24527DB673900C829D6 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23C27DB673900C829D6 /* libgmpxx.a */; }; - 5C79C24627DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */; }; - 5C79C24727DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; @@ -98,10 +102,14 @@ 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; - 5CE4407627ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; }; - 5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; + 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; + 5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; + 5CEACCE727DE97B6000BD591 /* QuotedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */; }; + 5CEACCE827DE97B6000BD591 /* QuotedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */; }; + 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; + 5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; /* End PBXBuildFile section */ @@ -125,15 +133,21 @@ /* Begin PBXFileReference section */ 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; + 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a"; sourceTree = ""; }; + 5C0E5EF227E24676003DE3D0 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a"; sourceTree = ""; }; + 5C0E5EF427E24676003DE3D0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; - 5C2E260927A2C63500F70299 /* MyPlayground.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = MyPlayground.playground; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; 5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; 5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; 5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = ""; }; 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; }; + 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; + 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; @@ -147,11 +161,6 @@ 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (macOS)-Bridging-Header.h"; sourceTree = ""; }; 5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; - 5C79C23927DB673800C829D6 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C79C23A27DB673800C829D6 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a"; sourceTree = ""; }; - 5C79C23C27DB673900C829D6 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a"; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; 5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; @@ -179,8 +188,10 @@ 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContactView.swift; sourceTree = ""; }; 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; - 5CE4407527ADB66A007B033A /* TextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextItemView.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; + 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; + 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedItemView.swift; sourceTree = ""; }; + 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -190,13 +201,13 @@ buildActionMask = 2147483647; files = ( 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, - 5C79C24027DB673900C829D6 /* libgmp.a in Frameworks */, + 5C0E5EFC27E24676003DE3D0 /* libgmp.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, + 5C0E5EF627E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */, + 5C0E5EFE27E24676003DE3D0 /* libgmpxx.a in Frameworks */, + 5C0E5EF827E24676003DE3D0 /* libffi.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, - 5C79C23E27DB673900C829D6 /* libffi.a in Frameworks */, - 5C79C24227DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */, - 5C79C24627DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */, - 5C79C24427DB673900C829D6 /* libgmpxx.a in Frameworks */, + 5C0E5EFA27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -204,13 +215,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C79C24527DB673900C829D6 /* libgmpxx.a in Frameworks */, - 5C79C24727DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */, 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5C79C23F27DB673900C829D6 /* libffi.a in Frameworks */, - 5C79C24127DB673900C829D6 /* libgmp.a in Frameworks */, + 5C0E5EF727E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */, 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, - 5C79C24327DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */, + 5C0E5EFB27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */, + 5C0E5EFD27E24676003DE3D0 /* libgmp.a in Frameworks */, + 5C0E5EF927E24676003DE3D0 /* libffi.a in Frameworks */, + 5C0E5EFF27E24676003DE3D0 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -249,10 +260,10 @@ isa = PBXGroup; children = ( 5CE4407427ADB657007B033A /* ChatItem */, + 5CEACCE527DE977C000BD591 /* ComposeMessage */, 5C2E260E27A30FDC00F70299 /* ChatView.swift */, 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */, 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */, - 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, 5CE4407127ADB1D0007B033A /* Emoji.swift */, ); @@ -262,11 +273,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C79C23927DB673800C829D6 /* libffi.a */, - 5C79C23A27DB673800C829D6 /* libgmp.a */, - 5C79C23C27DB673900C829D6 /* libgmpxx.a */, - 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */, - 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */, + 5C0E5EF227E24676003DE3D0 /* libffi.a */, + 5C0E5EF427E24676003DE3D0 /* libgmp.a */, + 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */, + 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */, + 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */, ); path = Libraries; sourceTree = ""; @@ -298,6 +309,7 @@ 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */, 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */, 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */, + 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */, ); path = Helpers; sourceTree = ""; @@ -327,7 +339,6 @@ 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */, 5C764E7F279C7276000C6508 /* dummy.m */, - 5C2E260927A2C63500F70299 /* MyPlayground.playground */, ); path = Shared; sourceTree = ""; @@ -408,13 +419,24 @@ 5CE4407427ADB657007B033A /* ChatItem */ = { isa = PBXGroup; children = ( - 5CE4407527ADB66A007B033A /* TextItemView.swift */, 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */, 5CE4407827ADB701007B033A /* EmojiItemView.swift */, + 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */, + 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */, ); path = ChatItem; sourceTree = ""; }; + 5CEACCE527DE977C000BD591 /* ComposeMessage */ = { + isa = PBXGroup; + children = ( + 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, + 5CEACCE227DE9246000BD591 /* ComposeView.swift */, + 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */, + ); + path = ComposeMessage; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -584,14 +606,16 @@ files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, - 5CE4407627ADB66A007B033A /* TextItemView.swift in Sources */, + 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */, + 5CEACCE727DE97B6000BD591 /* QuotedItemView.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, @@ -602,6 +626,7 @@ 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */, + 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, @@ -613,6 +638,7 @@ 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, + 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, @@ -629,14 +655,16 @@ files = ( 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */, 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */, - 5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */, + 5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */, 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */, 5C764E81279C7276000C6508 /* dummy.m in Sources */, 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, + 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */, + 5CEACCE827DE97B6000BD591 /* QuotedItemView.swift in Sources */, 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, @@ -647,6 +675,7 @@ 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */, + 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */, 5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */, @@ -658,6 +687,7 @@ 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */, 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, + 5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */, 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, 5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, @@ -821,7 +851,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 28; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -841,7 +871,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -861,7 +891,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 28; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -881,7 +911,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; diff --git a/cabal.project b/cabal.project index f2fe863ece..4d47cbcee4 100644 --- a/cabal.project +++ b/cabal.project @@ -2,20 +2,20 @@ packages: . source-repository-package type: git - location: git://github.com/simplex-chat/simplexmq.git + location: https://github.com/simplex-chat/simplexmq.git tag: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc source-repository-package type: git - location: git://github.com/simplex-chat/aeson.git + location: https://github.com/simplex-chat/aeson.git tag: 3eb66f9a68f103b5f1489382aad89f5712a64db7 source-repository-package type: git - location: git://github.com/simplex-chat/haskell-terminal.git + location: https://github.com/simplex-chat/haskell-terminal.git tag: f708b00009b54890172068f168bf98508ffcd495 source-repository-package type: git - location: git://github.com/zw3rk/android-support.git + location: https://github.com/zw3rk/android-support.git tag: 3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb diff --git a/package.yaml b/package.yaml index b96abaf7c9..ca39fdb7b6 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.3.2 +version: 1.3.3 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/rfcs/2022-03-02-number-chat-items.md b/rfcs/2022-03-02-number-chat-items.md new file mode 100644 index 0000000000..9f78c53e93 --- /dev/null +++ b/rfcs/2022-03-02-number-chat-items.md @@ -0,0 +1,61 @@ +# Message replies and chat item sequential numbers + +## Problem + +Many chat features require referring to the previous chat items in the same conversation: + +- item editing +- item deletion +- item reply (with quoting) +- delivery/read receipts +- any interactive features mutating chat item state +- group message integrity via DAG + +The most in-demand feature is replies. + +## Proposed solution + +As group message integrity is needed not for chat items, but for messages, the updated proposal is to introduce a random, non-sequential message id, unique per conversation and per sender. + +All above features would rely on this ID, e.g. reply would use the ID of the message that created the item. + +We will add an optional property `msgId` into all chat messages (not only visible to the users) and `msgRef` into messages that need to reference other messages. + +`msgId` property is a base64 encoded 12 byte binary + +JTD for quoting messages: + +```yaml +definitions: + msgRef: + discriminator: type + mapping: + direct: + properties: + msgId: type: string + sentAt: type: datetime + sent: type: boolean # true if it is in reference to the item that the sender of the message originally sent, false for references to received items + group: + properties: + msgId: type: string + sentAt: type: datetime + memberId: type: string # base64 member ID of the sender known to all group members for group chats + content: + properties: + type: type: string + text: type: string +properties: + msgId: string + event: enum: ["x.msg.new"] + params: + properties: + content: ref: content + quote: + properties: + content: ref: content + msgRef: ref: msgRef +``` + +This format ensures that replies with quoting show as normal messages on the clients that do not support showing quotes (`quote` property will be ignored). + +The only feature that would not work in case chatItem/chatItemRef is missing is navigating to the message to which the message is in reply to. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 10180fbaaf..dee280bcf6 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: 1.3.2 +version: 1.3.3 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -31,6 +31,7 @@ library Simplex.Chat.Migrations.M20220224_messages_fks Simplex.Chat.Migrations.M20220301_smp_servers Simplex.Chat.Migrations.M20220302_profile_images + Simplex.Chat.Migrations.M20220304_msg_quotes Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol @@ -126,7 +127,7 @@ test-suite simplex-chat-test Paths_simplex_chat hs-source-dirs: tests - ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: aeson ==2.0.* , ansi-terminal >=0.10 && <0.12 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index d9bcd94167..87fa2934b6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -19,6 +19,7 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (drgNew) +import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (first) @@ -56,7 +57,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P, parseAll) import Simplex.Messaging.Protocol (ErrorType (..), MsgBody) import qualified Simplex.Messaging.Protocol as SMP -import Simplex.Messaging.Util (tryError) +import Simplex.Messaging.Util (tryError, (<$?>)) import System.Exit (exitFailure, exitSuccess) import System.FilePath (combine, splitExtensions, takeFileName) import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout) @@ -175,16 +176,44 @@ processChatCommand = \case APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" APISendMessage cType chatId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do - ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId - ci <- sendDirectChatItem userId ct (XMsgNew mc) (CISndMsgContent mc) - setActive $ ActiveC c - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + ct <- withStore $ \st -> getContact st userId chatId + sendNewMsg user ct (MCSimple mc) mc Nothing CTGroup -> do - group@(Group gInfo@GroupInfo {localDisplayName = gName, membership} _) <- withStore $ \st -> getGroup st user chatId + group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc) - setActive $ ActiveG gName - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + sendNewGroupMsg user group (MCSimple mc) mc Nothing + CTContactRequest -> pure $ chatCmdError "not supported" + APISendMessageQuote cType chatId quotedItemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + CTDirect -> do + (ct, qci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId quotedItemId + case qci of + CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} -> do + case ciContent of + CISndMsgContent qmc -> send_ CIQDirectSnd True qmc + CIRcvMsgContent qmc -> send_ CIQDirectRcv False qmc + _ -> throwChatError CEInvalidQuote + where + send_ :: CIQDirection 'CTDirect -> Bool -> MsgContent -> m ChatResponse + send_ chatDir sent qmc = + let quotedItem = CIQuote {chatDir, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} + in sendNewMsg user ct (MCQuote QuotedMsg {msgRef, content = qmc} mc) mc (Just quotedItem) + CTGroup -> do + group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId + unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved + qci <- withStore $ \st -> getGroupChatItem st user chatId quotedItemId + case qci of + CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} -> do + case (ciContent, chatDir) of + (CISndMsgContent qmc, _) -> send_ CIQGroupSnd True membership qmc + (CIRcvMsgContent qmc, CIGroupRcv m) -> send_ (CIQGroupRcv $ Just m) False m qmc + _ -> throwChatError CEInvalidQuote + where + send_ :: CIQDirection 'CTGroup -> Bool -> GroupMember -> MsgContent -> m ChatResponse + send_ qd sent GroupMember {memberId} content = + let quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content, formattedText} + msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} + in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} mc) mc (Just quotedItem) CTContactRequest -> pure $ chatCmdError "not supported" APIChatRead cType chatId fromToIds -> withChatLock $ case cType of CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk @@ -264,6 +293,11 @@ processChatCommand = \case contactId <- withStore $ \st -> getContactIdByName st userId cName let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APISendMessage CTDirect contactId mc + SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \User {userId} -> do + contactId <- withStore $ \st -> getContactIdByName st userId cName + quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg) + let mc = MCText $ safeDecodeUtf8 msg + processChatCommand $ APISendMessageQuote CTDirect contactId quotedItemId mc NewGroup gProfile -> withUser $ \user -> do gVar <- asks idsDrg CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile) @@ -339,14 +373,19 @@ processChatCommand = \case groupId <- withStore $ \st -> getGroupIdByName st user gName let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APISendMessage CTGroup groupId mc - SendFile cName f -> withUser $ \User {userId} -> withChatLock $ do + SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do + groupId <- withStore $ \st -> getGroupIdByName st user gName + quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg) + let mc = MCText $ safeDecodeUtf8 msg + processChatCommand $ APISendMessageQuote CTGroup groupId quotedItemId mc + SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContactByName st userId cName (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq} SndFileTransfer {fileId} <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize - ci <- sendDirectChatItem userId contact (XFile fileInv) (CISndFileInvitation fileId f) + ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci setActive $ ActiveC cName pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci @@ -364,10 +403,9 @@ processChatCommand = \case traverse (\conn -> sendDirectMessage conn (XFile fileInv) (GroupId groupId)) $ memberConn m setActive $ ActiveG gName -- this is a hack as we have multiple direct messages instead of one per group - let ciContent = CISndFileInvitation fileId f - createdAt <- liftIO getCurrentTime - let ci = mkNewChatItem ciContent 0 createdAt createdAt - cItem@ChatItem {meta = CIMeta {itemId}} <- saveChatItem userId (CDGroupSnd gInfo) ci + let msg = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = ""} + ciContent = CISndFileInvitation fileId f + cItem@ChatItem {meta = CIMeta {itemId}} <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent Nothing withStore $ \st -> updateFileTransferChatItemId st fileId itemId pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) cItem ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do @@ -398,7 +436,7 @@ processChatCommand = \case let p = (profile :: Profile) {displayName = displayName, fullName = fullName} updateProfile user p UpdateProfileImage image -> withUser $ \user@User {profile} -> do - let p = (profile :: Profile) {image = Just image} + let p = (profile :: Profile) {image} updateProfile user p QuitChat -> liftIO exitSuccess ShowVersion -> pure $ CRVersionInfo versionNumber @@ -430,6 +468,14 @@ processChatCommand = \case connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId pure CRSentInvitation + sendNewMsg user ct@Contact {localDisplayName = c} msgContainer mc quotedItem = do + ci <- sendDirectChatItem user ct (XMsgNew msgContainer) (CISndMsgContent mc) quotedItem + setActive $ ActiveC c + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + sendNewGroupMsg user g@(Group gInfo@GroupInfo {localDisplayName = gName} _) msgContainer mc quotedItem = do + ci <- sendGroupChatItem user g (XMsgNew msgContainer) (CISndMsgContent mc) quotedItem + setActive $ ActiveG gName + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -448,7 +494,10 @@ processChatCommand = \case asks currentUser >>= atomically . (`writeTVar` Just user') contacts <- withStore (`getUserContacts` user) withChatLock . procCmd $ do - forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p' + forM_ contacts $ \ct -> + let s = connStatus $ activeConn (ct :: Contact) + in when (s == ConnReady || s == ConnSndReady) $ + void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError) pure $ CRUserProfileUpdated p p' getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath getRcvFilePath fileId filePath fileName = case filePath of @@ -536,7 +585,7 @@ subscribeUserConnections user@User {userId} = do ms <- pooledForConcurrentlyN n connectedMembers $ \(m@GroupMember {localDisplayName = c}, cId) -> (m,) <$> ((subscribe cId $> Nothing) `catchError` (\e -> when ce (toView $ CRMemberSubError g c e) $> Just e)) toView $ CRGroupSubscribed g - pure $ mapMaybe (\(m, e) -> maybe Nothing (Just . MemberSubError m) e) ms + pure $ mapMaybe (\(m, e) -> (Just . MemberSubError m) =<< e) ms subscribeFiles n = do sndFileTransfers <- withStore (`getLiveSndFileTransfers` user) pooledForConcurrentlyN_ n sndFileTransfers $ \sft -> subscribeSndFile sft @@ -617,7 +666,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage INFO connInfo -> saveConnInfo conn connInfo MSG meta msgBody -> do - _ <- saveRcvMSG conn meta msgBody (ConnectionId connId) + _ <- saveRcvMSG conn (ConnectionId connId) meta msgBody withAckMessage agentConnId meta $ pure () ackMsgDeliveryEvent conn meta SENT msgId -> @@ -628,13 +677,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage ERR _ -> pure () -- TODO add debugging output _ -> pure () - Just ct@Contact {localDisplayName = c} -> case agentMsg of + Just ct@Contact {localDisplayName = c, contactId} -> case agentMsg of MSG msgMeta msgBody -> do - (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody (ConnectionId connId) + msg@RcvMessage {chatMsgEvent} <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody withAckMessage agentConnId msgMeta $ case chatMsgEvent of - XMsgNew mc -> newContentMessage ct mc msgId msgMeta - XFile fInv -> processFileInvitation ct fInv msgId msgMeta + XMsgNew mc -> newContentMessage ct mc msg msgMeta + XFile fInv -> processFileInvitation ct fInv msg msgMeta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv XInfoProbe probe -> xInfoProbe ct probe @@ -679,7 +728,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage case chatItemId_ of Nothing -> pure () Just chatItemId -> do - chatItem <- withStore $ \st -> updateDirectChatItem st chatItemId CISSndSent + chatItem <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId CISSndSent toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) END -> do toView $ CRContactAnotherClient ct @@ -698,7 +747,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage case chatItemId_ of Nothing -> pure () Just chatItemId -> do - chatItem <- withStore $ \st -> updateDirectChatItem st chatItemId (agentErrToItemStatus err) + chatItem <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId (agentErrToItemStatus err) toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) ERR _ -> pure () -- TODO add debugging output @@ -769,11 +818,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage notifyMemberConnected gInfo m when (memberCategory m == GCPreMember) $ probeMatchingContacts ct MSG msgMeta msgBody -> do - (msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody (GroupId groupId) + msg@RcvMessage {chatMsgEvent} <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody withAckMessage agentConnId msgMeta $ case chatMsgEvent of - XMsgNew mc -> newGroupContentMessage gInfo m mc msgId msgMeta - XFile fInv -> processGroupFileInvitation gInfo m fInv msgId msgMeta + XMsgNew mc -> newGroupContentMessage gInfo m mc msg msgMeta + XFile fInv -> processGroupFileInvitation gInfo m fInv msg msgMeta XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo XGrpMemInv memId introInv -> xGrpMemInv gInfo m memId introInv @@ -942,39 +991,41 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage messageError :: Text -> m () messageError = toView . CRMessageError "error" - newContentMessage :: Contact -> MsgContent -> MessageId -> MsgMeta -> m () - newContentMessage ct@Contact {localDisplayName = c} mc msgId msgMeta = do - ci <- saveRcvChatItem userId (CDDirectRcv ct) msgId msgMeta (CIRcvMsgContent mc) + newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () + newContentMessage ct@Contact {localDisplayName = c} mc msg msgMeta = do + let content = mcContent mc + ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError - showToast (c <> "> ") $ msgContentText mc + showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContent -> MessageId -> MsgMeta -> m () - newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msgId msgMeta = do - ci <- saveRcvChatItem userId (CDGroupRcv gInfo m) msgId msgMeta (CIRcvMsgContent mc) + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () + newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do + let content = mcContent mc + ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) groupMsgToView gInfo ci msgMeta let g = groupName' gInfo - showToast ("#" <> g <> " " <> c <> "> ") $ msgContentText mc + showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText setActive $ ActiveG g - processFileInvitation :: Contact -> FileInvitation -> MessageId -> MsgMeta -> m () - processFileInvitation ct@Contact {localDisplayName = c} fInv msgId msgMeta = do + processFileInvitation :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () + processFileInvitation ct@Contact {localDisplayName = c} fInv msg msgMeta = do -- TODO chunk size has to be sent as part of invitation chSize <- asks $ fileChunkSize . config ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize - ci <- saveRcvChatItem userId (CDDirectRcv ct) msgId msgMeta (CIRcvFileInvitation ft) + ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvFileInvitation ft) withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c - processGroupFileInvitation :: GroupInfo -> GroupMember -> FileInvitation -> MessageId -> MsgMeta -> m () - processGroupFileInvitation gInfo m@GroupMember {localDisplayName = c} fInv msgId msgMeta = do + processGroupFileInvitation :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () + processGroupFileInvitation gInfo m@GroupMember {localDisplayName = c} fInv msg msgMeta = do chSize <- asks $ fileChunkSize . config ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize - ci <- saveRcvChatItem userId (CDGroupRcv gInfo m) msgId msgMeta (CIRcvFileInvitation ft) + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvFileInvitation ft) withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci groupMsgToView gInfo ci msgMeta let g = groupName' gInfo @@ -1251,27 +1302,27 @@ deleteMemberConnection m@GroupMember {activeConn} = do -- withStore $ \st -> deleteGroupMemberConnection st userId m forM_ activeConn $ \conn -> withStore $ \st -> updateConnectionStatus st conn ConnDeleted -sendDirectContactMessage :: ChatMonad m => Contact -> ChatMsgEvent -> m MessageId +sendDirectContactMessage :: ChatMonad m => Contact -> ChatMsgEvent -> m SndMessage sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent = do if connStatus == ConnReady || connStatus == ConnSndReady then sendDirectMessage conn chatMsgEvent (ConnectionId connId) else throwChatError $ CEContactNotReady ct -sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> ConnOrGroupId -> m MessageId +sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> ConnOrGroupId -> m SndMessage sendDirectMessage conn chatMsgEvent connOrGroupId = do - (msgId, msgBody) <- createSndMessage chatMsgEvent connOrGroupId + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId deliverMessage conn msgBody msgId - pure msgId + pure msg -createSndMessage :: ChatMonad m => ChatMsgEvent -> ConnOrGroupId -> m (MessageId, MsgBody) +createSndMessage :: ChatMonad m => ChatMsgEvent -> ConnOrGroupId -> m SndMessage createSndMessage chatMsgEvent connOrGroupId = do - let msgBody = directMessage chatMsgEvent - newMsg = NewMessage {direction = MDSnd, cmEventTag = toCMEventTag chatMsgEvent, msgBody} - msgId <- withStore $ \st -> createNewMessage st newMsg connOrGroupId - pure (msgId, msgBody) + gVar <- asks idsDrg + withStore $ \st -> createNewSndMessage st gVar connOrGroupId $ \sharedMsgId -> + let msgBody = strEncode ChatMessage {msgId = Just sharedMsgId, chatMsgEvent} + in NewMessage {chatMsgEvent, msgBody} directMessage :: ChatMsgEvent -> ByteString -directMessage chatMsgEvent = strEncode ChatMessage {chatMsgEvent} +directMessage chatMsgEvent = strEncode ChatMessage {msgId = Nothing, chatMsgEvent} deliverMessage :: ChatMonad m => Connection -> MsgBody -> MessageId -> m () deliverMessage conn@Connection {connId} msgBody msgId = do @@ -1279,18 +1330,18 @@ deliverMessage conn@Connection {connId} msgBody msgId = do let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} withStore $ \st -> createSndMsgDelivery st sndMsgDelivery msgId -sendGroupMessage :: ChatMonad m => GroupInfo -> [GroupMember] -> ChatMsgEvent -> m MessageId +sendGroupMessage :: ChatMonad m => GroupInfo -> [GroupMember] -> ChatMsgEvent -> m SndMessage sendGroupMessage GroupInfo {groupId} members chatMsgEvent = sendGroupMessage' members chatMsgEvent groupId Nothing $ pure () -sendXGrpMemInv :: ChatMonad m => GroupInfo -> GroupMember -> ChatMsgEvent -> Int64 -> m MessageId +sendXGrpMemInv :: ChatMonad m => GroupInfo -> GroupMember -> ChatMsgEvent -> Int64 -> m SndMessage sendXGrpMemInv GroupInfo {groupId} reMember chatMsgEvent introId = sendGroupMessage' [reMember] chatMsgEvent groupId (Just introId) $ withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) -sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Int64 -> Maybe Int64 -> m () -> m MessageId +sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Int64 -> Maybe Int64 -> m () -> m SndMessage sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do - (msgId, msgBody) <- createSndMessage chatMsgEvent (GroupId groupId) + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) -- TODO collect failed deliveries into a single error forM_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} -> case memberConn m of @@ -1299,7 +1350,7 @@ sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do if not (connStatus == ConnSndReady || connStatus == ConnReady) then unless (connStatus == ConnDeleted) $ withStore (\st -> createPendingGroupMessage st groupMemberId msgId introId_) else (deliverMessage conn msgBody msgId >> postDeliver) `catchError` const (pure ()) - pure msgId + pure msg sendPendingGroupMessages :: ChatMonad m => GroupMember -> Connection -> m () sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do @@ -1312,54 +1363,42 @@ sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do Nothing -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName Just introId -> withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded) -saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> ConnOrGroupId -> m (MessageId, ChatMsgEvent) -saveRcvMSG Connection {connId} agentMsgMeta msgBody connOrGroupId = do - ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody +saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> m RcvMessage +saveRcvMSG Connection {connId} connOrGroupId agentMsgMeta msgBody = do + ChatMessage {msgId = sharedMsgId_, chatMsgEvent} <- liftEither $ parseChatMessage msgBody let agentMsgId = fst $ recipient agentMsgMeta - cmEventTag = toCMEventTag chatMsgEvent - newMsg = NewMessage {direction = MDRcv, cmEventTag, msgBody} + newMsg = NewMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} - msgId <- withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg connOrGroupId rcvMsgDelivery - pure (msgId, chatMsgEvent) + withStore $ \st -> createNewMessageAndRcvMsgDelivery st connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery -sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTDirect 'MDSnd) -sendDirectChatItem userId ct chatMsgEvent ciContent = do - msgId <- sendDirectContactMessage ct chatMsgEvent - saveSndChatItem userId (CDDirectSnd ct) msgId ciContent +sendDirectChatItem :: ChatMonad m => User -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> Maybe (CIQuote 'CTDirect) -> m (ChatItem 'CTDirect 'MDSnd) +sendDirectChatItem user ct chatMsgEvent ciContent quotedItem = do + msg <- sendDirectContactMessage ct chatMsgEvent + saveSndChatItem user (CDDirectSnd ct) msg ciContent quotedItem -sendGroupChatItem :: ChatMonad m => UserId -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTGroup 'MDSnd) -sendGroupChatItem userId (Group g ms) chatMsgEvent ciContent = do - msgId <- sendGroupMessage g ms chatMsgEvent - saveSndChatItem userId (CDGroupSnd g) msgId ciContent +sendGroupChatItem :: ChatMonad m => User -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> Maybe (CIQuote 'CTGroup) -> m (ChatItem 'CTGroup 'MDSnd) +sendGroupChatItem user (Group g ms) chatMsgEvent ciContent quotedItem = do + msg <- sendGroupMessage g ms chatMsgEvent + saveSndChatItem user (CDGroupSnd g) msg ciContent quotedItem -saveSndChatItem :: ChatMonad m => UserId -> ChatDirection c 'MDSnd -> MessageId -> CIContent 'MDSnd -> m (ChatItem c 'MDSnd) -saveSndChatItem userId cd msgId ciContent = do +saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> m (ChatItem c 'MDSnd) +saveSndChatItem user cd msg@SndMessage {sharedMsgId} content quotedItem = do createdAt <- liftIO getCurrentTime - saveChatItem userId cd $ mkNewChatItem ciContent msgId createdAt createdAt + ciId <- withStore $ \st -> createNewSndChatItem st user cd msg content quotedItem createdAt + liftIO $ mkChatItem cd ciId content quotedItem (Just sharedMsgId) createdAt createdAt -saveRcvChatItem :: ChatMonad m => UserId -> ChatDirection c 'MDRcv -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv) -saveRcvChatItem userId cd msgId MsgMeta {broker = (_, brokerTs)} ciContent = do +saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv) +saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brokerTs)} content = do createdAt <- liftIO getCurrentTime - saveChatItem userId cd $ mkNewChatItem ciContent msgId brokerTs createdAt + (ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg content brokerTs createdAt -- createNewChatItem st user cd $ mkNewChatItem content msg brokerTs createdAt + liftIO $ mkChatItem cd ciId content quotedItem sharedMsgId_ brokerTs createdAt -saveChatItem :: (ChatMonad m, MsgDirectionI d) => UserId -> ChatDirection c d -> NewChatItem d -> m (ChatItem c d) -saveChatItem userId cd ci@NewChatItem {itemContent, itemTs, itemText, createdAt} = do - tz <- liftIO getCurrentTimeZone - ciId <- withStore $ \st -> createNewChatItem st userId cd ci - let ciMeta = mkCIMeta ciId itemText ciStatusNew tz itemTs createdAt - pure $ ChatItem (toCIDirection cd) ciMeta itemContent $ parseMaybeMarkdownList itemText - -mkNewChatItem :: forall d. MsgDirectionI d => CIContent d -> MessageId -> UTCTime -> UTCTime -> NewChatItem d -mkNewChatItem itemContent msgId itemTs createdAt = - NewChatItem - { createdByMsgId = if msgId == 0 then Nothing else Just msgId, - itemSent = msgDirection @d, - itemTs, - itemContent, - itemText = ciContentToText itemContent, - itemStatus = ciStatusNew, - createdAt - } +mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) +mkChatItem cd ciId content quotedItem sharedMsgId itemTs createdAt = do + tz <- getCurrentTimeZone + let itemText = ciContentToText content + meta = mkCIMeta ciId itemText ciStatusNew sharedMsgId tz itemTs createdAt + pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem} allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () allowAgentConnection conn confId msg = do @@ -1424,6 +1463,13 @@ getCreateActiveUser st = do getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine +showMsgToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> MsgContent -> Maybe MarkdownList -> m () +showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_ + where + hideSecret :: FormattedText -> Text + hideSecret FormattedText {format = Just Secret} = "..." + hideSecret FormattedText {text} = text + showToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> Text -> m () showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ @@ -1470,6 +1516,7 @@ chatCommandP = <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) + <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) <|> "/_accept " *> (APIAcceptContact <$> A.decimal) @@ -1480,6 +1527,7 @@ chatCommandP = <|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles <|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups <|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress + <|> ("/help replies" <|> "/hr") $> ChatHelp HSQuotes <|> ("/help" <|> "/h") $> ChatHelp HSMain <|> ("/group #" <|> "/group " <|> "/g #" <|> "/g ") *> (NewGroup <$> groupProfile) <|> ("/add #" <|> "/add " <|> "/a #" <|> "/a ") *> (AddMember <$> displayName <* A.space <*> displayName <*> memberRole) @@ -1490,11 +1538,15 @@ chatCommandP = <|> ("/members #" <|> "/members " <|> "/ms #" <|> "/ms ") *> (ListMembers <$> displayName) <|> ("/groups" <|> "/gs") $> ListGroups <|> A.char '#' *> (SendGroupMessage <$> displayName <* A.space <*> A.takeByteString) + <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString) + <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* optional (A.char '@') <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString) <|> ("/contacts" <|> "/cs") $> ListContacts <|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)) <|> ("/connect" <|> "/c") $> AddContact <|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName) <|> A.char '@' *> (SendMessage <$> displayName <* A.space <*> A.takeByteString) + <|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv) + <|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd) <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) <|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath) <|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath)) @@ -1509,7 +1561,8 @@ chatCommandP = <|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName) <|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown <|> ("/welcome" <|> "/w") $> Welcome - <|> "/profile_image " *> (UpdateProfileImage . ProfileImage <$> imageP) + <|> "/profile_image " *> (UpdateProfileImage . Just . ProfileImage <$> imageP) + <|> "/profile_image" $> UpdateProfileImage Nothing <|> ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames) <|> ("/profile" <|> "/p") $> ShowProfile <|> ("/quit" <|> "/q" <|> "/exit") $> QuitChat @@ -1522,8 +1575,12 @@ chatCommandP = (CPLast <$ "count=" <*> A.decimal) <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) - msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) + msgContentP = + "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) + <|> "json " *> (J.eitherDecodeStrict' <$?> A.takeByteString) displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) + sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString + quotedMsg = A.char '(' *> A.takeTill (== ')') <* A.char ')' <* optional A.space refChar c = c > ' ' && c /= '#' && c /= '@' onOffP = ("on" $> True) <|> ("off" $> False) userNames = do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 34f51829fb..7d5fa8c8b3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -20,8 +20,10 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Map.Strict (Map) import Data.Text (Text) +import Data.Version (showVersion) import GHC.Generics (Generic) import Numeric.Natural +import qualified Paths_simplex_chat as SC import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) @@ -36,7 +38,7 @@ import System.IO (Handle) import UnliftIO.STM versionNumber :: String -versionNumber = "1.3.2" +versionNumber = showVersion SC.version versionStr :: String versionStr = "SimpleX Chat v" <> versionNumber @@ -76,7 +78,7 @@ data ChatController = ChatController config :: ChatConfig } -data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown +data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSQuotes deriving (Show, Generic) instance ToJSON HelpSection where @@ -91,6 +93,7 @@ data ChatCommand | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int | APISendMessage ChatType Int64 MsgContent + | APISendMessageQuote ChatType Int64 ChatItemId MsgContent | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) | APIDeleteChat ChatType Int64 | APIAcceptContact Int64 @@ -111,6 +114,7 @@ data ChatCommand | AcceptContact ContactName | RejectContact ContactName | SendMessage ContactName ByteString + | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: ByteString, message :: ByteString} | NewGroup GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName @@ -121,6 +125,7 @@ data ChatCommand | ListMembers GroupName | ListGroups | SendGroupMessage GroupName ByteString + | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} | SendFile ContactName FilePath | SendGroupFile GroupName FilePath | ReceiveFile FileTransferId (Maybe FilePath) @@ -128,7 +133,7 @@ data ChatCommand | FileStatus FileTransferId | ShowProfile | UpdateProfile ContactName Text - | UpdateProfileImage ProfileImage + | UpdateProfileImage (Maybe ProfileImage) | QuitChat | ShowVersion deriving (Show) @@ -289,6 +294,7 @@ data ChatErrorType | CEFileSend {fileId :: FileTransferId, agentError :: AgentErrorType} | CEFileRcvChunk {message :: String} | CEFileInternal {message :: String} + | CEInvalidQuote | CEAgentVersion | CECommandError {message :: String} deriving (Show, Exception, Generic) diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 3f17074dd3..58c8fbb3e3 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -7,6 +7,7 @@ module Simplex.Chat.Help filesHelpInfo, groupsHelpInfo, myAddressHelpInfo, + quotesHelpInfo, markdownInfo, ) where @@ -44,11 +45,7 @@ chatWelcome user = "Welcome " <> green userName <> "!", "Thank you for installing SimpleX Chat!", "", - "We have a couple of groups that you can join to play with SimpleX Chat:", - highlight "#termux" <> " (Android Termux πŸ“±) - chatting about using SimpleX Chat on Android devices", - highlight "#music" <> " (Music 🎸) - favorite music of our team and users", - "", - "Connect to SimpleX Chat team to be added to these groups - 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/", @@ -86,7 +83,7 @@ chatHelpInfo = green "Create your address: " <> highlight "/address", "", green "Other commands:", - indent <> highlight "/help " <> " - help on: files, groups, address, smp_servers", + indent <> highlight "/help " <> " - help on: files, groups, address, replies, smp_servers", indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/delete " <> " - delete contact and all messages with them", indent <> highlight "/contacts " <> " - list contacts", @@ -146,6 +143,18 @@ myAddressHelpInfo = "The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"] ] +quotesHelpInfo :: [StyledString] +quotesHelpInfo = + map + styleMarkdown + [ green "Sending replies to messages", + "To quote a message that starts with \"hi\":", + indent <> highlight "> @alice (hi) " <> " - to reply to alice's most recent message", + indent <> highlight ">> @alice (hi) " <> " - to quote user's most recent message to alice", + indent <> highlight "> #team (hi) " <> " - to quote most recent message in the group from any member", + indent <> highlight "> #team @alice (hi) " <> " - to quote alice's most recent message in the group #team" + ] + markdownInfo :: [StyledString] markdownInfo = map diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 1a136d5efb..dc8d9ece9d 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -78,7 +78,8 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem { chatDir :: CIDirection c d, meta :: CIMeta d, content :: CIContent d, - formattedText :: Maybe [FormattedText] + formattedText :: Maybe MarkdownList, + quotedItem :: Maybe (CIQuote c) } deriving (Show, Generic) @@ -101,9 +102,6 @@ data JSONCIDirection | JCIGroupRcv {groupMember :: GroupMember} deriving (Generic, Show) -instance FromJSON JSONCIDirection where - parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI" - instance ToJSON JSONCIDirection where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI" @@ -150,6 +148,8 @@ data NewChatItem d = NewChatItem itemContent :: CIContent d, itemText :: Text, itemStatus :: CIStatus d, + itemSharedMsgId :: Maybe SharedMsgId, + itemQuotedMsg :: Maybe QuotedMsg, createdAt :: UTCTime } deriving (Show) @@ -185,7 +185,7 @@ instance ToJSON ChatStats where toEncoding = J.genericToEncoding J.defaultOptions -- | type to show a mix of messages from multiple chats -data AChatItem = forall c d. AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) +data AChatItem = forall c d. MsgDirectionI d => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d) deriving instance Show AChatItem @@ -205,18 +205,60 @@ data CIMeta (d :: MsgDirection) = CIMeta itemTs :: ChatItemTs, itemText :: Text, itemStatus :: CIStatus d, + itemSharedMsgId :: Maybe SharedMsgId, localItemTs :: ZonedTime, createdAt :: UTCTime } deriving (Show, Generic) -mkCIMeta :: ChatItemId -> Text -> CIStatus d -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta d -mkCIMeta itemId itemText itemStatus tz itemTs createdAt = +mkCIMeta :: ChatItemId -> Text -> CIStatus d -> Maybe SharedMsgId -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta d +mkCIMeta itemId itemText itemStatus itemSharedMsgId tz itemTs createdAt = let localItemTs = utcToZonedTime tz itemTs - in CIMeta {itemId, itemTs, itemText, itemStatus, localItemTs, createdAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, localItemTs, createdAt} instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions +data CIQuote (c :: ChatType) = CIQuote + { chatDir :: CIQDirection c, + itemId :: Maybe ChatItemId, -- Nothing in case MsgRef references the item the user did not receive yet + sharedMsgId :: Maybe SharedMsgId, -- Nothing for the messages from the old clients + sentAt :: UTCTime, + content :: MsgContent, + formattedText :: Maybe MarkdownList + } + deriving (Show, Generic) + +instance ToJSON (CIQuote c) where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data CIQDirection (c :: ChatType) where + CIQDirectSnd :: CIQDirection 'CTDirect + CIQDirectRcv :: CIQDirection 'CTDirect + CIQGroupSnd :: CIQDirection 'CTGroup + CIQGroupRcv :: Maybe GroupMember -> CIQDirection 'CTGroup -- member can be Nothing in case MsgRef has memberId that the user is not notified about yet + +deriving instance Show (CIQDirection c) + +instance ToJSON (CIQDirection c) where + toJSON = J.toJSON . jsonCIQDirection + toEncoding = J.toEncoding . jsonCIQDirection + +jsonCIQDirection :: CIQDirection c -> Maybe JSONCIDirection +jsonCIQDirection = \case + CIQDirectSnd -> Just JCIDirectSnd + CIQDirectRcv -> Just JCIDirectRcv + CIQGroupSnd -> Just JCIGroupSnd + CIQGroupRcv (Just m) -> Just $ JCIGroupRcv m + CIQGroupRcv Nothing -> Nothing + +quoteMsgDirection :: CIQDirection c -> MsgDirection +quoteMsgDirection = \case + CIQDirectSnd -> MDSnd + CIQDirectRcv -> MDRcv + CIQGroupSnd -> MDSnd + CIQGroupRcv _ -> MDRcv + data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd CISSndSent :: CIStatus 'MDSnd @@ -242,6 +284,8 @@ instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d) +deriving instance Show ACIStatus + instance MsgDirectionI d => StrEncoding (CIStatus d) where strEncode = \case CISSndNew -> "snd_new" @@ -322,6 +366,8 @@ instance ToJSON (CIContent d) where data ACIContent = forall d. ACIContent (SMsgDirection d) (CIContent d) +deriving instance Show ACIContent + -- platform specific instance FromJSON ACIContent where parseJSON = fmap aciContentJSON . J.parseJSON @@ -407,12 +453,24 @@ instance ChatTypeI 'CTDirect where chatType = SCTDirect instance ChatTypeI 'CTGroup where chatType = SCTGroup data NewMessage = NewMessage - { direction :: MsgDirection, - cmEventTag :: CMEventTag, + { chatMsgEvent :: ChatMsgEvent, msgBody :: MsgBody } deriving (Show) +data SndMessage = SndMessage + { msgId :: MessageId, + sharedMsgId :: SharedMsgId, + msgBody :: MsgBody + } + +data RcvMessage = RcvMessage + { msgId :: MessageId, + chatMsgEvent :: ChatMsgEvent, + sharedMsgId_ :: Maybe SharedMsgId, + msgBody :: MsgBody + } + data PendingGroupMessage = PendingGroupMessage { msgId :: MessageId, cmEventTag :: CMEventTag, @@ -425,7 +483,7 @@ type MessageId = Int64 data ConnOrGroupId = ConnectionId Int64 | GroupId Int64 data MsgDirection = MDRcv | MDSnd - deriving (Show, Generic) + deriving (Eq, Show, Generic) instance FromJSON MsgDirection where parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MD" @@ -449,11 +507,20 @@ instance TestEquality SMsgDirection where instance ToField (SMsgDirection d) where toField = toField . msgDirectionInt . toMsgDirection +data AMsgDirection = forall d. MsgDirectionI d => AMsgDirection (SMsgDirection d) + +deriving instance Show AMsgDirection + toMsgDirection :: SMsgDirection d -> MsgDirection toMsgDirection = \case SMDRcv -> MDRcv SMDSnd -> MDSnd +fromMsgDirection :: MsgDirection -> AMsgDirection +fromMsgDirection = \case + MDRcv -> AMsgDirection SMDRcv + MDSnd -> AMsgDirection SMDSnd + class MsgDirectionI (d :: MsgDirection) where msgDirection :: SMsgDirection d diff --git a/src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs b/src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs new file mode 100644 index 0000000000..129c3616a0 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220304_msg_quotes.hs @@ -0,0 +1,24 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220304_msg_quotes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220304_msg_quotes :: Query +m20220304_msg_quotes = + [sql| + ALTER TABLE messages ADD COLUMN shared_msg_id BLOB; + ALTER TABLE messages ADD COLUMN shared_msg_id_user INTEGER; -- 1 for user messages, NULL for received messages + CREATE INDEX idx_messages_shared_msg_id ON messages (shared_msg_id); + CREATE UNIQUE INDEX idx_messages_direct_shared_msg_id ON messages (connection_id, shared_msg_id_user, shared_msg_id); + CREATE UNIQUE INDEX idx_messages_group_shared_msg_id ON messages (group_id, shared_msg_id_user, shared_msg_id); + + ALTER TABLE chat_items ADD COLUMN shared_msg_id BLOB; + ALTER TABLE chat_items ADD COLUMN quoted_shared_msg_id BLOB; -- from MessageRef in QuotedMsg + ALTER TABLE chat_items ADD COLUMN quoted_sent_at TEXT; -- from MessageRef in QuotedMsg + ALTER TABLE chat_items ADD COLUMN quoted_content TEXT; -- from MsgContent in QuotedMsg (JSON) + ALTER TABLE chat_items ADD COLUMN quoted_sent INTEGER; -- from MessageRef, 1 for sent, 0 for received, NULL for messages without quote + ALTER TABLE chat_items ADD COLUMN quoted_member_id BLOB; -- from MessageRef + CREATE INDEX idx_chat_items_shared_msg_id ON chat_items (shared_msg_id); +|] diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index ace8eeb06c..a238f0e028 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -12,6 +12,7 @@ module Simplex.Chat.Protocol where +import Control.Applicative ((<|>)) import Control.Monad ((<=<)) import Data.Aeson (FromJSON, ToJSON, (.:), (.:?), (.=)) import qualified Data.Aeson as J @@ -19,15 +20,17 @@ import qualified Data.Aeson.Encoding as JE import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) +import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Types -import Simplex.Chat.Util (eitherToMaybe) +import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$?>)) @@ -52,14 +55,51 @@ updateEntityConnStatus connEntity connStatus = case connEntity of -- chat message is sent as JSON with these properties data AppMessage = AppMessage - { event :: Text, + { msgId :: Maybe SharedMsgId, + event :: Text, params :: J.Object } deriving (Generic, FromJSON) -instance ToJSON AppMessage where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +instance ToJSON AppMessage where + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} -newtype ChatMessage = ChatMessage {chatMsgEvent :: ChatMsgEvent} +newtype SharedMsgId = SharedMsgId ByteString + deriving (Eq, Show) + +instance FromField SharedMsgId where fromField f = SharedMsgId <$> fromField f + +instance ToField SharedMsgId where toField (SharedMsgId m) = toField m + +instance StrEncoding SharedMsgId where + strEncode (SharedMsgId m) = strEncode m + strDecode s = SharedMsgId <$> strDecode s + strP = SharedMsgId <$> strP + +instance FromJSON SharedMsgId where + parseJSON = strParseJSON "SharedMsgId" + +instance ToJSON SharedMsgId where + toJSON = strToJSON + toEncoding = strToJEncoding + +data MsgRef = MsgRef + { msgId :: Maybe SharedMsgId, + sentAt :: UTCTime, + sent :: Bool, + memberId :: Maybe MemberId -- must be present in all group message references, both referencing sent and received + } + deriving (Eq, Show, Generic) + +instance FromJSON MsgRef where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +instance ToJSON MsgRef where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data ChatMessage = ChatMessage {msgId :: Maybe SharedMsgId, chatMsgEvent :: ChatMsgEvent} deriving (Eq, Show) instance StrEncoding ChatMessage where @@ -68,7 +108,7 @@ instance StrEncoding ChatMessage where strP = strDecode <$?> A.takeByteString data ChatMsgEvent - = XMsgNew MsgContent + = XMsgNew MsgContainer | XFile FileInvitation | XFileAcpt String | XInfo Profile @@ -89,58 +129,107 @@ data ChatMsgEvent | XInfoProbeCheck ProbeHash | XInfoProbeOk Probe | XOk + | XUnknown {event :: Text, params :: J.Object} deriving (Eq, Show) -data MsgContentType = MCText_ | MCUnknown_ +data QuotedMsg = QuotedMsg {msgRef :: MsgRef, content :: MsgContent} + deriving (Eq, Show, Generic, FromJSON) -instance StrEncoding MsgContentType where +instance ToJSON QuotedMsg where + toEncoding = J.genericToEncoding J.defaultOptions + toJSON = J.genericToJSON J.defaultOptions + +cmToQuotedMsg :: ChatMsgEvent -> Maybe QuotedMsg +cmToQuotedMsg = \case + XMsgNew (MCQuote quotedMsg _) -> Just quotedMsg + _ -> Nothing + +data MsgContentTag = MCText_ | MCUnknown_ Text + +instance StrEncoding MsgContentTag where strEncode = \case MCText_ -> "text" - MCUnknown_ -> "text" + MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ - _ -> Right MCUnknown_ + t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') -instance FromJSON MsgContentType where +instance FromJSON MsgContentTag where parseJSON = strParseJSON "MsgContentType" -instance ToJSON MsgContentType where +instance ToJSON MsgContentTag where toJSON = strToJSON toEncoding = strToJEncoding -data MsgContent = MCText Text | MCUnknown J.Value Text +data MsgContainer + = MCSimple MsgContent + | MCQuote QuotedMsg MsgContent + | MCForward MsgContent + deriving (Eq, Show) + +mcContent :: MsgContainer -> MsgContent +mcContent = \case + MCSimple c -> c + MCQuote _ c -> c + MCForward c -> c + +data MsgContent + = MCText Text + | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) msgContentText :: MsgContent -> Text msgContentText = \case MCText t -> t - MCUnknown _ t -> t + MCUnknown {text} -> text -toMsgContentType :: MsgContent -> MsgContentType -toMsgContentType = \case +msgContentTag :: MsgContent -> MsgContentTag +msgContentTag = \case MCText _ -> MCText_ - MCUnknown {} -> MCUnknown_ + MCUnknown {tag} -> MCUnknown_ tag + +parseMsgContainer :: J.Object -> JT.Parser MsgContainer +parseMsgContainer v = + MCQuote <$> v .: "quote" <*> mc + <|> (v .: "forward" >>= \f -> (if f then MCForward else MCSimple) <$> mc) + <|> MCSimple <$> mc + where + mc = v .: "content" instance FromJSON MsgContent where - parseJSON jv@(J.Object v) = do + parseJSON (J.Object v) = v .: "type" >>= \case MCText_ -> MCText <$> v .: "text" - MCUnknown_ -> MCUnknown jv . fromMaybe unknownMsgType <$> v .:? "text" + MCUnknown_ tag -> do + text <- fromMaybe unknownMsgType <$> v .:? "text" + pure MCUnknown {tag, text, json = v} parseJSON invalid = JT.prependFailure "bad MsgContent, " (JT.typeMismatch "Object" invalid) unknownMsgType :: Text unknownMsgType = "unknown message type" +msgContainerJSON :: MsgContainer -> J.Object +msgContainerJSON = \case + MCQuote qm c -> JM.fromList ["quote" .= qm, "content" .= c] + MCForward c -> JM.fromList ["forward" .= True, "content" .= c] + MCSimple c -> JM.fromList ["content" .= c] + instance ToJSON MsgContent where toJSON = \case - MCUnknown v _ -> v + MCUnknown {json} -> J.Object json MCText t -> J.object ["type" .= MCText_, "text" .= t] toEncoding = \case - MCUnknown v _ -> JE.value v + MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t +instance ToField MsgContent where + toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode + +instance FromField MsgContent where + fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8 + data CMEventTag = XMsgNew_ | XFile_ @@ -163,6 +252,7 @@ data CMEventTag | XInfoProbeCheck_ | XInfoProbeOk_ | XOk_ + | XUnknown_ Text deriving (Eq, Show) instance StrEncoding CMEventTag where @@ -188,6 +278,7 @@ instance StrEncoding CMEventTag where XInfoProbeCheck_ -> "x.info.probe.check" XInfoProbeOk_ -> "x.info.probe.ok" XOk_ -> "x.ok" + XUnknown_ t -> encodeUtf8 t strDecode = \case "x.msg.new" -> Right XMsgNew_ "x.file" -> Right XFile_ @@ -210,7 +301,7 @@ instance StrEncoding CMEventTag where "x.info.probe.check" -> Right XInfoProbeCheck_ "x.info.probe.ok" -> Right XInfoProbeOk_ "x.ok" -> Right XOk_ - _ -> Left "bad CMEventTag" + t -> Right . XUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') toCMEventTag :: ChatMsgEvent -> CMEventTag @@ -236,6 +327,7 @@ toCMEventTag = \case XInfoProbeCheck _ -> XInfoProbeCheck_ XInfoProbeOk _ -> XInfoProbeOk_ XOk -> XOk_ + XUnknown t _ -> XUnknown_ t cmEventTagT :: Text -> Maybe CMEventTag cmEventTagT = eitherToMaybe . strDecode . encodeUtf8 @@ -248,19 +340,21 @@ instance FromField CMEventTag where fromField = fromTextField_ cmEventTagT instance ToField CMEventTag where toField = toField . serializeCMEventTag appToChatMessage :: AppMessage -> Either String ChatMessage -appToChatMessage AppMessage {event, params} = do +appToChatMessage AppMessage {msgId, event, params} = do eventTag <- strDecode $ encodeUtf8 event chatMsgEvent <- msg eventTag - pure ChatMessage {chatMsgEvent} + pure ChatMessage {msgId, chatMsgEvent} where p :: FromJSON a => J.Key -> Either String a p key = JT.parseEither (.: key) params + opt :: FromJSON a => J.Key -> Either String (Maybe a) + opt key = JT.parseEither (.:? key) params msg = \case - XMsgNew_ -> XMsgNew <$> p "content" + XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params XFile_ -> XFile <$> p "file" XFileAcpt_ -> XFileAcpt <$> p "fileName" XInfo_ -> XInfo <$> p "profile" - XContact_ -> XContact <$> p "profile" <*> JT.parseEither (.:? "contactReqId") params + XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" @@ -277,19 +371,21 @@ appToChatMessage AppMessage {event, params} = do XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" XInfoProbeOk_ -> XInfoProbeOk <$> p "probe" XOk_ -> pure XOk + XUnknown_ t -> pure $ XUnknown t params chatToAppMessage :: ChatMessage -> AppMessage -chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params} +chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, params} where event = serializeCMEventTag . toCMEventTag $ chatMsgEvent o :: [(J.Key, J.Value)] -> J.Object o = JM.fromList + key .=? value = maybe id ((:) . (key .=)) value params = case chatMsgEvent of - XMsgNew content -> o ["content" .= content] + XMsgNew container -> msgContainerJSON container XFile fileInv -> o ["file" .= fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] - XInfo profile -> o $ ["profile" .= profile] - XContact profile xContactId -> o $ maybe id ((:) . ("contactReqId" .=)) xContactId ["profile" .= profile] + XInfo profile -> o ["profile" .= profile] + XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] @@ -306,3 +402,4 @@ chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params} XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] XInfoProbeOk probe -> o ["probe" .= probe] XOk -> JM.empty + XUnknown _ ps -> ps diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 784418eaac..7d0be6b857 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -105,7 +105,7 @@ module Simplex.Chat.Store updateFileTransferChatItemId, getFileTransfer, getFileTransferProgress, - createNewMessage, + createNewSndMessage, createSndMsgDelivery, createNewMessageAndRcvMsgDelivery, createSndMsgDeliveryEvent, @@ -113,11 +113,16 @@ module Simplex.Chat.Store createPendingGroupMessage, getPendingGroupMessages, deletePendingGroupMessage, - createNewChatItem, + createNewSndChatItem, + createNewRcvChatItem, getChatPreviews, getDirectChat, getGroupChat, getChatItemIdByAgentMsgId, + getDirectChatItem, + getGroupChatItem, + getDirectChatItemIdByText, + getGroupChatItemIdByText, updateDirectChatItem, updateDirectChatItemsRead, updateGroupChatItemsRead, @@ -143,7 +148,7 @@ import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, sortBy, sortOn) -import Data.Maybe (listToMaybe) +import Data.Maybe (isJust, listToMaybe) import Data.Ord (Down (..)) import Data.Text (Text) import qualified Data.Text as T @@ -162,6 +167,7 @@ import Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests import Simplex.Chat.Migrations.M20220224_messages_fks import Simplex.Chat.Migrations.M20220301_smp_servers import Simplex.Chat.Migrations.M20220302_profile_images +import Simplex.Chat.Migrations.M20220304_msg_quotes import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) @@ -170,6 +176,7 @@ import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) +import Simplex.Messaging.Protocol (MsgBody) import Simplex.Messaging.Util (liftIOEither, (<$$>)) import System.FilePath (takeFileName) import UnliftIO.STM @@ -182,7 +189,8 @@ schemaMigrations = ("20220210_deduplicate_contact_requests", m20220210_deduplicate_contact_requests), ("20220224_messages_fks", m20220224_messages_fks), ("20220301_smp_servers", m20220301_smp_servers), - ("20220302_profile_images", m20220302_profile_images) + ("20220302_profile_images", m20220302_profile_images), + ("20220304_msg_quotes", m20220304_msg_quotes) ] -- | The list of migrations in ascending order by date @@ -2023,11 +2031,30 @@ getSndFileTransfers_ db userId fileId = Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, recipientDisplayName, connId, agentConnId} Nothing -> Left $ SESndFileInvalid fileId -createNewMessage :: MonadUnliftIO m => SQLiteStore -> NewMessage -> ConnOrGroupId -> m MessageId -createNewMessage st newMsg connOrGroupId = - liftIO . withTransaction st $ \db -> do - currentTs <- getCurrentTime - createNewMessage_ db newMsg connOrGroupId currentTs +createNewSndMessage :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage) -> m SndMessage +createNewSndMessage st gVar connOrGroupId mkMessage = + liftIOEither . withTransaction st $ \db -> + createWithRandomId gVar $ \sharedMsgId -> do + createdAt <- getCurrentTime + DB.execute + db + "INSERT INTO messages (msg_sent, chat_msg_event, msg_body, shared_msg_id, shared_msg_id_user, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + (MDSnd, XUnknown_ "", "" :: MsgBody, sharedMsgId, Just True, createdAt, createdAt) + msgId <- insertedRowId db + let NewMessage {chatMsgEvent, msgBody} = mkMessage $ SharedMsgId sharedMsgId + DB.execute + db + [sql| + UPDATE messages + SET msg_sent = ?, chat_msg_event = ?, msg_body = ?, connection_id = ?, group_id = ? + WHERE message_id = ? + |] + (MDSnd, toCMEventTag chatMsgEvent, msgBody, connId_, groupId_, msgId) + pure SndMessage {msgId, sharedMsgId = SharedMsgId sharedMsgId, msgBody} + where + (connId_, groupId_) = case connOrGroupId of + ConnectionId connId -> (Just connId, Nothing) + GroupId groupId -> (Nothing, Just groupId) createSndMsgDelivery :: MonadUnliftIO m => SQLiteStore -> SndMsgDelivery -> MessageId -> m () createSndMsgDelivery st sndMsgDelivery messageId = @@ -2036,14 +2063,26 @@ createSndMsgDelivery st sndMsgDelivery messageId = msgDeliveryId <- createSndMsgDelivery_ db sndMsgDelivery messageId currentTs createMsgDeliveryEvent_ db msgDeliveryId MDSSndAgent currentTs -createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> NewMessage -> ConnOrGroupId -> RcvMsgDelivery -> m MessageId -createNewMessageAndRcvMsgDelivery st newMsg connOrGroupId rcvMsgDelivery = +createNewMessageAndRcvMsgDelivery :: MonadUnliftIO m => SQLiteStore -> ConnOrGroupId -> NewMessage -> Maybe SharedMsgId -> RcvMsgDelivery -> m RcvMessage +createNewMessageAndRcvMsgDelivery st connOrGroupId NewMessage {chatMsgEvent, msgBody} sharedMsgId_ RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime - messageId <- createNewMessage_ db newMsg connOrGroupId currentTs - msgDeliveryId <- createRcvMsgDelivery_ db rcvMsgDelivery messageId currentTs + DB.execute + db + "INSERT INTO messages (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id) VALUES (?,?,?,?,?,?,?,?)" + (MDRcv, toCMEventTag chatMsgEvent, msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_) + msgId <- insertedRowId db + DB.execute + db + "INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" + (msgId, connId, agentMsgId, msgMetaJson agentMsgMeta, snd $ broker agentMsgMeta, currentTs, currentTs) + msgDeliveryId <- insertedRowId db createMsgDeliveryEvent_ db msgDeliveryId MDSRcvAgent currentTs - pure messageId + pure RcvMessage {msgId, chatMsgEvent, sharedMsgId_, msgBody} + where + (connId_, groupId_) = case connOrGroupId of + ConnectionId connId' -> (Just connId', Nothing) + GroupId groupId -> (Nothing, Just groupId) createSndMsgDeliveryEvent :: StoreMonad m => SQLiteStore -> Int64 -> AgentMsgId -> MsgDeliveryStatus 'MDSnd -> m () createSndMsgDeliveryEvent st connId agentMsgId sndMsgDeliveryStatus = @@ -2061,22 +2100,6 @@ createRcvMsgDeliveryEvent st connId agentMsgId rcvMsgDeliveryStatus = currentTs <- getCurrentTime createMsgDeliveryEvent_ db msgDeliveryId rcvMsgDeliveryStatus currentTs -createNewMessage_ :: DB.Connection -> NewMessage -> ConnOrGroupId -> UTCTime -> IO MessageId -createNewMessage_ db NewMessage {direction, cmEventTag, msgBody} connOrGroupId createdAt = do - DB.execute - db - [sql| - INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id) - VALUES (?,?,?,?,?,?,?) - |] - (direction, cmEventTag, msgBody, createdAt, createdAt, connId_, groupId_) - insertedRowId db - where - (connId_, groupId_) = case connOrGroupId of - ConnectionId connId -> (Just connId, Nothing) - GroupId groupId -> (Nothing, Just groupId) - createSndMsgDelivery_ :: DB.Connection -> SndMsgDelivery -> MessageId -> UTCTime -> IO Int64 createSndMsgDelivery_ db SndMsgDelivery {connId, agentMsgId} messageId createdAt = do DB.execute @@ -2089,18 +2112,6 @@ createSndMsgDelivery_ db SndMsgDelivery {connId, agentMsgId} messageId createdAt (messageId, connId, agentMsgId, createdAt, createdAt, createdAt) insertedRowId db -createRcvMsgDelivery_ :: DB.Connection -> RcvMsgDelivery -> MessageId -> UTCTime -> IO Int64 -createRcvMsgDelivery_ db RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} messageId createdAt = do - DB.execute - db - [sql| - INSERT INTO msg_deliveries - (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at) - VALUES (?,?,?,?,?,?,?) - |] - (messageId, connId, agentMsgId, msgMetaJson agentMsgMeta, snd $ broker agentMsgMeta, createdAt, createdAt) - insertedRowId db - createMsgDeliveryEvent_ :: DB.Connection -> Int64 -> MsgDeliveryStatus d -> UTCTime -> IO () createMsgDeliveryEvent_ db msgDeliveryId msgDeliveryStatus createdAt = do DB.execute @@ -2160,37 +2171,133 @@ deletePendingGroupMessage st groupMemberId messageId = liftIO . withTransaction st $ \db -> DB.execute db "DELETE FROM pending_group_messages WHERE group_member_id = ? AND message_id = ?" (groupMemberId, messageId) -createNewChatItem :: (MonadUnliftIO m, MsgDirectionI d) => SQLiteStore -> UserId -> ChatDirection c d -> NewChatItem d -> m ChatItemId -createNewChatItem st userId chatDirection NewChatItem {createdByMsgId, itemSent, itemTs, itemContent, itemText, itemStatus, createdAt} = - liftIO . withTransaction st $ \db -> do - let (contactId_, groupId_, groupMemberId_) = ids - DB.execute - db - [sql| - INSERT INTO chat_items ( - user_id, contact_id, group_id, group_member_id, created_by_msg_id, - item_sent, item_ts, item_content, item_text, item_status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (userId, contactId_, groupId_, groupMemberId_, createdByMsgId) - :. (itemSent, itemTs, itemContent, itemText, itemStatus, createdAt, createdAt) - ) - ciId <- insertedRowId db - case createdByMsgId of - Nothing -> pure () - Just msgId -> - DB.execute - db - "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" - (ciId, msgId, createdAt, createdAt) - pure ciId +type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MemberId) + +createNewSndChatItem :: MonadUnliftIO m => SQLiteStore -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> UTCTime -> m ChatItemId +createNewSndChatItem st user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem createdAt = + liftIO . withTransaction st $ \db -> + createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow createdAt createdAt where - ids :: (Maybe Int64, Maybe Int64, Maybe Int64) - ids = case chatDirection of - CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing) + createdByMsgId = if msgId == 0 then Nothing else Just msgId + quoteRow :: NewQuoteRow + quoteRow = case quotedItem of + Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing) + Just (CIQuote {chatDir, sharedMsgId = quotedSharedMsgId, sentAt, content}) -> + uncurry (quotedSharedMsgId,Just sentAt,Just content,,) $ case chatDir of + CIQDirectSnd -> (Just True, Nothing) + CIQDirectRcv -> (Just False, Nothing) + CIQGroupSnd -> (Just True, Nothing) + CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) + CIQGroupRcv Nothing -> (Just False, Nothing) + +createNewRcvChatItem :: MonadUnliftIO m => SQLiteStore -> User -> ChatDirection c 'MDRcv -> RcvMessage -> CIContent 'MDRcv -> UTCTime -> UTCTime -> m (ChatItemId, Maybe (CIQuote c)) +createNewRcvChatItem st user chatDirection RcvMessage {msgId, chatMsgEvent, sharedMsgId_} ciContent itemTs createdAt = + liftIO . withTransaction st $ \db -> do + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemTs createdAt + quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg + pure (ciId, quotedItem) + where + quotedMsg = cmToQuotedMsg chatMsgEvent + quoteRow :: NewQuoteRow + quoteRow = case quotedMsg of + Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing) + Just QuotedMsg {msgRef = MsgRef {msgId = sharedMsgId, sentAt, sent, memberId}, content} -> + uncurry (sharedMsgId,Just sentAt,Just content,,) $ case chatDirection of + CDDirectRcv _ -> (Just $ not sent, Nothing) + CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> + (Just $ Just userMemberId == memberId, memberId) + +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> UTCTime -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection msgId sharedMsgId ciContent quoteRow itemTs createdAt = do + DB.execute + db + [sql| + INSERT INTO chat_items ( + -- user and IDs + user_id, created_by_msg_id, contact_id, group_id, group_member_id, + -- meta + item_sent, item_ts, item_content, item_text, item_status, shared_msg_id, created_at, updated_at, + -- quote + quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ((userId, msgId) :. idsRow :. itemRow :. quoteRow) + ciId <- insertedRowId db + when (isJust msgId) $ + DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (ciId, msgId, createdAt, createdAt) + pure ciId + where + itemRow :: (SMsgDirection d, UTCTime, CIContent d, Text, CIStatus d, Maybe SharedMsgId, UTCTime, UTCTime) + itemRow = (msgDirection @d, itemTs, ciContent, ciContentToText ciContent, ciStatusNew @d, sharedMsgId, createdAt, createdAt) + idsRow :: (Maybe Int64, Maybe Int64, Maybe Int64) + idsRow = case chatDirection of CDDirectRcv Contact {contactId} -> (Just contactId, Nothing, Nothing) - CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing) + CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing) CDGroupRcv GroupInfo {groupId} GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId) + CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing) + +getChatItemQuote_ :: DB.Connection -> User -> ChatDirection c 'MDRcv -> QuotedMsg -> IO (CIQuote c) +getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRef = MsgRef {msgId, sentAt, sent, memberId}, content} = + case chatDirection of + CDDirectRcv Contact {contactId} -> getDirectChatItemQuote_ contactId (not sent) + CDGroupRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} sender@GroupMember {memberId = senderMemberId} -> + case memberId of + Just mId + | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId + | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId mId + | otherwise -> getGroupChatItemQuote_ groupId mId + _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing + where + ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c + ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content + getDirectChatItemQuote_ :: Int64 -> Bool -> IO (CIQuote 'CTDirect) + getDirectChatItemQuote_ contactId userSent = do + ciQuoteDirect . listToMaybe . map fromOnly + <$> DB.query + db + "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ?" + (userId, contactId, msgId, userSent) + where + ciQuoteDirect :: Maybe ChatItemId -> CIQuote 'CTDirect + ciQuoteDirect = (`ciQuote` if userSent then CIQDirectSnd else CIQDirectRcv) + getUserGroupChatItemId_ :: Int64 -> IO (Maybe ChatItemId) + getUserGroupChatItemId_ groupId = + listToMaybe . map fromOnly + <$> DB.query + db + "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id IS NULL" + (userId, groupId, msgId, MDSnd) + getGroupChatItemId_ :: Int64 -> MemberId -> IO (Maybe ChatItemId) + getGroupChatItemId_ groupId mId = + listToMaybe . map fromOnly + <$> DB.query + db + "SELECT chat_item_id FROM chat_items WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? AND item_sent = ? AND group_member_id = ?" + (userId, groupId, msgId, MDRcv, mId) + getGroupChatItemQuote_ :: Int64 -> MemberId -> IO (CIQuote 'CTGroup) + getGroupChatItemQuote_ groupId mId = do + ciQuoteGroup + <$> DB.queryNamed + db + [sql| + SELECT i.chat_item_id, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, + m.member_status, m.invited_by, m.local_display_name, m.contact_id, + p.display_name, p.full_name, p.image + FROM group_members m + JOIN contact_profiles p ON m.contact_profile_id = p.contact_profile_id + LEFT JOIN contacts c ON m.contact_id = c.contact_id + LEFT JOIN chat_items i ON i.group_id = m.group_id + AND m.group_member_id = i.group_member_id + AND i.shared_msg_id = :msg_id + WHERE m.user_id = :user_id AND m.group_id = :group_id AND m.member_id = :member_id + |] + [":user_id" := userId, ":group_id" := groupId, ":member_id" := mId, ":msg_id" := msgId] + where + ciQuoteGroup :: [Only (Maybe ChatItemId) :. GroupMemberRow] -> CIQuote 'CTGroup + ciQuoteGroup [] = ciQuote Nothing $ CIQGroupRcv Nothing + ciQuoteGroup ((Only itemId :. memberRow) : _) = ciQuote itemId . CIQGroupRcv . Just $ toGroupMember userContactId memberRow getChatPreviews :: MonadUnliftIO m => SQLiteStore -> User -> m [AChat] getChatPreviews st user = @@ -2226,7 +2333,9 @@ getDirectChatPreviews_ db User {userId} = do -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + -- DirectQuote + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id JOIN connections c ON c.contact_id = ct.contact_id @@ -2236,14 +2345,15 @@ getDirectChatPreviews_ db User {userId} = do WHERE item_deleted != 1 GROUP BY contact_id ) MaxIds ON MaxIds.contact_id = ct.contact_id - LEFT JOIN chat_items ci ON ci.contact_id = MaxIds.contact_id - AND ci.chat_item_id = MaxIds.MaxId + LEFT JOIN chat_items i ON i.contact_id = MaxIds.contact_id + AND i.chat_item_id = MaxIds.MaxId LEFT JOIN ( SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE item_status = ? GROUP BY contact_id ) ChatStats ON ChatStats.contact_id = ct.contact_id + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE ct.user_id = ? AND c.connection_id = ( SELECT cc_connection_id FROM ( @@ -2256,11 +2366,11 @@ getDirectChatPreviews_ db User {userId} = do LIMIT 1 ) ) - ORDER BY ci.item_ts DESC + ORDER BY i.item_ts DESC |] (CISRcvNew, userId, ConnReady, ConnSndReady) where - toDirectChatPreview :: TimeZone -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow -> AChat + toDirectChatPreview :: TimeZone -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat toDirectChatPreview tz (contactRow :. connRow :. statsRow :. ciRow_) = let contact = toContact $ contactRow :. connRow ci_ = toDirectChatItemList tz ciRow_ @@ -2284,11 +2394,17 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image + p.display_name, p.full_name, p.image, + -- quoted ChatItem + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, + -- quoted GroupMember + rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, + rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, + rp.display_name, rp.full_name, rp.image FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -2299,18 +2415,21 @@ getGroupChatPreviews_ db User {userId, userContactId} = do WHERE item_deleted != 1 GROUP BY group_id ) MaxIds ON MaxIds.group_id = g.group_id - LEFT JOIN chat_items ci ON ci.group_id = MaxIds.group_id - AND ci.chat_item_id = MaxIds.MaxId + LEFT JOIN chat_items i ON i.group_id = MaxIds.group_id + AND i.chat_item_id = MaxIds.MaxId LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items WHERE item_status = ? GROUP BY group_id ) ChatStats ON ChatStats.group_id = g.group_id - LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id + LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id WHERE g.user_id = ? AND mu.contact_id = ? - ORDER BY ci.item_ts DESC + ORDER BY i.item_ts DESC |] (CISRcvNew, userId, userContactId) where @@ -2367,10 +2486,13 @@ getDirectChatLast_ db User {userId} contactId count = do [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at - FROM chat_items ci - WHERE ci.user_id = ? AND ci.contact_id = ? - ORDER BY ci.chat_item_id DESC + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + -- DirectQuote + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent + FROM chat_items i + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + WHERE i.user_id = ? AND i.contact_id = ? + ORDER BY i.chat_item_id DESC LIMIT ? |] (userId, contactId, count) @@ -2391,10 +2513,13 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at - FROM chat_items ci - WHERE ci.user_id = ? AND ci.contact_id = ? AND ci.chat_item_id > ? - ORDER BY ci.chat_item_id ASC + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + -- DirectQuote + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent + FROM chat_items i + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id > ? + ORDER BY i.chat_item_id ASC LIMIT ? |] (userId, contactId, afterChatItemId, count) @@ -2415,10 +2540,13 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at - FROM chat_items ci - WHERE ci.user_id = ? AND ci.contact_id = ? AND ci.chat_item_id < ? - ORDER BY ci.chat_item_id DESC + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + -- DirectQuote + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent + FROM chat_items i + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id < ? + ORDER BY i.chat_item_id DESC LIMIT ? |] (userId, contactId, beforeChatItemId, count) @@ -2511,16 +2639,25 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image - FROM chat_items ci - LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id + p.display_name, p.full_name, p.image, + -- quoted ChatItem + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, + -- quoted GroupMember + rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, + rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, + rp.display_name, rp.full_name, rp.image + FROM chat_items i + LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id - WHERE ci.user_id = ? AND ci.group_id = ? - ORDER BY ci.item_ts DESC, ci.chat_item_id DESC + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id + WHERE i.user_id = ? AND i.group_id = ? + ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] (userId, groupId, count) @@ -2541,16 +2678,25 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image - FROM chat_items ci - LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id + p.display_name, p.full_name, p.image, + -- quoted ChatItem + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, + -- quoted GroupMember + rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, + rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, + rp.display_name, rp.full_name, rp.image + FROM chat_items i + LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id - WHERE ci.user_id = ? AND ci.group_id = ? AND ci.chat_item_id > ? - ORDER BY ci.item_ts ASC, ci.chat_item_id ASC + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id + WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id > ? + ORDER BY i.item_ts ASC, i.chat_item_id ASC LIMIT ? |] (userId, groupId, afterChatItemId, count) @@ -2571,16 +2717,25 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI [sql| SELECT -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, - p.display_name, p.full_name, p.image - FROM chat_items ci - LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id + p.display_name, p.full_name, p.image, + -- quoted ChatItem + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, + -- quoted GroupMember + rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, + rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, + rp.display_name, rp.full_name, rp.image + FROM chat_items i + LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id - WHERE ci.user_id = ? AND ci.group_id = ? AND ci.chat_item_id < ? - ORDER BY ci.item_ts DESC, ci.chat_item_id DESC + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id + WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id < ? + ORDER BY i.item_ts DESC, i.chat_item_id DESC LIMIT ? |] (userId, groupId, beforeChatItemId, count) @@ -2655,36 +2810,134 @@ getChatItemIdByAgentMsgId st connId msgId = |] (connId, msgId) -updateDirectChatItem :: (StoreMonad m, MsgDirectionI d) => SQLiteStore -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) -updateDirectChatItem st itemId itemStatus = +updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) +updateDirectChatItem st userId contactId itemId itemStatus = liftIOEither . withTransaction st $ \db -> runExceptT $ do - ci <- ExceptT $ getDirectChatItem_ db itemId + ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId currentTs <- liftIO getCurrentTime - liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE chat_item_id = ?" (itemStatus, currentTs, itemId) + liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, contactId, itemId) pure ci {meta = (meta ci) {itemStatus}} - -getDirectChatItem_ :: forall d. MsgDirectionI d => DB.Connection -> ChatItemId -> IO (Either StoreError (ChatItem 'CTDirect d)) -getDirectChatItem_ db itemId = do - tz <- getCurrentTimeZone - join - <$> firstRow - (correctDir <=< toDirectChatItem tz) - (SEChatItemNotFound itemId) - ( DB.query - db - [sql| - SELECT - -- ChatItem - ci.chat_item_id, ci.item_ts, ci.item_content, ci.item_text, ci.item_status, ci.created_at - FROM chat_items ci - WHERE ci.chat_item_id = ? - |] - (Only itemId) - ) where correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci +getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ChatItemId -> m (CChatItem 'CTDirect) +getDirectChatItem st userId contactId itemId = + liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId + +getDirectChatItem_ :: DB.Connection -> UserId -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTDirect)) +getDirectChatItem_ db userId contactId itemId = do + tz <- getCurrentTimeZone + join <$> firstRow (toDirectChatItem tz) (SEChatItemNotFound itemId) getItem + where + getItem = + DB.query + db + [sql| + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + -- DirectQuote + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent + FROM chat_items i + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ? + |] + (userId, contactId, itemId) + +getDirectChatItemIdByText :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> SMsgDirection d -> Text -> m ChatItemId +getDirectChatItemIdByText st userId contactId msgDir quotedMsg = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly SEQuotedChatItemNotFound $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND item_sent = ? AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, contactId, msgDir, quotedMsg <> "%") + +getGroupChatItem :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatItemId -> m (CChatItem 'CTGroup) +getGroupChatItem st User {userId, userContactId} groupId itemId = + liftIOEither . withTransaction st $ \db -> do + tz <- getCurrentTimeZone + join <$> firstRow (toGroupChatItem tz userContactId) (SEChatItemNotFound itemId) (getItem db) + where + getItem db = + DB.query + db + [sql| + SELECT + -- ChatItem + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + -- GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, + m.member_status, m.invited_by, m.local_display_name, m.contact_id, + p.display_name, p.full_name, p.image, + -- quoted ChatItem + ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, + -- quoted GroupMember + rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, + rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, + rp.display_name, rp.full_name, rp.image + FROM chat_items i + LEFT JOIN group_members m ON m.group_member_id = i.group_member_id + LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id + LEFT JOIN group_members rm ON rm.group_member_id = ri.group_member_id + LEFT JOIN contact_profiles rp ON rp.contact_profile_id = rm.contact_profile_id + WHERE i.user_id = ? AND i.group_id = ? AND i.chat_item_id = ? + |] + (userId, groupId, itemId) + +getGroupChatItemIdByText :: StoreMonad m => SQLiteStore -> User -> Int64 -> Maybe ContactName -> Text -> m ChatItemId +getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId contactName_ quotedMsg = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly SEQuotedChatItemNotFound $ case contactName_ of + Nothing -> anyMemberChatItem_ db + Just cName + | userName == cName -> userChatItem_ db + | otherwise -> memberChatItem_ db cName + where + anyMemberChatItem_ db = + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, groupId, quotedMsg <> "%") + userChatItem_ db = + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NULL AND item_text like ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, groupId, quotedMsg <> "%") + memberChatItem_ db cName = + DB.query + db + [sql| + SELECT i.chat_item_id + FROM chat_items i + JOIN group_members m ON m.group_member_id = i.group_member_id + JOIN contacts c ON c.contact_id = m.contact_id + WHERE i.user_id = ? AND i.group_id = ? AND c.local_display_name = ? AND i.item_text like ? + ORDER BY i.chat_item_id DESC + LIMIT 1 + |] + (userId, groupId, cName, quotedMsg <> "%") + updateDirectChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> (ChatItemId, ChatItemId) -> m () updateDirectChatItemsRead st contactId (fromItemId, toItemId) = do currentTs <- liftIO getCurrentTime @@ -2714,49 +2967,79 @@ type ChatStatsRow = (Int, ChatItemId) toChatStats :: ChatStatsRow -> ChatStats toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadItemId} -type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, UTCTime) +type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, UTCTime) -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe UTCTime) +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe UTCTime) -toDirectChatItem :: TimeZone -> ChatItemRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem tz (itemId, itemTs, itemContent, itemText, itemStatus, createdAt) = +type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) + +-- type DirectChatItemRow = ChatItemRow :. DirectQuoteRow + +-- type MaybeDirectChatItemRow = MaybeChatItemRow :. DirectQuoteRow + +-- toQuoteData :: QuoteDataRow -> Maybe CIQuoteData +-- toQuoteData (quotedItemId, quotedSentAt, quotedMsgContent) = +-- CIQuoteData quotedItemId <$> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) + +toDirectQuote :: QuoteRow -> Maybe (CIQuote 'CTDirect) +toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction <$> quotedSent + where + direction sent = if sent then CIQDirectSnd else CIQDirectRcv + +toQuote :: QuoteRow -> Maybe (CIQDirection c) -> Maybe (CIQuote c) +toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir = + CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) + +toDirectChatItem :: TimeZone -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) +toDirectChatItem tz ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. quoteRow) = case (itemContent, itemStatus) of (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus) -> Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus) -> Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent _ -> badItem where - cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection c d -> CIStatus d -> CIContent d -> CChatItem c - cItem d cid ciStatus ciContent = CChatItem d (ChatItem cid (ciMeta ciStatus) ciContent $ parseMaybeMarkdownList itemText) + cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> CChatItem 'CTDirect + cItem d chatDir ciStatus content = + CChatItem d ChatItem {chatDir, meta = ciMeta ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow} badItem = Left $ SEBadChatItem itemId ciMeta :: CIStatus d -> CIMeta d - ciMeta status = mkCIMeta itemId itemText status tz itemTs createdAt + ciMeta status = mkCIMeta itemId itemText status sharedMsgId tz itemTs createdAt -toDirectChatItemList :: TimeZone -> MaybeChatItemRow -> [CChatItem 'CTDirect] -toDirectChatItemList tz (Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, Just createdAt) = - either (const []) (: []) $ toDirectChatItem tz (itemId, itemTs, itemContent, itemText, itemStatus, createdAt) +toDirectChatItemList :: TimeZone -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] +toDirectChatItemList tz ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just createdAt) :. quoteRow) = + either (const []) (: []) $ toDirectChatItem tz ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. quoteRow) toDirectChatItemList _ _ = [] -type GroupChatItemRow = ChatItemRow :. MaybeGroupMemberRow +type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow -type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow +type MaybeGroupChatItemRow = MaybeChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -toGroupChatItem :: TimeZone -> Int64 -> GroupChatItemRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, createdAt) :. memberRow_) = do +toGroupQuote :: QuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup) +toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction quotedSent quotedMember_ + where + direction (Just True) _ = Just CIQGroupSnd + direction (Just False) (Just member) = Just . CIQGroupRcv $ Just member + direction (Just False) Nothing = Just $ CIQGroupRcv Nothing + direction _ _ = Nothing + +toGroupChatItem :: TimeZone -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup) +toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do let member_ = toMaybeGroupMember userContactId memberRow_ + let quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ case (itemContent, itemStatus, member_) of - (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Nothing) -> Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member) -> Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _) -> Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent quotedMember_ + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member) -> Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent quotedMember_ _ -> badItem where - cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection c d -> CIStatus d -> CIContent d -> CChatItem c - cItem d cid ciStatus ciContent = CChatItem d (ChatItem cid (ciMeta ciStatus) ciContent $ parseMaybeMarkdownList itemText) + cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe GroupMember -> CChatItem 'CTGroup + cItem d chatDir ciStatus content quotedMember_ = + CChatItem d ChatItem {chatDir, meta = ciMeta ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_} badItem = Left $ SEBadChatItem itemId ciMeta :: CIStatus d -> CIMeta d - ciMeta status = mkCIMeta itemId itemText status tz itemTs createdAt + ciMeta status = mkCIMeta itemId itemText status sharedMsgId tz itemTs createdAt toGroupChatItemList :: TimeZone -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, Just createdAt) :. memberRow_) = - either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, createdAt) :. memberRow_) +toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = + either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) toGroupChatItemList _ _ _ = [] getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer] @@ -2876,6 +3159,7 @@ data StoreError | SENoMsgDelivery {connId :: Int64, agentMsgId :: AgentMsgId} | SEBadChatItem {itemId :: ChatItemId} | SEChatItemNotFound {itemId :: ChatItemId} + | SEQuotedChatItemNotFound deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 833911acd9..7a1a3017d4 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -48,6 +48,8 @@ runInputLoop ct cc = forever $ do Right SendGroupMessage {} -> True Right SendFile {} -> True Right SendGroupFile {} -> True + Right SendMessageQuote {} -> True + Right SendGroupMessageQuote {} -> True _ -> False runTerminalInput :: ChatTerminal -> ChatController -> IO () @@ -98,8 +100,10 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition _ -> ts where insertCharsWithContact cs - | null s && cs /= "@" && cs /= "#" && cs /= "/" = + | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" = insertChars $ contactPrefix <> cs + | s == ">" && cs == " " = + insertChars $ cs <> contactPrefix | otherwise = insertChars cs insertChars = ts' . if p >= length s then append else insert append cs = let s' = s <> cs in (s', length s') diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index af40b1a5a0..07d952c0af 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -4,6 +4,9 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} module Simplex.Chat.View where @@ -54,6 +57,7 @@ responseToView testView = \case HSFiles -> filesHelpInfo HSGroups -> groupsHelpInfo HSMyAddress -> myAddressHelpInfo + HSQuotes -> quotesHelpInfo HSMarkdown -> markdownInfo CRWelcome user -> chatWelcome user CRContactsList cs -> viewContactsList cs @@ -106,7 +110,7 @@ responseToView testView = \case CRContactSubscribed c -> [ttyContact' c <> ": connected to server"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] CRContactSubSummary summary -> - (if null subscribed then [] else [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)"]) <> viewErrorsSummary errors " contact errors" + [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" where (errors, subscribed) = partition (isJust . contactError) summary CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} -> @@ -148,37 +152,60 @@ responseToView testView = \case testViewChat :: AChat -> [StyledString] testViewChat (AChat _ Chat {chatItems}) = [sShow $ map toChatView chatItems] where - toChatView :: CChatItem c -> (Int, Text) - toChatView (CChatItem dir ChatItem {meta}) = (msgDirectionInt $ toMsgDirection dir, itemText meta) + toChatView :: CChatItem c -> ((Int, Text), Maybe (Int, Text)) + toChatView (CChatItem dir ChatItem {meta, quotedItem}) = + ((msgDirectionInt $ toMsgDirection dir, itemText meta),) $ case quotedItem of + Nothing -> Nothing + Just CIQuote {chatDir = quoteDir, content} -> + Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content) viewErrorsSummary :: [a] -> StyledString -> [StyledString] - viewErrorsSummary summary s = if null summary then [] else [styled (colored Red) (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)"] + viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] -viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString] -viewChatItem chat (ChatItem cd meta content _) = case (chat, cd) of - (DirectChat c, CIDirectSnd) -> case content of - CISndMsgContent mc -> viewSentMessage to mc meta - CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta +viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] +viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of + DirectChat c -> case chatDir of + CIDirectSnd -> case content of + CISndMsgContent mc -> viewSentMessage to quote mc meta + CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta + where + to = ttyToContact' c + CIDirectRcv -> case content of + CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft + where + from = ttyFromContact' c where - to = ttyToContact' c - (DirectChat c, CIDirectRcv) -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from meta mc -- mOk - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk + quote = maybe [] (directQuote chatDir) quotedItem + GroupChat g -> case chatDir of + CIGroupSnd -> case content of + CISndMsgContent mc -> viewSentMessage to quote mc meta + CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta + where + to = ttyToGroup g + CIGroupRcv m -> case content of + CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft + where + from = ttyFromGroup' g m where - from = ttyFromContact' c - (GroupChat g, CIGroupSnd) -> case content of - CISndMsgContent mc -> viewSentMessage to mc meta - CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta - where - to = ttyToGroup g - (GroupChat g, CIGroupRcv m) -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from meta mc -- mOk - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk - where - from = ttyFromGroup' g m + quote = maybe [] (groupQuote g) quotedItem + _ -> [] where - ttyToContact' Contact {localDisplayName = c} = ttyToContact c - ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c - ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m + directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString] + directQuote _ CIQuote {content = qmc, chatDir = qouteDir} = + quoteText qmc $ if toMsgDirection (msgDirection @d') == quoteMsgDirection qouteDir then ">>" else ">" + groupQuote :: GroupInfo -> CIQuote 'CTGroup -> [StyledString] + groupQuote g CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc . ttyQuotedMember $ sentByMember g quoteDir + sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember + sentByMember GroupInfo {membership} = \case + CIQGroupSnd -> Just membership + CIQGroupRcv m -> m + quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc + msgPreview = msgPlain . preview . msgContentText + where + preview t + | T.length t <= 60 = t + | otherwise = t <> "..." viewMsgIntegrityError :: MsgErrorType -> [StyledString] viewMsgIntegrityError err = msgError $ case err of @@ -190,7 +217,7 @@ viewMsgIntegrityError err = msgError $ case err of MsgDuplicate -> "duplicate message ID" where msgError :: String -> [StyledString] - msgError s = [styled (colored Red) s] + msgError s = [ttyError s] viewInvalidConnReq :: [StyledString] viewInvalidConnReq = @@ -357,13 +384,14 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewReceivedMessage :: StyledString -> CIMeta d -> MsgContent -> [StyledString] -viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc) +viewReceivedMessage :: StyledString -> [StyledString] -> CIMeta d -> MsgContent -> [StyledString] +viewReceivedMessage from quote meta = receivedWithTime_ from quote meta . ttyMsgContent -receivedWithTime_ :: StyledString -> CIMeta d -> [StyledString] -> [StyledString] -receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do - prependFirst (formattedTime <> " " <> from) styledMsg -- ++ showIntegrity mOk +receivedWithTime_ :: StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString] +receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do + prependFirst (formattedTime <> " " <> from) (quote <> prependFirst indent styledMsg) where + indent = if null quote then "" else " " formattedTime :: StyledString formattedTime = let localTime = zonedTimeToLocalTime localItemTs @@ -375,8 +403,10 @@ receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do else "%H:%M" in styleTime $ formatTime defaultTimeLocale format localTime -viewSentMessage :: StyledString -> MsgContent -> CIMeta d -> [StyledString] -viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent +viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString] +viewSentMessage to quote mc = sentWithTime_ . prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc) + where + indent = if null quote then "" else " " viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta d -> [StyledString] viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath @@ -389,9 +419,7 @@ ttyMsgTime :: ZonedTime -> StyledString ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M" ttyMsgContent :: MsgContent -> [StyledString] -ttyMsgContent = \case - MCText t -> msgPlain t - MCUnknown _ t -> msgPlain t +ttyMsgContent = msgPlain . msgContentText ttySentFile :: StyledString -> FileTransferId -> FilePath -> [StyledString] ttySentFile to fId fPath = ["/f " <> to <> ttyFilePath fPath, "use " <> highlight ("/fc " <> show fId) <> " to cancel sending"] @@ -421,7 +449,7 @@ sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName viewReceivedFileInvitation :: StyledString -> CIMeta d -> RcvFileTransfer -> [StyledString] -viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft) +viewReceivedFileInvitation from meta ft = receivedWithTime_ from [] meta (receivedFileInvitation_ ft) receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] receivedFileInvitation_ RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} = @@ -523,6 +551,7 @@ viewChatError = \case CEFileSend fileId e -> ["error sending file " <> sShow fileId <> ": " <> sShow e] CEFileRcvChunk e -> ["error receiving file: " <> plain e] CEFileInternal e -> ["file error: " <> plain e] + CEInvalidQuote -> ["cannot reply to this message"] CEAgentVersion -> ["unsupported agent version"] CECommandError e -> ["bad chat command: " <> plain e] -- e -> ["chat error: " <> sShow e] @@ -539,6 +568,7 @@ viewChatError = \case SEUserContactLinkNotFound -> ["no chat address, to create: " <> highlight' "/ad"] SEContactRequestNotFoundByName c -> ["no contact request from " <> ttyContact c] SEConnectionNotFound _ -> [] -- TODO mutes delete group error, but also mutes any error from getConnectionEntity + SEQuotedChatItemNotFound -> ["message not found - reply is not sent"] e -> ["chat db error: " <> sShow e] ChatErrorAgent err -> case err of SMP SMP.AUTH -> ["error: this connection is deleted"] @@ -547,7 +577,7 @@ viewChatError = \case fileNotFound fileId = ["file " <> sShow fileId <> " not found"] ttyContact :: ContactName -> StyledString -ttyContact = styled (colored Green) +ttyContact = styled $ colored Green ttyContact' :: Contact -> StyledString ttyContact' Contact {localDisplayName = c} = ttyContact c @@ -570,7 +600,20 @@ ttyToContact :: ContactName -> StyledString ttyToContact c = styled (colored Cyan) $ "@" <> c <> " " ttyFromContact :: ContactName -> StyledString -ttyFromContact c = styled (colored Yellow) $ c <> "> " +ttyFromContact c = ttyFrom $ c <> "> " + +ttyToContact' :: Contact -> StyledString +ttyToContact' Contact {localDisplayName = c} = ttyToContact c + +ttyQuotedContact :: Contact -> StyledString +ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ c <> ">" + +ttyQuotedMember :: Maybe GroupMember -> StyledString +ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom c +ttyQuotedMember _ = "> " <> ttyFrom "?" + +ttyFromContact' :: Contact -> StyledString +ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c ttyGroup :: GroupName -> StyledString ttyGroup g = styled (colored Blue) $ "#" <> g @@ -588,7 +631,13 @@ ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullN ttyGroup g <> optFullName g fullName ttyFromGroup :: GroupInfo -> ContactName -> StyledString -ttyFromGroup GroupInfo {localDisplayName = g} c = styled (colored Yellow) $ "#" <> g <> " " <> c <> "> " +ttyFromGroup GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c <> "> " + +ttyFrom :: Text -> StyledString +ttyFrom = styled $ colored Yellow + +ttyFromGroup' :: GroupInfo -> GroupMember -> StyledString +ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m ttyToGroup :: GroupInfo -> StyledString ttyToGroup GroupInfo {localDisplayName = g} = styled (colored Cyan) $ "#" <> g <> " " @@ -602,10 +651,16 @@ optFullName localDisplayName fullName | otherwise = plain (" (" <> fullName <> ")") highlight :: StyledFormat a => a -> StyledString -highlight = styled (colored Cyan) +highlight = styled $ colored Cyan highlight' :: String -> StyledString highlight' = highlight styleTime :: String -> StyledString styleTime = Styled [SetColor Foreground Vivid Black] + +ttyError :: StyledFormat a => a -> StyledString +ttyError = styled $ colored Red + +ttyError' :: String -> StyledString +ttyError' = ttyError diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index dd996011cb..b27780430d 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -13,6 +13,8 @@ import Control.Concurrent.STM import Control.Exception (bracket, bracket_) import Control.Monad.Except import Data.List (dropWhileEnd) +import Data.Maybe (fromJust) +import qualified Data.Text as T import Network.Socket import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) @@ -20,7 +22,7 @@ import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Terminal import Simplex.Chat.Terminal.Output (newChatTerminal) -import Simplex.Chat.Types (Profile) +import Simplex.Chat.Types (Profile, User (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Server (runSMPServerBlocking) @@ -132,12 +134,16 @@ testChatN ps test = withTmpFiles $ do getTermLine :: TestCC -> IO String getTermLine = atomically . readTQueue . termQ --- Use below to echo virtual terminal +-- Use code below to echo virtual terminal -- getTermLine cc = do -- s <- atomically . readTQueue $ termQ cc --- putStrLn s +-- name <- userName cc +-- putStrLn $ name <> ": " <> s -- pure s +userName :: TestCC -> IO [Char] +userName (TestCC ChatController {currentUser} _ _ _ _) = T.unpack . localDisplayName . fromJust <$> readTVarIO currentUser + testChat2 :: Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO () testChat2 p1 p2 test = testChatN [p1, p2] test_ where diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index dcce728279..ca270a60b4 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -11,7 +11,6 @@ import Control.Concurrent.Async (concurrently_) import Control.Concurrent.STM import qualified Data.ByteString as B import Data.Char (isDigit) -import Data.Maybe (fromJust) import qualified Data.Text as T import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Types (Profile (..), ProfileImage (..), User (..)) @@ -33,10 +32,9 @@ danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing} chatTests :: Spec chatTests = do - describe "direct messages" $ + describe "direct messages" $ do it "add contact and send/receive message" testAddContact - describe "SMP servers" $ - it "get and set SMP servers" testGetSetSMPServers + it "direct message quoted replies" testDirectMessageQuotedReply describe "chat groups" $ do it "add contacts, create group and send/receive messages" testGroup it "create and join group with 4 members" testGroup2 @@ -45,6 +43,7 @@ chatTests = do it "re-add member in status invited" testGroupReAddInvited it "remove contact from group and add again" testGroupRemoveAdd it "list groups containing group invitations" testGroupList + it "group message quoted replies" testGroupMessageQuotedReply describe "user profiles" $ do it "update user profiles and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage @@ -61,6 +60,8 @@ chatTests = do it "deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange it "reject contact and delete contact link" testRejectContactAndDeleteUserContact it "delete connection requests when contact link deleted" testDeleteConnectionRequests + describe "SMP servers" $ + it "get and set SMP servers" testGetSetSMPServers testAddContact :: IO () testAddContact = @@ -73,31 +74,13 @@ testAddContact = concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") - -- empty chats - alice #$$> ("/_get chats", [("@bob", "")]) - alice #$> ("/_get chat @2 count=100", chat, []) - bob #$$> ("/_get chats", [("@alice", "")]) - bob #$> ("/_get chat @2 count=100", chat, []) - -- one message + chatsEmpty alice bob alice #> "@bob hello πŸ™‚" bob <# "alice> hello πŸ™‚" - alice #$$> ("/_get chats", [("@bob", "hello πŸ™‚")]) - alice #$> ("/_get chat @2 count=100", chat, [(1, "hello πŸ™‚")]) - bob #$$> ("/_get chats", [("@alice", "hello πŸ™‚")]) - bob #$> ("/_get chat @2 count=100", chat, [(0, "hello πŸ™‚")]) - -- many messages + chatsOneMessage alice bob bob #> "@alice hi" alice <# "bob> hi" - alice #$$> ("/_get chats", [("@bob", "hi")]) - alice #$> ("/_get chat @2 count=100", chat, [(1, "hello πŸ™‚"), (0, "hi")]) - bob #$$> ("/_get chats", [("@alice", "hi")]) - bob #$> ("/_get chat @2 count=100", chat, [(0, "hello πŸ™‚"), (1, "hi")]) - -- pagination - alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hi")]) - alice #$> ("/_get chat @2 before=2 count=100", chat, [(1, "hello πŸ™‚")]) - -- read messages - alice #$> ("/_read chat @2 from=1 to=100", id, "ok") - bob #$> ("/_read chat @2 from=1 to=100", id, "ok") + chatsManyMessages alice bob -- test adding the same contact one more time - local name will be different alice ##> "/c" inv' <- getInvitation alice @@ -119,18 +102,53 @@ testAddContact = alice <## "no contact bob_1" alice #$$> ("/_get chats", [("@bob", "hi")]) bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")]) + where + chatsEmpty alice bob = do + alice #$$> ("/_get chats", [("@bob", "")]) + alice #$> ("/_get chat @2 count=100", chat, []) + bob #$$> ("/_get chats", [("@alice", "")]) + bob #$> ("/_get chat @2 count=100", chat, []) + chatsOneMessage alice bob = do + alice #$$> ("/_get chats", [("@bob", "hello πŸ™‚")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "hello πŸ™‚")]) + bob #$$> ("/_get chats", [("@alice", "hello πŸ™‚")]) + bob #$> ("/_get chat @2 count=100", chat, [(0, "hello πŸ™‚")]) + chatsManyMessages alice bob = do + alice #$$> ("/_get chats", [("@bob", "hi")]) + alice #$> ("/_get chat @2 count=100", chat, [(1, "hello πŸ™‚"), (0, "hi")]) + bob #$$> ("/_get chats", [("@alice", "hi")]) + bob #$> ("/_get chat @2 count=100", chat, [(0, "hello πŸ™‚"), (1, "hi")]) + -- pagination + alice #$> ("/_get chat @2 after=1 count=100", chat, [(0, "hi")]) + alice #$> ("/_get chat @2 before=2 count=100", chat, [(1, "hello πŸ™‚")]) + -- read messages + alice #$> ("/_read chat @2 from=1 to=100", id, "ok") + bob #$> ("/_read chat @2 from=1 to=100", id, "ok") -testGetSetSMPServers :: IO () -testGetSetSMPServers = +testDirectMessageQuotedReply :: IO () +testDirectMessageQuotedReply = do testChat2 aliceProfile bobProfile $ - \alice _ -> do - alice #$> ("/smp_servers", id, "no custom SMP servers saved") - alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok") - alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im") - alice #$> ("/smp_servers smp://2345-w==@smp2.example.im,smp://3456-w==@smp3.example.im:5224", id, "ok") - alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") - alice #$> ("/smp_servers default", id, "ok") - alice #$> ("/smp_servers", id, "no custom SMP servers saved") + \alice bob -> do + connectUsers alice bob + alice ##> "/_send @2 json {\"type\": \"text\", \"text\": \"hello! how are you?\"}" + alice <# "@bob hello! how are you?" + bob <# "alice> hello! how are you?" + bob #> "@alice hi!" + alice <# "bob> hi!" + bob `send` "> @alice (hello) all good - you?" + bob <# "@alice > hello! how are you?" + bob <## " all good - you?" + alice <# "bob> > hello! how are you?" + alice <## " all good - you?" + bob #$> ("/_get chat @2 count=1", chat', [((1, "all good - you?"), Just (0, "hello! how are you?"))]) + alice #$> ("/_get chat @2 count=1", chat', [((0, "all good - you?"), Just (1, "hello! how are you?"))]) + bob `send` ">> @alice (all good) will tell more" + bob <# "@alice >> all good - you?" + bob <## " will tell more" + alice <# "bob> >> all good - you?" + alice <## " will tell more" + bob #$> ("/_get chat @2 count=1", chat', [((1, "will tell more"), Just (1, "all good - you?"))]) + alice #$> ("/_get chat @2 count=1", chat', [((0, "will tell more"), Just (0, "all good - you?"))]) testGroup :: IO () testGroup = @@ -184,18 +202,7 @@ testGroup = (alice <# "#team cath> hey team") (bob <# "#team cath> hey team") bob <##> cath - -- get and read chats - alice #$$> ("/_get chats", [("#team", "hey team"), ("@cath", ""), ("@bob", "")]) - alice #$> ("/_get chat #1 count=100", chat, [(1, "hello"), (0, "hi there"), (0, "hey team")]) - alice #$> ("/_get chat #1 after=1 count=100", chat, [(0, "hi there"), (0, "hey team")]) - alice #$> ("/_get chat #1 before=3 count=100", chat, [(1, "hello"), (0, "hi there")]) - bob #$$> ("/_get chats", [("@cath", "hey"), ("#team", "hey team"), ("@alice", "")]) - bob #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (1, "hi there"), (0, "hey team")]) - cath #$$> ("/_get chats", [("@bob", "hey"), ("#team", "hey team"), ("@alice", "")]) - cath #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (0, "hi there"), (1, "hey team")]) - alice #$> ("/_read chat #1 from=1 to=100", id, "ok") - bob #$> ("/_read chat #1 from=1 to=100", id, "ok") - cath #$> ("/_read chat #1 from=1 to=100", id, "ok") + getReadChats alice bob cath -- list groups alice ##> "/gs" alice <## "#team" @@ -230,6 +237,19 @@ testGroup = cath ##> "#team hello" cath <## "you are no longer a member of the group" bob <##> cath + where + getReadChats alice bob cath = do + alice #$$> ("/_get chats", [("#team", "hey team"), ("@cath", ""), ("@bob", "")]) + alice #$> ("/_get chat #1 count=100", chat, [(1, "hello"), (0, "hi there"), (0, "hey team")]) + alice #$> ("/_get chat #1 after=1 count=100", chat, [(0, "hi there"), (0, "hey team")]) + alice #$> ("/_get chat #1 before=3 count=100", chat, [(1, "hello"), (0, "hi there")]) + bob #$$> ("/_get chats", [("@cath", "hey"), ("#team", "hey team"), ("@alice", "")]) + bob #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (1, "hi there"), (0, "hey team")]) + cath #$$> ("/_get chats", [("@bob", "hey"), ("#team", "hey team"), ("@alice", "")]) + cath #$> ("/_get chat #1 count=100", chat, [(0, "hello"), (0, "hi there"), (1, "hey team")]) + alice #$> ("/_read chat #1 from=1 to=100", id, "ok") + bob #$> ("/_read chat #1 from=1 to=100", id, "ok") + cath #$> ("/_read chat #1 from=1 to=100", id, "ok") testGroup2 :: IO () testGroup2 = @@ -543,6 +563,75 @@ testGroupList = bob ##> "/gs" bob <## "#team" +testGroupMessageQuotedReply :: IO () +testGroupMessageQuotedReply = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice #> "#team hello! how are you?" + concurrently_ + (bob <# "#team alice> hello! how are you?") + (cath <# "#team alice> hello! how are you?") + threadDelay 1000000 + bob `send` "> #team @alice (hello) hello, all good, you?" + bob <# "#team > alice hello! how are you?" + bob <## " hello, all good, you?" + concurrently_ + ( do + alice <# "#team bob> > alice hello! how are you?" + alice <## " hello, all good, you?" + ) + ( do + cath <# "#team bob> > alice hello! how are you?" + cath <## " hello, all good, you?" + ) + bob #$> ("/_get chat #1 count=100", chat', [((0, "hello! how are you?"), Nothing), ((1, "hello, all good, you?"), Just (0, "hello! how are you?"))]) + alice #$> ("/_get chat #1 count=100", chat', [((1, "hello! how are you?"), Nothing), ((0, "hello, all good, you?"), Just (1, "hello! how are you?"))]) + cath #$> ("/_get chat #1 count=100", chat', [((0, "hello! how are you?"), Nothing), ((0, "hello, all good, you?"), Just (0, "hello! how are you?"))]) + bob `send` "> #team bob (hello, all good) will tell more" + bob <# "#team > bob hello, all good, you?" + bob <## " will tell more" + concurrently_ + ( do + alice <# "#team bob> > bob hello, all good, you?" + alice <## " will tell more" + ) + ( do + cath <# "#team bob> > bob hello, all good, you?" + cath <## " will tell more" + ) + bob #$> ("/_get chat #1 count=1", chat', [((1, "will tell more"), Just (1, "hello, all good, you?"))]) + alice #$> ("/_get chat #1 count=1", chat', [((0, "will tell more"), Just (0, "hello, all good, you?"))]) + cath #$> ("/_get chat #1 count=1", chat', [((0, "will tell more"), Just (0, "hello, all good, you?"))]) + threadDelay 1000000 + cath `send` "> #team bob (hello) hi there!" + cath <# "#team > bob hello, all good, you?" + cath <## " hi there!" + concurrently_ + ( do + alice <# "#team cath> > bob hello, all good, you?" + alice <## " hi there!" + ) + ( do + bob <# "#team cath> > bob hello, all good, you?" + bob <## " hi there!" + ) + cath #$> ("/_get chat #1 count=1", chat', [((1, "hi there!"), Just (0, "hello, all good, you?"))]) + alice #$> ("/_get chat #1 count=1", chat', [((0, "hi there!"), Just (0, "hello, all good, you?"))]) + bob #$> ("/_get chat #1 count=1", chat', [((0, "hi there!"), Just (1, "hello, all good, you?"))]) + alice `send ` "> #team (will tell) go on" + alice <# "#team > bob will tell more" + alice <## " go on" + concurrently_ + ( do + bob <# "#team alice> > bob will tell more" + bob <## " go on" + ) + ( do + cath <# "#team alice> > bob will tell more" + cath <## " go on" + ) + testUpdateProfile :: IO () testUpdateProfile = testChat3 aliceProfile bobProfile cathProfile $ @@ -593,6 +682,8 @@ testUpdateProfileImage = -- Note we currently don't support removing profile image. alice ##> "/profile_image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" alice <## "profile image updated" + alice ##> "/profile_image" + alice <## "profile image removed" (bob ("/c " <> cLink') alice <#? cath +testGetSetSMPServers :: IO () +testGetSetSMPServers = + testChat2 aliceProfile bobProfile $ + \alice _ -> do + alice #$> ("/smp_servers", id, "no custom SMP servers saved") + alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok") + alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im") + alice #$> ("/smp_servers smp://2345-w==@smp2.example.im,smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + alice #$> ("/smp_servers default", id, "ok") + alice #$> ("/smp_servers", id, "no custom SMP servers saved") + startFileTransfer :: TestCC -> TestCC -> IO () startFileTransfer alice bob = do alice #> "/f @bob ./tests/fixtures/test.jpg" @@ -1042,9 +1145,6 @@ cc1 <##> cc2 = do cc2 #> ("@" <> name1 <> " hey") cc1 <# (name2 <> "> hey") -userName :: TestCC -> IO [Char] -userName (TestCC ChatController {currentUser} _ _ _ _) = T.unpack . localDisplayName . fromJust <$> readTVarIO currentUser - (##>) :: TestCC -> String -> IO () cc ##> cmd = do cc `send` cmd @@ -1061,7 +1161,10 @@ cc #$> (cmd, f, res) = do (f <$> getTermLine cc) `shouldReturn` res chat :: String -> [(Int, String)] -chat = read +chat = map fst . chat' + +chat' :: String -> [((Int, String), Maybe (Int, String))] +chat' = read (#$$>) :: TestCC -> (String, [(String, String)]) -> Expectation cc #$$> (cmd, res) = do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index a94f0d66b9..fba1ce62a5 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -7,6 +7,7 @@ module ProtocolTests where import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) +import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol @@ -54,15 +55,26 @@ testE2ERatchetParams = E2ERatchetParamsUri e2eEncryptVRange testDhPubKey testDhP testConnReq :: ConnectionRequestUri 'CMInvitation testConnReq = CRInvitationUri connReqData testE2ERatchetParams +(==##) :: ByteString -> ChatMessage -> Expectation +s ==## msg = do + strDecode s `shouldBe` Right msg + parseAll strP s `shouldBe` Right msg + +(##==) :: ByteString -> ChatMessage -> Expectation +s ##== msg = + J.eitherDecodeStrict' (strEncode msg) + `shouldBe` (J.eitherDecodeStrict' s :: Either String J.Value) + +(##==##) :: ByteString -> ChatMessage -> Expectation +s ##==## msg = do + s ##== msg + s ==## msg + (==#) :: ByteString -> ChatMsgEvent -> Expectation -s ==# msg = do - strDecode s `shouldBe` Right (ChatMessage msg) - parseAll strP s `shouldBe` Right (ChatMessage msg) +s ==# msg = s ==## (ChatMessage Nothing msg) (#==) :: ByteString -> ChatMsgEvent -> Expectation -s #== msg = - J.eitherDecodeStrict' (strEncode $ ChatMessage msg) - `shouldBe` (J.eitherDecodeStrict' s :: Either String J.Value) +s #== msg = s ##== (ChatMessage Nothing msg) (#==#) :: ByteString -> ChatMsgEvent -> Expectation s #==# msg = do @@ -77,7 +89,24 @@ testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do - it "x.msg.new" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgNew (MCText "hello") + it "x.msg.new" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgNew (MCSimple $ MCText "hello") + it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCSimple $ MCText "hello")) + it "x.msg.new" $ + "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" + ##==## ( ChatMessage + (Just $ SharedMsgId "\1\2\3\4") + ( XMsgNew $ + MCQuote + ( QuotedMsg + (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) + $ MCText "hello there!" + ) + (MCText "hello to you too") + ) + ) + it "x.msg.new" $ + "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" + ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCForward $ MCText "hello")) it "x.file" $ "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = testConnReq}