Compare commits

..

19 Commits

Author SHA1 Message Date
Evgeny Poberezkin
b956f80132 update http2 2023-11-02 12:35:58 +00:00
Evgeny Poberezkin
34b07d6a3b core: update simplexmq (http2 lib update to fix sending files) 2023-11-02 10:44:24 +00:00
Evgeny Poberezkin
5bdbba1117 Merge branch 'master' into ep/journal-mode-wal 2023-11-02 10:36:43 +00:00
Stanislav Dmitrenko
fad5128a83 android, desktop: updated Compose and changed mac notarization tool (#3303)
* android, desktop: updated Compose and changed mac notarization tool

* imports

* desktop (mac): fix lib building

* imports

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-01 19:11:04 +00:00
Stanislav Dmitrenko
4fd38a270c desktop: adding build version code to UI (#3304) 2023-11-01 18:23:41 +00:00
Stanislav Dmitrenko
4cc20a2d32 android, desktop: block members (#3290)
* android, desktop: block members

* fixes

* more fixes

* fix

* fix

* color

* color and icon

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-11-01 13:52:45 +00:00
spaced4ndy
68873464d7 docs: groups integrity DAGs rfc (#3258) 2023-11-01 17:30:40 +04:00
spaced4ndy
c1a0486c1d docs: groups integrity rfc (#3128) 2023-11-01 17:30:19 +04:00
Evgeny Poberezkin
381346cdba command to get/set SQLite journalling mode 2023-10-08 08:16:30 +01:00
Evgeny Poberezkin
7fe940e921 Merge branch 'master' into ep/journal-mode-wal 2023-10-07 21:15:50 +01:00
Evgeny Poberezkin
5878d4608c ios: close store when app is about to terminate 2023-10-01 10:58:16 +01:00
Evgeny Poberezkin
b26195e581 Merge branch 'master' into ep/journal-mode-wal 2023-09-30 20:12:01 +01:00
Evgeny Poberezkin
4d37eff26c api types 2023-09-30 20:04:21 +01:00
Evgeny Poberezkin
4d2826f490 add delay to test 2023-09-30 19:52:17 +01:00
Evgeny Poberezkin
c4ac5a784f use functions from simplexmq, fix tests 2023-09-30 17:49:43 +01:00
Evgeny Poberezkin
d32adf6f6c update simplexmq 2023-09-30 11:57:22 +01:00
Evgeny Poberezkin
8d6fee89db Merge branch 'master' into ep/journal-mode-wal 2023-09-29 16:50:41 +01:00
Evgeny Poberezkin
eb22f32d18 fix simplexmq 2023-09-28 17:51:34 +01:00
Evgeny Poberezkin
497ef087c5 checkpoint on stop and on encryption change, switch journal_mode to DELETE on export and back to WAL after 2023-09-28 17:13:06 +01:00
60 changed files with 1309 additions and 807 deletions

View File

@@ -17,7 +17,8 @@ typedef void* chat_ctrl;
// the last parameter is used to return the pointer to chat controller
extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl);
extern char *chat_close_store(chat_ctrl ctl);
extern char *chat_close_store(chat_ctrl ctl)
extern char *chat_open_store(chat_ctrl ctl, char *key);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);

View File

@@ -8,7 +8,7 @@ plugins {
}
android {
compileSdkVersion(33)
compileSdkVersion(34)
defaultConfig {
applicationId = "chat.simplex.app"
@@ -144,7 +144,7 @@ dependencies {
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
//androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
debugImplementation("androidx.compose.ui:ui-tooling:${rootProject.extra["compose.version"] as String}")
debugImplementation("androidx.compose.ui:ui-tooling:1.4.3")
}
tasks {

View File

@@ -36,7 +36,7 @@ buildscript {
extra.set("desktop.mac.signing.keychain", prop["desktop.mac.signing.keychain"] ?: extra.getOrNull("compose.desktop.mac.signing.keychain"))
extra.set("desktop.mac.notarization.apple_id", prop["desktop.mac.notarization.apple_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.appleID"))
extra.set("desktop.mac.notarization.password", prop["desktop.mac.notarization.password"] ?: extra.getOrNull("compose.desktop.mac.notarization.password"))
extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.ascProvider"))
extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.teamID"))
repositories {
google()

View File

@@ -107,7 +107,7 @@ kotlin {
}
android {
compileSdkVersion(33)
compileSdkVersion(34)
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
minSdkVersion(26)
@@ -138,6 +138,7 @@ buildConfig {
buildConfigField("String", "ANDROID_VERSION_NAME", "\"${extra["android.version_name"]}\"")
buildConfigField("int", "ANDROID_VERSION_CODE", "${extra["android.version_code"]}")
buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"")
buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}")
}
}

View File

@@ -23,3 +23,5 @@ actual fun Modifier.desktopOnExternalDrag(
onImage: (Painter) -> Unit,
onText: (String) -> Unit
): Modifier = this
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this

View File

@@ -7,6 +7,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.helpers.*
@Composable

View File

@@ -1,48 +0,0 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this
actual interface DefaultExposedDropdownMenuBoxScope {
@Composable
actual fun DefaultExposedDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier,
content: @Composable ColumnScope.() -> Unit
) {
DropdownMenu(expanded, onDismissRequest, modifier, content = content)
}
@Composable
fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit
) {
androidx.compose.material.DropdownMenu(expanded, onDismissRequest, modifier, offset, properties, content)
}
}
@Composable
actual fun DefaultExposedDropdownMenuBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier,
content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit
) {
val scope = remember { object : DefaultExposedDropdownMenuBoxScope {} }
androidx.compose.material.ExposedDropdownMenuBox(expanded, onExpandedChange, modifier, content = {
scope.content()
})
}

View File

@@ -137,6 +137,7 @@ object ChatModel {
fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }
fun getGroupMember(groupMemberId: Long): GroupMember? = groupMembers.firstOrNull { it.groupMemberId == groupMemberId }
private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id }
fun addChat(chat: Chat) = chats.add(index = 0, chat)
@@ -442,6 +443,78 @@ object ChatModel {
}
}
fun getChatItemIndexOrNull(cItem: ChatItem): Int? {
val reversedChatItems = chatItems.asReversed()
val index = reversedChatItems.indexOfFirst { it.id == cItem.id }
return if (index != -1) index else null
}
// this function analyses "connected" events and assumes that each member will be there only once
fun getConnectedMemberNames(cItem: ChatItem): Pair<Int, List<String>> {
var count = 0
val ns = mutableListOf<String>()
var idx = getChatItemIndexOrNull(cItem)
if (cItem.mergeCategory != null && idx != null) {
val reversedChatItems = chatItems.asReversed()
while (idx < reversedChatItems.size) {
val ci = reversedChatItems[idx]
if (ci.mergeCategory != cItem.mergeCategory) break
val m = ci.memberConnected
if (m != null) {
ns.add(m.displayName)
}
count++
idx++
}
}
return count to ns
}
// returns the index of the passed item and the next item (it has smaller index)
fun getNextChatItem(ci: ChatItem): Pair<Int?, ChatItem?> {
val i = getChatItemIndexOrNull(ci)
return if (i != null) {
val reversedChatItems = chatItems.asReversed()
i to if (i > 0) reversedChatItems[i - 1] else null
} else {
null to null
}
}
// returns the index of the first item in the same merged group (the first hidden item)
// and the previous visible item with another merge category
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair<Int?, ChatItem?> {
var i = ciIndex ?: return null to null
val reversedChatItems = chatItems.asReversed()
val fst = reversedChatItems.lastIndex
while (i < fst) {
i++
val ci = reversedChatItems[i]
if (ciCategory == null || ciCategory != ci.mergeCategory) {
return i - 1 to ci
}
}
return i to null
}
// returns the previous member in the same merge group and the count of members in this group
fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair<GroupMember?, Int> {
val reversedChatItems = chatItems.asReversed()
var prevMember: GroupMember? = null
val names: MutableSet<Long> = mutableSetOf()
for (i in range) {
val dir = reversedChatItems[i].chatDir
if (dir is CIDirection.GroupRcv) {
val m = dir.groupMember
if (prevMember == null && m.groupMemberId != member.groupMemberId) {
prevMember = m
}
names.add(m.groupMemberId)
}
}
return prevMember to names.size
}
// func popChat(_ id: String) {
// if let i = getChatIndex(id) {
// popChat_(i)
@@ -474,7 +547,7 @@ object ChatModel {
}
// update current chat
return if (chatId.value == groupInfo.id) {
val memberIndex = groupMembers.indexOfFirst { it.id == member.id }
val memberIndex = groupMembers.indexOfFirst { it.groupMemberId == member.groupMemberId }
if (memberIndex >= 0) {
groupMembers[memberIndex] = member
false
@@ -1090,11 +1163,11 @@ data class GroupMember (
val groupMemberId: Long,
val groupId: Long,
val memberId: String,
var memberRole: GroupMemberRole,
var memberCategory: GroupMemberCategory,
var memberStatus: GroupMemberStatus,
var memberSettings: GroupMemberSettings,
var invitedBy: InvitedBy,
val memberRole: GroupMemberRole,
val memberCategory: GroupMemberCategory,
val memberStatus: GroupMemberStatus,
val memberSettings: GroupMemberSettings,
val invitedBy: InvitedBy,
val localDisplayName: String,
val memberProfile: LocalProfile,
val memberContactId: Long? = null,
@@ -1467,7 +1540,7 @@ data class ChatItem (
chatController.appPrefs.privacyEncryptLocalFiles.get()
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.chatViewName
else null
val isDeletedContent: Boolean get() =
@@ -1491,6 +1564,29 @@ data class ChatItem (
else -> null
}
val mergeCategory: CIMergeCategory?
get() = when (content) {
is CIContent.RcvChatFeature,
is CIContent.SndChatFeature,
is CIContent.RcvGroupFeature,
is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.UserRole, is RcvGroupEvent.UserDeleted, is RcvGroupEvent.GroupDeleted, is RcvGroupEvent.MemberCreatedContact -> null
else -> CIMergeCategory.RcvGroupEvent
}
is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) {
is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft -> null
else -> CIMergeCategory.SndGroupEvent
}
else -> {
if (meta.itemDeleted == null) {
null
} else {
if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted
}
}
}
fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember>? {
return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
val m = chatInfo.groupInfo.membership
@@ -1695,6 +1791,15 @@ data class ChatItem (
}
}
enum class CIMergeCategory {
MemberConnected,
RcvGroupEvent,
SndGroupEvent,
SndItemDeleted,
RcvItemDeleted,
ChatFeature,
}
@Serializable
sealed class CIDirection {
@Serializable @SerialName("directSnd") class DirectSnd: CIDirection()
@@ -1895,7 +2000,9 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent()
@Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent()
// legacy - since v4.3.0 itemDeleted field is used
@Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
// legacy - since v4.3.0 itemDeleted field is used
@Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }

View File

@@ -745,6 +745,9 @@ object ChatController {
}
}
suspend fun apiSetMemberSettings(groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean =
sendCommandOkResp(CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings))
suspend fun apiContactInfo(contactId: Long): Pair<ConnectionStats, Profile?>? {
val r = sendCmd(CC.APIContactInfo(contactId))
if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile
@@ -1926,6 +1929,7 @@ sealed class CC {
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
class APIGetNetworkConfig: CC()
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC()
class APIContactInfo(val contactId: Long): CC()
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
class APISwitchContact(val contactId: Long): CC()
@@ -2036,6 +2040,7 @@ sealed class CC {
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}"
is APIContactInfo -> "/_info @$contactId"
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
is APISwitchContact -> "/_switch @$contactId"
@@ -2139,9 +2144,10 @@ sealed class CC {
is APITestProtoServer -> "testProtoServer"
is APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "/apiSetNetworkConfig"
is APIGetNetworkConfig -> "/apiGetNetworkConfig"
is APISetChatSettings -> "/apiSetChatSettings"
is APISetNetworkConfig -> "apiSetNetworkConfig"
is APIGetNetworkConfig -> "apiGetNetworkConfig"
is APISetChatSettings -> "apiSetChatSettings"
is ApiSetMemberSettings -> "apiSetMemberSettings"
is APIContactInfo -> "apiContactInfo"
is APIGroupMemberInfo -> "apiGroupMemberInfo"
is APISwitchContact -> "apiSwitchContact"

View File

@@ -21,7 +21,7 @@ expect val appPlatform: AppPlatform
val appVersionInfo: Pair<String, Int?> = if (appPlatform == AppPlatform.ANDROID)
BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE
else
BuildConfigCommon.DESKTOP_VERSION_NAME to null
BuildConfigCommon.DESKTOP_VERSION_NAME to BuildConfigCommon.DESKTOP_VERSION_CODE
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {

View File

@@ -20,3 +20,5 @@ expect fun Modifier.desktopOnExternalDrag(
onImage: (Painter) -> Unit = {},
onText: (String) -> Unit = {}
): Modifier
expect fun Modifier.onRightClick(action: () -> Unit): Modifier

View File

@@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
@@ -382,7 +383,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List<MemberDeliveryStatus>): List<Pair<GroupMember, CIStatus>> {
return memberDeliveryStatuses.mapNotNull { mds ->
chatModel.groupMembers.firstOrNull { it.groupMemberId == mds.groupMemberId }?.let { mem ->
chatModel.getGroupMember(mds.groupMemberId)?.let { mem ->
mem to mds.memberDeliveryStatus
}
}

View File

@@ -152,6 +152,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null
chatModel.groupMembers.clear()
},
info = {
if (ModalManager.end.hasModalsOpen()) {
@@ -212,7 +213,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
setGroupMembers(groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
}
}
@@ -263,6 +264,25 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
},
deleteMessages = { itemIds ->
if (itemIds.isNotEmpty()) {
val chatInfo = chat.chatInfo
withBGApi {
val deletedItems: ArrayList<ChatItem> = arrayListOf()
for (itemId in itemIds) {
val di = chatModel.controller.apiDeleteChatItem(
chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal
)?.deletedChatItem?.chatItem
if (di != null) {
deletedItems.add(di)
}
}
for (di in deletedItems) {
chatModel.removeChatItem(chatInfo, di)
}
}
}
},
receiveFile = { fileId, encrypted ->
withApi { chatModel.controller.receiveFile(user, fileId, encrypted) }
},
@@ -442,6 +462,7 @@ fun ChatLayout(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
@@ -517,7 +538,7 @@ fun ChatLayout(
) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools,
@@ -744,6 +765,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
@@ -846,31 +868,27 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val nextItem = if (i - 1 >= 0) reversedChatItems[i - 1] else null
fun getConnectedMemberNames(): List<String> {
val ns = mutableListOf<String>()
var idx = i
while (idx < reversedChatItems.size) {
val m = reversedChatItems[idx].memberConnected
if (m != null) {
ns.add(m.displayName)
} else {
break
}
idx++
}
return ns
}
if (cItem.memberConnected != null && nextItem?.memberConnected != null) {
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
Box(Modifier.size(0.dp)) {}
} else {
val revealed = remember { mutableStateOf(false) }
@Composable
fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
}
@Composable
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) {
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val member = cItem.chatDir.groupMember
if (showMemberImage(member, prevItem)) {
val (prevMember, memCount) =
if (range != null) {
chatModel.getPrevHiddenMember(member, range)
} else {
null to 1
}
if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) {
Column(
Modifier
.padding(top = 8.dp)
@@ -880,7 +898,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
if (cItem.content.showMemberName) {
Text(
member.displayName,
memberNames(member, prevMember, memCount),
Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp),
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
@@ -898,7 +916,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
MemberImage(member)
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
ChatItemViewShortHand(cItem, range)
}
}
} else {
@@ -907,28 +925,45 @@ fun BoxWithConstraintsScope.ChatItemsList(
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
.then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
ChatItemViewShortHand(cItem, range)
}
}
} else {
Box(
Modifier
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
.then(swipeableModifier)
) {
ChatItemViewShortHand(cItem, range)
}
}
} else {
} else { // direct message
val sent = cItem.chatDir.sent
Box(
Modifier
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
.then(swipeableModifier)
Modifier.padding(
start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp,
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
ChatItemViewShortHand(cItem, range)
}
}
} else { // direct message
val sent = cItem.chatDir.sent
Box(
Modifier.padding(
start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp,
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
}
val (currIndex, nextItem) = chatModel.getNextChatItem(cItem)
val ciCategory = cItem.mergeCategory
if (ciCategory != null && ciCategory == nextItem?.mergeCategory) {
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
} else {
val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
val range = chatViewItemsRange(currIndex, prevHidden)
if (revealed.value && range != null) {
reversedChatItems.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
val prev = if (index + range.first == prevHidden) prevItem else reversedChatItems[index + range.first + 1]
ChatItemView(ci, null, prev)
}
} else {
ChatItemView(cItem, range, prevItem)
}
}
@@ -1106,10 +1141,12 @@ fun PreloadItems(
}
}
fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd ||
(prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
}
private fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean =
when (val dir = prevItem?.chatDir) {
is CIDirection.GroupSnd -> true
is CIDirection.GroupRcv -> dir.groupMember.groupMemberId != member.groupMemberId
else -> false
}
val MEMBER_IMAGE_SIZE: Dp = 38.dp
@@ -1206,6 +1243,29 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
}
}
@Composable
private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String {
val name = member.displayName
val prevName = prevMember?.displayName
return if (prevName != null) {
if (memCount > 2) {
stringResource(MR.strings.group_members_n).format(name, prevName, memCount - 2)
} else {
stringResource(MR.strings.group_members_2).format(name, prevName)
}
} else {
name
}
}
fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? =
if (currIndex != null && prevHidden != null && prevHidden > currIndex) {
currIndex..prevHidden
} else {
null
}
sealed class ProviderMedia {
data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia()
data class Video(val uri: URI, val preview: String): ProviderMedia()
@@ -1347,6 +1407,7 @@ fun PreviewChatLayout() {
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = { _, _ -> },
@@ -1418,6 +1479,7 @@ fun PreviewGroupChatLayout() {
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
deleteMessages = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = { _, _ -> },

View File

@@ -4,10 +4,12 @@ import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewLongClickable
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
@@ -31,6 +33,7 @@ import chat.simplex.common.views.usersettings.*
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import kotlinx.coroutines.launch
@@ -82,7 +85,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
member to null
}
ModalManager.end.showModalCloseable(true) { closeCurrent ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
closeCurrent()
close()
@@ -157,6 +160,23 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U
)
}
private fun removeMemberAlert(groupInfo: GroupInfo, mem: GroupMember) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.button_remove_member_question),
text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone),
confirmText = generalGetString(MR.strings.remove_member_confirmation),
onConfirm = {
withApi {
val updatedMember = chatModel.controller.apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
if (updatedMember != null) {
chatModel.upsertGroupMember(groupInfo, updatedMember)
}
}
},
destructive = true,
)
}
@Composable
fun GroupChatInfoLayout(
chat: Chat,
@@ -238,8 +258,10 @@ fun GroupChatInfoLayout(
}
items(filteredMembers.value) { member ->
Divider()
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
MemberRow(member)
val showMenu = remember { mutableStateOf(false) }
SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp) {
DropDownMenuForMember(member, groupInfo, showMenu)
MemberRow(member, onClick = { showMemberInfo(member) })
}
}
item {
@@ -344,7 +366,7 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick
}
@Composable
private fun MemberRow(member: GroupMember, user: Boolean = false) {
private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -390,6 +412,29 @@ private fun MemberVerifiedShield() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary)
}
@Composable
private fun DropDownMenuForMember(member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
DefaultDropdownMenu(showMenu) {
if (member.canBeRemoved(groupInfo)) {
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
removeMemberAlert(groupInfo, member)
showMenu.value = false
})
}
if (member.memberSettings.showMessages) {
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
blockMemberAlert(groupInfo, member)
showMenu.value = false
})
} else {
ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
unblockMemberAlert(groupInfo, member)
showMenu.value = false
})
}
}
}
@Composable
private fun GroupLinkButton(onClick: () -> Unit) {
SettingsActionItem(

View File

@@ -96,6 +96,8 @@ fun GroupMemberInfoView(
connectViaAddress = { connReqUri ->
connectViaMemberAddressAlert(connReqUri)
},
blockMember = { blockMemberAlert(groupInfo, member) },
unblockMember = { unblockMemberAlert(groupInfo, member) },
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
onRoleSelected = {
if (it == newRole.value) return@GroupMemberInfoLayout
@@ -162,7 +164,7 @@ fun GroupMemberInfoView(
},
verifyClicked = {
ModalManager.end.showModalCloseable { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
@@ -224,6 +226,8 @@ fun GroupMemberInfoLayout(
openDirectChat: (Long) -> Unit,
createMemberContact: () -> Unit,
connectViaAddress: (String) -> Unit,
blockMember: () -> Unit,
unblockMember: () -> Unit,
removeMember: () -> Unit,
onRoleSelected: (GroupMemberRole) -> Unit,
switchMemberAddress: () -> Unit,
@@ -338,9 +342,14 @@ fun GroupMemberInfoLayout(
}
}
if (member.canBeRemoved(groupInfo)) {
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
if (member.memberSettings.showMessages) {
BlockMemberButton(blockMember)
} else {
UnblockMemberButton(unblockMember)
}
if (member.canBeRemoved(groupInfo)) {
RemoveMemberButton(removeMember)
}
}
@@ -396,6 +405,26 @@ fun GroupMemberInfoHeader(member: GroupMember) {
}
}
@Composable
fun BlockMemberButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_back_hand),
stringResource(MR.strings.block_member_button),
click = onClick,
textColor = Color.Red,
iconColor = Color.Red,
)
}
@Composable
fun UnblockMemberButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_do_not_touch),
stringResource(MR.strings.unblock_member_button),
click = onClick
)
}
@Composable
fun RemoveMemberButton(onClick: () -> Unit) {
SettingsActionItem(
@@ -485,6 +514,43 @@ fun connectViaMemberAddressAlert(connReqUri: String) {
}
}
fun blockMemberAlert(gInfo: GroupInfo, mem: GroupMember) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.block_member_question),
text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.block_member_confirmation),
onConfirm = {
toggleShowMemberMessages(gInfo, mem, false)
},
destructive = true,
)
}
fun unblockMemberAlert(gInfo: GroupInfo, mem: GroupMember) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.unblock_member_question),
text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.unblock_member_confirmation),
onConfirm = {
toggleShowMemberMessages(gInfo, mem, true)
},
)
}
fun toggleShowMemberMessages(gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) {
val updatedMemberSettings = member.memberSettings.copy(showMessages = showMessages)
updateMemberSettings(gInfo, member, updatedMemberSettings)
}
fun updateMemberSettings(gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) {
withBGApi {
val success = ChatController.apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings)
if (success) {
ChatModel.upsertGroupMember(gInfo, member.copy(memberSettings = memberSettings))
}
}
}
@Preview
@Composable
fun PreviewGroupMemberInfoLayout() {
@@ -500,6 +566,8 @@ fun PreviewGroupMemberInfoLayout() {
openDirectChat = {},
createMemberContact = {},
connectViaAddress = {},
blockMember = {},
unblockMember = {},
removeMember = {},
onRoleSelected = {},
switchMemberAddress = {},

View File

@@ -1,19 +1,119 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.Feature
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
import chat.simplex.common.platform.onRightClick
@Composable
fun CIChatFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color,
icon: Painter? = null,
revealed: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
) {
val merged = if (!revealed.value) mergedFeatures(chatItem) else emptyList()
Box(
Modifier
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = {}
)
.onRightClick { showMenu.value = true }
) {
if (!revealed.value && merged != null) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
merged.forEach {
FeatureIconView(it)
}
}
} else {
FullFeatureView(chatItem, feature, iconColor, icon)
}
}
}
private data class FeatureInfo(
val icon: PainterBox,
val color: Color,
val param: String?
)
private class PainterBox(
val featureName: String,
val icon: Painter,
) {
override fun hashCode(): Int = featureName.hashCode()
override fun equals(other: Any?): Boolean = other is PainterBox && featureName == other.featureName
}
@Composable
private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): FeatureInfo =
FeatureInfo(
icon = PainterBox(type, iconFilled()),
color = color,
param = if (this.hasParam && param != null) timeText(param) else null
)
@Composable
private fun mergedFeatures(chatItem: ChatItem): List<FeatureInfo>? {
val m = ChatModel
val fs: ArrayList<FeatureInfo> = arrayListOf()
val icons: MutableSet<PainterBox> = mutableSetOf()
var i = getChatItemIndexOrNull(chatItem)
if (i != null) {
val reversedChatItems = m.chatItems.asReversed()
while (i < reversedChatItems.size) {
val f = featureInfo(reversedChatItems[i]) ?: break
if (!icons.contains(f.icon)) {
fs.add(0, f)
icons.add(f.icon)
}
i++
}
}
return if (fs.size > 1) fs else null
}
@Composable
private fun featureInfo(ci: ChatItem): FeatureInfo? =
when (ci.content) {
is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name)
is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name)
is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name)
is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name)
else -> null
}
@Composable
private fun FeatureIconView(f: FeatureInfo) {
val icon = @Composable { Icon(f.icon.icon, null, Modifier.size(20.dp), tint = f.color) }
if (f.param != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
icon()
Text(chatEventText(f.param, ""), maxLines = 1)
}
} else {
icon()
}
}
@Composable
private fun FullFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color,
@@ -24,7 +124,7 @@ fun CIChatFeatureView(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor)
Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(20.dp), tint = iconColor)
Text(
chatEventText(chatItem),
Modifier,

View File

@@ -1,11 +1,9 @@
package chat.simplex.common.views.chat.item
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.dp
@@ -14,12 +12,7 @@ import chat.simplex.common.ui.theme.*
@Composable
fun CIEventView(text: AnnotatedString) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
Text(text, Modifier.padding(horizontal = 6.dp, vertical = 6.dp), style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,

View File

@@ -21,9 +21,8 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.chat.ComposeContextItem
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -47,7 +46,10 @@ fun ChatItemView(
imageProvider: (() -> ImageGalleryProvider)? = null,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
revealed: MutableState<Boolean>,
range: IntRange?,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
@@ -63,14 +65,12 @@ fun ChatItemView(
findModelMember: (String) -> GroupMember?,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
getConnectedMemberNames: (() -> List<String>)? = null,
developerTools: Boolean,
) {
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
val revealed = remember { mutableStateOf(false) }
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
val onLinkLongClick = { _: String -> showMenu.value = true }
val live = composeState.value.liveMessage != null
@@ -178,61 +178,75 @@ fun ChatItemView(
fun MsgContentItemDropdownMenu() {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file)
DefaultDropdownMenu(showMenu) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
}
if (cItem.meta.itemDeleted == null && !live) {
ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
val fileSource = getLoadedFileSource(cItem.file)
when {
fileSource != null -> shareFile(cItem.text, fileSource)
else -> clipboard.shareText(cItem.content.text)
if (cItem.content.msgContent != null) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
}
showMenu.value = false
})
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = {
copyItemToClipboard(cItem, clipboard)
showMenu.value = false
})
if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
if (cItem.meta.itemDeleted == null && !live) {
ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
val fileSource = getLoadedFileSource(cItem.file)
when {
fileSource != null -> shareFile(cItem.text, fileSource)
else -> clipboard.shareText(cItem.content.text)
}
showMenu.value = false
})
}
if (cItem.meta.itemDeleted != null && revealed.value) {
ItemAction(
stringResource(MR.strings.hide_verb),
painterResource(MR.images.ic_visibility_off),
onClick = {
revealed.value = false
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = {
copyItemToClipboard(cItem, clipboard)
showMenu.value = false
})
if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
}
)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive)) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
})
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
if (revealed.value) {
HideItemAction(revealed, showMenu)
}
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive)) {
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
}
} else if (cItem.meta.itemDeleted != null) {
if (revealed.value) {
HideItemAction(revealed, showMenu)
} else if (!cItem.isDeletedContent) {
RevealItemAction(revealed, showMenu)
} else if (range != null) {
ExpandItemAction(revealed, showMenu)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
} else if (cItem.isDeletedContent) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
} else if (cItem.mergeCategory != null) {
if (revealed.value) {
ShrinkItemAction(revealed, showMenu)
} else {
ExpandItemAction(revealed, showMenu)
}
}
}
}
@@ -241,25 +255,18 @@ fun ChatItemView(
fun MarkedDeletedItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (!cItem.isDeletedContent) {
ItemAction(
stringResource(MR.strings.reveal_verb),
painterResource(MR.images.ic_visibility),
onClick = {
revealed.value = true
showMenu.value = false
}
)
RevealItemAction(revealed, showMenu)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
}
}
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL)
if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed)
MarkedDeletedItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
@@ -281,7 +288,7 @@ fun ChatItemView(
DeletedItemView(cItem, cInfo.timedMessagesTTL)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
}
}
@@ -289,9 +296,32 @@ fun ChatItemView(
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
fun mergedGroupEventText(chatItem: ChatItem): String? {
val (count, ns) = chatModel.getConnectedMemberNames(chatItem)
val members = when {
ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0])
ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1])
ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2])
ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2)
else -> ""
}
return if (count <= 1) {
null
} else if (ns.isEmpty()) {
generalGetString(MR.strings.rcv_group_events_count).format(count)
} else if (count > ns.size) {
members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size)
} else {
members
}
}
fun eventItemViewText(): AnnotatedString {
val memberDisplayName = cItem.memberDisplayName
return if (memberDisplayName != null) {
val t = mergedGroupEventText(cItem)
return if (!revealed.value && t != null) {
chatEventText(t, cItem.timestampText)
} else if (memberDisplayName != null) {
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
@@ -305,35 +335,12 @@ fun ChatItemView(
CIEventView(eventItemViewText())
}
fun membersConnectedText(): String? {
return if (getConnectedMemberNames != null) {
val ns = getConnectedMemberNames()
when {
ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2)
ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2])
ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1])
else -> null
}
} else {
null
}
}
fun membersConnectedItemText(): AnnotatedString {
val t = membersConnectedText()
return if (t != null) {
chatEventText(t, cItem.timestampText)
} else {
eventItemViewText()
}
}
@Composable
fun ModeratedItem() {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage)
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
}
}
@@ -352,26 +359,61 @@ fun ChatItemView(
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvDirectEventContent -> EventItemView()
is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) {
is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText())
is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat)
else -> EventItemView()
is CIContent.RcvDirectEventContent -> {
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.RcvGroupEventContent -> {
when (c.rcvGroupEvent) {
is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat)
else -> EventItemView()
}
MsgContentItemDropdownMenu()
}
is CIContent.SndGroupEventContent -> {
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.RcvConnEventContent -> {
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.SndConnEventContent -> {
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.RcvChatFeature -> {
CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndChatFeature -> {
CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndGroupEventContent -> EventItemView()
is CIContent.RcvConnEventContent -> EventItemView()
is CIContent.SndConnEventContent -> EventItemView()
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
is CIContent.SndChatPreference -> {
CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvGroupFeature -> {
CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndGroupFeature -> {
CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvChatFeatureRejected -> {
CIChatFeatureView(cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvGroupFeatureRejected -> {
CIChatFeatureView(cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndModerated -> ModeratedItem()
is CIContent.RcvModerated -> ModeratedItem()
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
@@ -430,16 +472,38 @@ fun ItemInfoAction(
@Composable
fun DeleteItemAction(
cItem: ChatItem,
revealed: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
) {
ItemAction(
stringResource(MR.strings.delete_verb),
painterResource(MR.images.ic_delete),
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
if (!revealed.value && cItem.meta.itemDeleted != null) {
val currIndex = chatModel.getChatItemIndexOrNull(cItem)
val ciCategory = cItem.mergeCategory
if (currIndex != null && ciCategory != null) {
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
val range = chatViewItemsRange(currIndex, prevHidden)
if (range != null) {
val itemIds: ArrayList<Long> = arrayListOf()
for (i in range) {
itemIds.add(chatModel.chatItems.asReversed()[i].id)
}
deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages)
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
}
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
}
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
}
},
color = Color.Red
)
@@ -463,6 +527,54 @@ fun ModerateItemAction(
)
}
@Composable
private fun RevealItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.reveal_verb),
painterResource(MR.images.ic_visibility),
onClick = {
revealed.value = true
showMenu.value = false
}
)
}
@Composable
private fun HideItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.hide_verb),
painterResource(MR.images.ic_visibility_off),
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
@Composable
private fun ExpandItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.expand_verb),
painterResource(MR.images.ic_expand_all),
onClick = {
revealed.value = true
showMenu.value = false
},
)
}
@Composable
private fun ShrinkItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.hide_verb),
painterResource(MR.images.ic_collapse_all),
onClick = {
revealed.value = false
showMenu.value = false
},
)
}
@Composable
fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) {
val finalColor = if (color == Color.Unspecified) {
@@ -542,6 +654,26 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
)
}
fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, deleteMessages: (List<Long>) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size),
text = questionText,
buttons = {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = {
deleteMessages(itemIds)
AlertManager.shared.hideAlert()
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
}
}
)
}
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.delete_member_message__question),
@@ -575,7 +707,10 @@ fun PreviewChatItemView() {
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
revealed = remember { mutableStateOf(false) },
range = 0..1,
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = { _, _ -> },
@@ -606,7 +741,10 @@ fun PreviewChatItemViewDeletedContent() {
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
revealed = remember { mutableStateOf(false) },
range = 0..1,
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = { _, _ -> },

View File

@@ -20,10 +20,9 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.base64ToBitmap
import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE
import chat.simplex.res.MR
import kotlin.math.min
@@ -202,10 +201,16 @@ fun FramedItemView(
Column(Modifier.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.meta.itemDeleted != null) {
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
} else {
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
when (ci.meta.itemDeleted) {
is CIDeleted.Moderated -> {
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
}
is CIDeleted.Blocked -> {
FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
}
else -> {
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
}
}
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(MR.strings.live), false)

View File

@@ -32,7 +32,12 @@ interface ImageGalleryProvider {
@Composable
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
val provider = remember { imageProvider() }
val pagerState = rememberPagerState(provider.initialIndex)
val pagerState = rememberPagerState(
initialPage = provider.initialIndex,
initialPageOffsetFraction = 0f
) {
provider.totalMediaSize.value
}
val goBack = { provider.onDismiss(pagerState.currentPage); close() }
BackHandler(onBack = goBack)
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
@@ -138,7 +143,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
}
}
if (appPlatform.isAndroid) {
HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) }
HorizontalPager(state = pagerState) { index -> Content(index) }
} else {
Content(pagerState.currentPage)
}

View File

@@ -2,25 +2,25 @@ package chat.simplex.common.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.runtime.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.CIDeleted
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Surface(
@@ -32,11 +32,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f, false)) {
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
MarkedDeletedText(String.format(generalGetString(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
} else {
MarkedDeletedText(generalGetString(MR.strings.marked_deleted_description))
}
MergedMarkedDeletedText(ci, revealed)
}
CIMetaView(ci, timedMessagesTTL)
}
@@ -44,7 +40,41 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
}
@Composable
private fun MarkedDeletedText(text: String) {
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState<Boolean>) {
var i = getChatItemIndexOrNull(chatItem)
val ciCategory = chatItem.mergeCategory
val text = if (!revealed.value && ciCategory != null && i != null) {
val reversedChatItems = ChatModel.chatItems.asReversed()
var moderated = 0
var blocked = 0
var deleted = 0
val moderatedBy: MutableSet<String> = mutableSetOf()
while (i < reversedChatItems.size) {
val ci = reversedChatItems.getOrNull(i)
if (ci?.mergeCategory != ciCategory) break
when (val itemDeleted = ci.meta.itemDeleted ?: break) {
is CIDeleted.Moderated -> {
moderated += 1
moderatedBy.add(itemDeleted.byGroupMember.displayName)
}
is CIDeleted.Blocked -> blocked += 1
is CIDeleted.Deleted -> deleted += 1
}
i++
}
val total = moderated + blocked + deleted
if (total <= 1)
markedDeletedText(chatItem.meta)
else if (total == moderated)
stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", "))
else if (total == blocked)
stringResource(MR.strings.blocked_items_description).format(total)
else
stringResource(MR.strings.marked_deleted_items_description).format(total)
} else {
markedDeletedText(chatItem.meta)
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) }
@@ -56,6 +86,16 @@ private fun MarkedDeletedText(text: String) {
)
}
private fun markedDeletedText(meta: CIMeta): String =
when (meta.itemDeleted) {
is CIDeleted.Moderated ->
String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName)
is CIDeleted.Blocked ->
generalGetString(MR.strings.blocked_item_description)
else ->
generalGetString(MR.strings.marked_deleted_description)
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"

View File

@@ -11,26 +11,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
expect fun Modifier.onRightClick(action: () -> Unit): Modifier
expect interface DefaultExposedDropdownMenuBoxScope {
@Composable
open fun DefaultExposedDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
)
}
@Composable
expect fun DefaultExposedDropdownMenuBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit
)
@Composable
fun DefaultDropdownMenu(
showMenu: MutableState<Boolean>,
@@ -55,7 +35,7 @@ fun DefaultDropdownMenu(
}
@Composable
fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
expanded: MutableState<Boolean>,
modifier: Modifier = Modifier,
dropdownMenuItems: (@Composable () -> Unit)?
@@ -63,7 +43,7 @@ fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
MaterialTheme(
shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp)))
) {
DefaultExposedDropdownMenu(
ExposedDropdownMenu(
modifier = Modifier
.widthIn(min = 200.dp)
.background(MaterialTheme.colors.surface)

View File

@@ -29,7 +29,7 @@ fun <T> ExposedDropDownSettingRow(
) {
SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) {
val expanded = remember { mutableStateOf(false) }
DefaultExposedDropdownMenuBox(
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = {
expanded.value = !expanded.value && enabled.value

View File

@@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.platform.windowWidth
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -98,6 +99,34 @@ fun SectionItemView(
}
}
@Composable
fun SectionItemViewLongClickable(
click: () -> Unit,
longClick: () -> Unit,
minHeight: Dp = 46.dp,
disabled: Boolean = false,
extraPadding: Boolean = false,
padding: PaddingValues = if (extraPadding)
PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING)
else
PaddingValues(horizontal = DEFAULT_PADDING),
content: (@Composable RowScope.() -> Unit)
) {
val modifier = Modifier
.fillMaxWidth()
.sizeIn(minHeight = minHeight)
Row(
if (disabled) {
modifier.padding(padding)
} else {
modifier.combinedClickable(onClick = click, onLongClick = longClick).onRightClick(longClick).padding(padding)
},
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
@Composable
fun SectionItemViewWithIcon(
click: (() -> Unit)? = null,

View File

@@ -254,7 +254,7 @@ fun IntSettingRow(title: String, selection: MutableState<Int>, values: List<Int>
Text(title)
DefaultExposedDropdownMenuBox(
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = {
expanded.value = !expanded.value
@@ -313,7 +313,7 @@ fun TimeoutSettingRow(title: String, selection: MutableState<Long>, values: List
Text(title)
DefaultExposedDropdownMenuBox(
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = {
expanded.value = !expanded.value

View File

@@ -24,6 +24,7 @@ fun VersionInfoView(info: CoreVersionInfo) {
Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE))
} else {
Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.DESKTOP_VERSION_NAME))
Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.DESKTOP_VERSION_CODE))
}
Text(String.format(stringResource(MR.strings.core_version), info.version))
val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit

View File

@@ -30,7 +30,11 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">deleted</string>
<string name="marked_deleted_description">marked deleted</string>
<string name="marked_deleted_items_description">%d messages marked deleted</string>
<string name="moderated_item_description">moderated by %s</string>
<string name="moderated_items_description">%d messages moderated by %s</string>
<string name="blocked_item_description">blocked</string>
<string name="blocked_items_description">%d messages blocked</string>
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
<string name="sender_you_pronoun">you</string>
@@ -243,7 +247,9 @@
<string name="hide_verb">Hide</string>
<string name="allow_verb">Allow</string>
<string name="moderate_verb">Moderate</string>
<string name="expand_verb">Expand</string>
<string name="delete_message__question">Delete message?</string>
<string name="delete_messages__question">Delete %d messages?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
<string name="delete_member_message__question">Delete member message?</string>
@@ -1132,9 +1138,14 @@
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
<string name="rcv_group_event_1_member_connected">%s connected</string>
<string name="rcv_group_event_2_members_connected">%s and %s connected</string>
<string name="rcv_group_event_3_members_connected">%s, %s and %s connected</string>
<string name="rcv_group_event_n_members_connected">%s, %s and %d other members connected</string>
<string name="rcv_group_events_count">%d group events</string>
<string name="rcv_group_and_other_events">and %d other events</string>
<string name="group_members_2">%s and %s</string>
<string name="group_members_n">%s, %s and %d members</string>
<string name="rcv_group_event_open_chat">Open</string>
@@ -1250,10 +1261,21 @@
<string name="recipient_colon_delivery_status">%s: %s</string>
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member_question">Remove member?</string>
<string name="button_remove_member">Remove member</string>
<string name="button_send_direct_message">Send direct message</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="remove_member_confirmation">Remove</string>
<string name="remove_member_button">Remove member</string>
<string name="block_member_question">Block member?</string>
<string name="block_member_button">Block member</string>
<string name="block_member_confirmation">Block</string>
<string name="block_member_desc">All new messages from %s will be hidden!</string>
<string name="unblock_member_question">Unblock member?</string>
<string name="unblock_member_button">Unblock member</string>
<string name="unblock_member_confirmation">Unblock</string>
<string name="unblock_member_desc">Messages from %s will be shown!</string>
<string name="member_info_section_title_member">MEMBER</string>
<string name="role_in_group">Role</string>
<string name="change_role">Change role</string>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M504.5-45q-92 0-169.75-46T210-216.5l-147.5-247 19-19.5q15-15 36-16.5T156-489l129 93v-410.5q0-10.925 8.154-19.713 8.153-8.787 20.75-8.787 11.096 0 19.846 8.787 8.75 8.788 8.75 19.713v522l-185-134L258-250q37.5 68.5 103.318 108 65.817 39.5 143.182 39.5 112.792 0 192.896-78.104Q777.5-258.708 777.5-371v-395.688q0-10.812 8.154-19.562 8.153-8.75 20.75-8.75 11.096 0 19.846 8.787Q835-777.425 835-766.5V-371q0 136-96.832 231Q641.335-45 504.5-45Zm-55-446.5v-395q0-10.925 8.654-19.713 8.653-8.787 20.25-8.787 12.096 0 20.346 8.787Q507-897.425 507-886.5v395h-57.5Zm164.5 0v-355q0-10.925 8.154-19.713 8.153-8.787 20.75-8.787 11.096 0 19.846 8.787 8.75 8.788 8.75 19.713v355H614ZM468-297Z"/></svg>

After

Width:  |  Height:  |  Size: 782 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m291.5-89.5-40.5-40 229-229 229 229-40.5 40L480-278 291.5-89.5Zm188.5-513-229-229 40.5-40.5L480-683.5 668.5-872l40.5 40.5-229 229Z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M835-207.5 777.5-265v-501.5q0-11.675 8.425-20.088 8.426-8.412 20.5-8.412 12.075 0 20.325 8.412Q835-778.175 835-766.5v559ZM342.5-699 285-756.5v-50q0-11.675 8.425-20.088 8.426-8.412 20.5-8.412 12.075 0 20.325 8.412 8.25 8.413 8.25 20.088V-699ZM507-535.5 449.5-592v-294.5q0-11.675 8.425-20.088 8.426-8.412 20.5-8.412 12.075 0 20.325 8.412Q507-898.175 507-886.5v351ZM671.5-483H614v-363.668q0-11.582 8.425-19.957 8.426-8.375 20.5-8.375 12.075 0 20.325 8.351t8.25 19.935V-483ZM751-126.5 342.5-535v250.5L165-415l199 291q7 11 17.839 16.75 10.84 5.75 24.161 5.75h280.5q17.897 0 34.948-6.25Q738.5-114 751-126.5ZM406-44q-27.049 0-51.274-12.5Q330.5-69 316-91.5L59.5-468l19-15.5q17-14.5 39-18.25t43.573 12.938L285-394.5v-198l-253-253L73.5-887 873-87l-41 41-41-40.5q-20 19.5-47.17 31T686.5-44H406Zm140.5-287.5ZM560-483Z"/></svg>

After

Width:  |  Height:  |  Size: 911 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m480-84.5-237-237 42.5-42L480-169l195-194.5 42 42-237 237Zm-195-512-42-42 237-237 237 237-42 42L480-791 285-596.5Z"/></svg>

After

Width:  |  Height:  |  Size: 220 B

View File

@@ -1,5 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.foundation.contextMenuOpenDetector
import androidx.compose.runtime.Composable
import androidx.compose.ui.*
import androidx.compose.ui.graphics.painter.Painter
@@ -29,3 +30,5 @@ onExternalDrag(enabled) {
is DragData.Text -> onText(data.readText())
}
}
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() }

View File

@@ -6,9 +6,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import chat.simplex.common.platform.VideoPlayer
import chat.simplex.common.platform.isPlaying
import chat.simplex.common.views.helpers.onRightClick
import chat.simplex.common.platform.*
@Composable
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {

View File

@@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.helpers.*
object NoIndication : Indication {

View File

@@ -1,44 +0,0 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.DropdownMenu
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() }
actual interface DefaultExposedDropdownMenuBoxScope {
@Composable
actual fun DefaultExposedDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier,
content: @Composable ColumnScope.() -> Unit
) {
DropdownMenu(expanded, onDismissRequest, offset = DpOffset(0.dp, (-40).dp)) {
Column {
content()
}
}
}
}
@Composable
actual fun DefaultExposedDropdownMenuBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier,
content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit
) {
val obj = remember { object : DefaultExposedDropdownMenuBoxScope {} }
Box(Modifier
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { onExpandedChange(!expanded) })
) {
obj.content()
}
}

View File

@@ -88,7 +88,7 @@ compose {
notarization {
this.appleID.set(appleId)
this.password.set(password)
this.ascProvider.set(teamId)
this.teamID.set(teamId)
}
}
}

View File

@@ -33,4 +33,4 @@ desktop.version_code=15
kotlin.version=1.8.20
gradle.plugin.version=7.4.2
compose.version=1.4.3
compose.version=1.5.10

View File

@@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 0410948b56ea630dfa86441bbcf8ec97aeb1df01
tag: 7ebb63025cc70d0649830b31846deba2348c3c38
source-repository-package
type: git
@@ -19,7 +19,7 @@ source-repository-package
source-repository-package
type: git
location: https://github.com/kazu-yamamoto/http2.git
tag: 804fa283f067bd3fd89b8c5f8d25b3047813a517
tag: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb
source-repository-package
type: git

View File

@@ -0,0 +1,124 @@
# Groups integrity
## Problems
- Inconsistency of group state:
- group profile including group wide preferences,
- list of members and their roles.
- Lack of group messages integrity - group member can send different messages to different members.
Lack of group consistency leads to group federation both in terms of members list and content visible to different members, which leads to user frustration and lack of trust.
Improvements to group design should provide:
- Consistent group state.
- Group messages integrity:
- integrity violations (different message sent to different members) should be identified and shown to users,
- missed messages should be requested to fill in gaps.
## Design ideas and questions
### Group messages integrity
A message container to include member's message ID (ordered?), and list of IDs and hashes of parent messages.
```haskell
data MsgParentId = MsgParentId
{ memberId :: MemberId,
msgId :: Int64, -- sequential message ID for parent message (among memberId member messages)
msgHash :: ByteString
}
data MsgIds = MsgIds
{ msgId :: Int64, -- sequential message ID for member's message
parentIds :: [MsgParentId]
}
```
Questions:
- What level of protocol should include MsgIds, and what messages should be included into integrity graph?
- Having it on AppMessage level would allow to include all protocol messages. But some protocol messages are sent with different content per member (XGrpMemIntro, XGrpMemFwd, probe messages) and would have different hash. Also they contain sensitive data such as invitation links and should not be forwarded anyway.
- If MsgIds is MsgContainer level, only XMsgNew would have it. This excludes other content messages such as updates, deletes, etc.
- Include it into specific "content" chat events - XMsgNew, XMsgFileCancel (unused), XMsgUpdate, XMsgDel, XMsgReact, XFile (not used anymore but was never fully deprecated), XFileCancel.
- Some new protocol level container, uniting above events?
- Should msgId be sequential integer? (It leaks metadata about member's previous activity in the group) Can SharedMsgId be used instead?
- Depending on number of parent messages, parentIds can become arbitrarily long and not fit into 16KB block, especially for messages containing profiles pictures.
When receiving a message with unknown parent identifiers, client should request missing messages from the sender by sending XGrpRequestSkipped, including last seen message reference for each missing parent. When receiving XGrpRequestSkipped, member should forward requested messages up to last seen parent using XGrpRequested.
```haskell
-- include received parentId?
XGrpRequestSkipped :: [MsgParentId] -> ChatMsgEvent 'Json
data MsgRequestedParent = MsgRequested
{ parentId :: MsgParentId,
msg :: MsgContainer -- content TBD based on scope of messages included into integrity graph. Full event?
}
XGrpRequested :: MsgRequestedParent -> ChatMsgEvent 'Json
```
Questions:
- Depending on number of missing parents, XGrpRequestSkipped may not fit into 16KB block.
- There may be multiple skipped messages for a given member, should they be sent sequentially from oldest (following the one known to requesting member) to newest?
- XGrpRequested may not fit into 16KB block even if original MsgContainer / chat event did fit. On the other hand multiple XGrpRequested messages can be batched.
- Malicious group member may arbitrarily request (at any time or in response to a new message) any number of skipped messages by sending parentIds from the past and trigger receiving member to send a lot of traffic. There are already some automatic response events in protocol, but they are harder to abuse: XGrpMemFwd - requires cooperation with other member, or creating connection; receipts - can be turned off; probes - requires member having matching contact and being non incognito in group. Should the member receiving XGrpRequestSkipped protect from such abuse by limiting number of requested messages? Limiting number or requests from a specific member in time?
- By the time member requests skipped messages, sender may be offline. Should the requester send XGrpRequestSkipped to other members?
- together with the request to sender or after some period?
- to which members? - fraction of admins? all admins?
- Member receiving XGrpRequestSkipped may not have requested messages, for example:
- request is for the older parent id, and member never received it himself (was not part of the group then or has gap in place of this message), or has gap between sent message parent and requested parent.
- member deleted parent(s), e.g. via periodic cleanup, or by deleting specific messages.
- don't fully delete group message records while in group? instead only overwrite content?
Message integrity is computed for received messages, can be updated on receiving requested message parents.
```haskell
data GroupMsgIntegrity
= GMIOk
| GMISkippedParents {skippedParents :: [MsgParentId]}
| GMIBadParentHash {knownParent :: MsgParentId, badParent :: MsgParentId} -- list?
```
```sql
CREATE TABLE message_integrity_records( -- message_hashes? group_messages?
message_integrity_record_id INTEGER PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, -- SET NULL?
group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE,
group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
member_id BLOB NOT NULL,
member_msg_id INTEGER NOT NULL, -- shared_msg_id?
msg_hash BLOB NOT NULL,
msg_integrity TEXT NOT NULL, -- computed for received messages, for sent always Ok?
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
-- many to many table for message_integrity_records table
-- (parent can have multiple children, child can have multiple parents)
-- parent can be null if it wasn't received
CREATE TABLE message_parents(
message_parent_id INTEGER PRIMARY KEY,
message_integrity_record_id INTEGER NOT NULL REFERENCES message_integrity_record_id ON DELETE CASCADE,
message_parent_integrity_record_id INTEGER REFERENCES message_integrity_record_id ON DELETE CASCADE,
msg_parent_member_id BLOB NOT NULL,
msg_parent_member_msg_id INTEGER NOT NULL,
msg_hash BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
```
How should message integrity errors be displayed in UI?
- Displaying skipped parent errors would clutter UI due to delays in delivery. Probably they shouldn't be displayed.
- Integrity violations (hashes not matching) should be displayed on respective chat items.
- if integrity is on AppMessage level for all chat events - not all messages have corresponding chat items, create internal chat items?
- if it's on the level of content messages, updates / etc. can be high above in message history, deletes can be not visible at all (full delete).
- how to get reference to message via chat item when loading chat items? Integrity violation can be on a message different than chat item's created_by_msg_id message. For each chat item load integrity of all messages via chat_item_messages?
- If integrity errors are only displayed on integrity violations, for malicious member to work around it and send different message to different group members could he specify unknown (far into future or past) message id, instead of incorrect one? Sender then wouldn't respond with skipped parents (and other members wouldn't be able to) - how to differentiate between this case and skipper parent error that is to be ignored in UI?
- Should it be prohibited to not send MsgIds (to avoid message integrity check) if member protocol version supports it? Should it be prohibited at all and group with integrity be separated? How to distinguish between messages sent without integrity fields and messages with skipped parents in UI?
- Not showing skipped parents integrity error in UI would lead user to believe integrity is preserved, and integrity violation can be revealed later. If conversation is time sensitive member may react to message considering it conversation integrity wasn't breached, and integrity violation may be revealed later. Having eventual integrity may not be better than having no integrity at all, and may even be worse because it produces false assumptions regarding conversation integrity. The goal can be narrowed to only restoring missed messages (gaps), without calculating integrity.
### Consistent group state
TODO

View File

@@ -0,0 +1,229 @@
# Group integrity
3 level of DAGs:
Owner
- group profile and permissions, admin invites and removals
- in case of gap vote before applying event
Admin
- member invites and removals
- prohibit to add and remove admins
- in case of gap most destructive wins
- link to owner dag
Messages
- in case of gap show history according to local graph, correct when owner or admin dag changes
- link to both admin and owner dags
```haskell
-- protocol
data MsgParent = MsgParent
{ memberId :: MemberId,
memberName :: String, -- recipient can use to display message if they don't have member introduced;
-- optional?
sharedMsgId :: SharedMsgId,
msgHash :: ByteString,
msgBody :: String? -- recipient can use to display message in case parent wasn't yet received;
-- sender can pack as many parents as fits into block
stored :: Bool -- whether sender has message stored, and it can be requested
}
data MsgIds = MsgIds -- include into chat event
{ sharedMsgId :: SharedMsgId,
ownerDAGMsgId :: SharedMsgId, -- list of parents?
adminDAGMsgId :: SharedMsgId,
parents :: [MsgParent]
}
-- model
data OwnerDAGEventParent
= ODEPKnown {eventId :: ?} -- DB id? sharedMsgId?
| ODEPUnknown {eventId :: ?}
data OwnerDAGEvent = DAGEvent
{ eventId :: ?,
parents :: [OwnerDAGEventParent]
}
data AdminDAGEventParent
= ADEPKnown {eventId :: ?}
| ADEPUnknown {eventId :: ?}
data AdminDAGEvent = DAGEvent
{ eventId :: ?,
ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ?
parents :: [AdminDAGEventParent]
}
data MessagesDAGEventParent
= MDEPKnown {eventId :: ?}
| MDEPUnknown {eventId :: ?}
data MessagesDAGEvent = DAGEvent
{ eventId :: ?,
ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ?
adminDAGEventId :: ?, -- [AdminDAGEventParent] - parentIds? ?
parents :: [MessagesDAGEventParent]
}
```
How to restore from destructive messages?
Even if all message parents are known, destructive logic of message should be applied after other members refer it.
How to workaround members maliciously referring non-existent parents?
For example, this can lead to an owner preventing group updates.
```
-- should dag be maintained in memory? older events to be removed
-- read on event?
-- how long into past to get dag?
ClassifiedEvent = OwnerEvent | AdminEvent | MsgEvent
def processEvent(e: Event) =
classifiedEvent <- classifyEvent(e)
case classifiedEvent of
OwnerEvent oe -> processOwnerEvent(oe)
AdminEvent ae -> processAdminEvent(ae)
MsgEvent me -> processMsgEvent(me)
def classifyEvent(e: Event) -> ClassifiedEvent? =
case e of
XMsgNew -> MsgEvent
XMsgFileDescr -> Nothing -- different per member
XMsgFileCancel -> MsgEvent
XMsgUpdate -> MsgEvent
XMsgDel -> MsgEvent
XMsgReact -> MsgEvent
XFile -> MsgEvent
XFileCancel -> MsgEvent
XFileAcptInv -> Nothing -- different per member
XGrpMemNew -> OwnerEvent -- sent by owner, new member is admin or owner
or AdminEvent -- sent by admin (or by owner and new member role is less than admin?)
-- problem: if member role changes, members can add event to different dags
-- what should define member role?
XGrpMemIntro -> Nothing -- received only by invitee
XGrpMemInv -> Nothing -- received only by host
XGrpMemFwd -> Nothing -- different per member; not received by invitee
XGrpMemRole -> OwnerEvent -- sent by owner about owner or admin
or AdminEvent -- sent by admin (or by owner about member with role less than admin?)
XGrpMemDel -> OwnerEvent -- sent by owner about owner or admin
or AdminEvent -- sent by admin (or by owner about member with role less than admin?)
XGrpLeave -> MsgEvent
XGrpDel -> OwnerEvent
XGrpInfo -> OwnerEvent
XGrpDirectInv -> Nothing -- received by single member
XInfoProbe -> Nothing -- per member
XInfoProbeCheck -> Nothing -- per member
XInfoProbeOk -> Nothing -- per member
BFileChunk -> Nothing -- could be MsgEvent?
_ -> Nothing -- not supported in groups
-- # owner events
def processOwnerEvent(oe: OwnerEvent) =
process every owner event after owners reach consensus
// def processOwnerEvent(oe: OwnerEvent) =
// addOwnerDagEvent(oe)
// applyOwnerDagEvent(oe)
//
// def addOwnerDagEvent(oe: OwnerEvent) =
// if (any parent of oe not in dag):
// buffer until all parents are in ownerDag
// else
// add oe to ownerDag
//
// def applyOwnerDagEvent(oe: OwnerEvent) =
// case oe of
// -- process XGrpMemNew, XGrpMemRole, XGrpMemDel same as for admin dag (see below), or should vote for all events?
// XGrpMemNew -> ...
// XGrpMemRole -> ...
// XGrpMemDel -> ...
// -- how to vote - to depend on action (group - manual, update - automatic?);
// -- wait for voting always, or if event has unknown parents? (gaps in dag)
// -- how to treat delayed integrity violation - owner sending message to select members
// XGrpDel ->
// -- create "pending group deletion", wait for confirmation from majority of owners?
// -- new protocol requiring user action from other owners?
// XGrpInfo ->
// -- create "unconfirmed group profile update", remember prev group profile
// -- remove from "unconfirmed group profile update" when this event is in dag and not a leaf?
// -- if another group profile update event is received, revert "unconfirmed" event, don't apply new
// -- so if more than one update is received while dag is not merged to single vertice, all updates are not applied
// -- - this would likely lock out owners from any future updates
// -- - merge to new starting point after some time passes?
// -- - mark parents that are never received and so always block graph merging as special type?
-- # admin events
def processAdminEvent(ae: AdminEvent) =
lookup in owner dag - does member still have permission?
addAdminDagEvent(ae)
applyAdminDagEvent(ae)
def addAdminDagEvent(ae: AdminEvent) =
if (any parent of ae not in dag):
buffer until all parents are in adminDag
else
add ae to adminDag
def applyAdminDagEvent(ae: AdminEvent) =
case ae of
XGrpMemNew ->
-- handles case where messages from 2 admins about member addition and deletion arrive out of order
if member is not in "unconfirmed member deletions":
add member
XGrpMemRole ->
add role change to "unconfirmed role change"
-- remove from "unconfirmed role change" when this event is in dag and not a leaf?
if another role change already in "unconfirmed role change":
if new role is less than role in "unconfirmed role change":
change role -- role change applies in direction of lower role
XGrpMemDel ->
add member to "unconfirmed member deletions"
-- remove from "unconfirmed member deletions" when this event is in dag and not a leaf?
if member found by memberId:
delete member
-- ^ problem: if later admin event turns out to fail integrity check, how to revert it?
-- member deletion: don't apply until in graph and not a leaf
-- role change: remember previous role and revert
-- member addition: delete member
-- # message events
def processMsgEvent(me: MsgEvent) =
lookup points in owner and admin dag?
- does member have permission to send event? (role changed/removed)
addMsgDagEvent(me)
applyMsgEvent(me)
def addMsgDagEvent(me: MsgEvent) =
for me.parents not in msgDag:
add MDEPUnknown parent to msgDag
add me to msgDag
def applyMsgEvent(me: MsgEvent) =
case me of
XMsgNew -> message to view
-- start process waiting for missing parents; if parents are not received:
-- can be shown as integrity violation if parents are not received
-- can be shown as integrity violation if other members don't refer it?
XMsgFileCancel -> cancel file immediately
-- wait for missing parents / referrals similarly to XMsgNew
-- restart file reception on integrity violation?
XMsgUpdate -> update to view -- same as XMsgNew
XMsgDel -> mark deleted, don't apply full delete until parents/referrals are received?
XMsgReact -> to view -- same as XMsgNew
XFile -> -- deprecate?
XFileCancel -> cancel -- same as XMsgFileCancel
XGrpLeave -> mark member as left, don't delete member connection immediately
-- member may try to maliciously remove connections selectively
-- wait for integrity check
```
# Admin blockchain
Suppose admin DAG is replaced with blockchain, with a conflict resolution protocol to provide consistency of membership changes. Take Simplex (not to confuse with SimpleX chat) protocol (https://simplex.blog/). To reach BFT consensus and make progress, 2n/3 votes on block proposals are required, and it's assumed `f < n/3` where f is number of malicious actors. In a highly asynchronous setting of decentralized groups operated by mobile devices, progress seems unlikely or very slow. Should "admin participation" be hosted?

View File

@@ -112,6 +112,11 @@ if [ -n "$LIBCRYPTO_PATH" ]; then
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsmplxmq*.$LIB_EXT
fi
LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
if [ -n "$LIBCRYPTO_PATH" ]; then
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsqlcphr-*.$LIB_EXT
fi
for lib in $(find . -type f -name "*.$LIB_EXT"); do
RPATHS=`otool -l $lib | grep -E "path /Users/|path /usr/local|path /opt/" | cut -d' ' -f11`
for RPATH in $RPATHS; do

View File

@@ -1,7 +1,7 @@
{
"https://github.com/simplex-chat/simplexmq.git"."0410948b56ea630dfa86441bbcf8ec97aeb1df01" = "1y4a28dkccbv8cbh164iirsnxa62qwac0pd5c8lqr5kddqvkz970";
"https://github.com/simplex-chat/simplexmq.git"."7ebb63025cc70d0649830b31846deba2348c3c38" = "151lpqvbc04ql6xxyjrp0l06hp2l4pf0hyhqp654gz0xbfp5s40j";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895";
"https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
"https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr";

View File

@@ -36,7 +36,6 @@ library
Simplex.Chat.Markdown
Simplex.Chat.Messages
Simplex.Chat.Messages.CIContent
Simplex.Chat.Messages.Events
Simplex.Chat.Migrations.M20220101_initial
Simplex.Chat.Migrations.M20220122_v1_1
Simplex.Chat.Migrations.M20220205_chat_item_status
@@ -120,7 +119,6 @@ library
Simplex.Chat.Migrations.M20231010_member_settings
Simplex.Chat.Migrations.M20231019_indexes
Simplex.Chat.Migrations.M20231030_xgrplinkmem_received
Simplex.Chat.Migrations.M20231101_group_events
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared

View File

@@ -82,7 +82,7 @@ import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentCl
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig)
import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection)
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection, checkpointSQLiteStore, getSQLiteJournalMode, setSQLiteJournalMode)
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations
@@ -356,7 +356,7 @@ restoreCalls = do
atomically $ writeTVar calls callsMap
stopChatController :: forall m. MonadUnliftIO m => ChatController -> m ()
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do
stopChatController ChatController {chatStore, smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do
disconnectAgentClient smpAgent
readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2)
closeFiles sndFiles
@@ -365,6 +365,9 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles,
keys <- M.keys <$> readTVar expireCIFlags
forM_ keys $ \k -> TM.insert k False expireCIFlags
writeTVar s Nothing
let agentStore = agentClientStore smpAgent
liftIO $ checkpointSQLiteStore chatStore
liftIO $ checkpointSQLiteStore agentStore
where
closeFiles :: TVar (Map Int64 Handle) -> m ()
closeFiles files = do
@@ -549,6 +552,16 @@ processChatCommand = \case
. sortOn (timeAvg . snd)
. M.assocs
<$> withConnection st (readTVarIO . DB.slow)
StoreSQLMode mode_ -> checkChatStopped $ do
ChatController {chatStore, smpAgent} <- ask
let agentStore = agentClientStore smpAgent
forM_ mode_ $ \mode -> do
setStoreChanged
liftIO $ setSQLiteJournalMode chatStore mode >> setSQLiteJournalMode agentStore mode
liftIO $ do
chatMode <- getSQLiteJournalMode chatStore
agentMode <- getSQLiteJournalMode agentStore
pure CRStoreSQLMode {chatMode, agentMode}
APIGetChats userId withPCC -> withUserId userId $ \user ->
CRApiChats user <$> withStoreCtx' (Just "APIGetChats, getChatPreviews") (\db -> getChatPreviews db user withPCC)
APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of
@@ -5264,13 +5277,13 @@ createSndMessage chatMsgEvent connOrGroupId = do
gVar <- asks idsDrg
ChatConfig {chatVRange} <- asks config
withStore $ \db -> createNewSndMessage db gVar connOrGroupId $ \sharedMsgId ->
let msgBody = strEncode ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent, groupEvent = Nothing}
let msgBody = strEncode ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent}
in NewMessage {chatMsgEvent, msgBody}
directMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString
directMessage chatMsgEvent = do
ChatConfig {chatVRange} <- asks config
pure $ strEncode ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent, groupEvent = Nothing}
pure $ strEncode ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent}
deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64
deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do
@@ -5679,6 +5692,7 @@ chatCommandP =
"/sql chat " *> (ExecChatStoreSQL <$> textP),
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
"/sql slow" $> SlowSQLQueries,
"/sql mode" *> (StoreSQLMode <$> optional (A.space *> strP)),
"/_get chats " *> (APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)),
"/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),

View File

@@ -3,6 +3,7 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
module Simplex.Chat.Archive
( exportArchive,
@@ -21,7 +22,7 @@ import qualified Data.Text as T
import qualified Database.SQLite3 as SQL
import Simplex.Chat.Controller
import Simplex.Messaging.Agent.Client (agentClientStore)
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString, closeSQLiteStore)
import Simplex.Messaging.Agent.Store.SQLite
import Simplex.Messaging.Util
import System.FilePath
import UnliftIO.Directory
@@ -41,28 +42,31 @@ archiveFilesFolder = "simplex_v1_files"
exportArchive :: ChatMonad m => ArchiveConfig -> m ()
exportArchive cfg@ArchiveConfig {archivePath, disableCompression} =
withTempDir cfg "simplex-chat." $ \dir -> do
StorageFiles {chatStore, agentStore, filesPath} <- storageFiles
handleErr $ withTempDir cfg "simplex-chat." $ \dir -> do
fs@StorageFiles {chatStore, agentStore, filesPath} <- storageFiles
setWALMode SQLModeDelete `withStores` fs
copyFile (dbFilePath chatStore) $ dir </> archiveChatDbFile
copyFile (dbFilePath agentStore) $ dir </> archiveAgentDbFile
setWALMode SQLModeWAL `withStores` fs
forM_ filesPath $ \fp ->
copyDirectoryFiles fp $ dir </> archiveFilesFolder
let method = if disableCompression == Just True then Z.Store else Z.Deflate
Z.createArchive archivePath $ Z.packDirRecur method Z.mkEntrySelector dir
where
setWALMode mode st = liftIO $ setSQLiteJournalMode st mode
importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError]
importArchive cfg@ArchiveConfig {archivePath} =
withTempDir cfg "simplex-chat." $ \dir -> do
handleErr $ withTempDir cfg "simplex-chat." $ \dir -> do
Z.withArchive archivePath $ Z.unpackInto dir
fs@StorageFiles {chatStore, agentStore, filesPath} <- storageFiles
liftIO $ closeSQLiteStore `withStores` fs
backup `withDBs` fs
liftIO $ (closeSQLiteStore `withStores` fs) `catch` print @SomeException
liftIO $ backupSQLiteStore `withStores` fs
copyFile (dir </> archiveChatDbFile) $ dbFilePath chatStore
copyFile (dir </> archiveAgentDbFile) $ dbFilePath agentStore
copyFiles dir filesPath
`E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e]
where
backup f = whenM (doesFileExist f) $ copyFile f $ f <> ".bak"
copyFiles dir filesPath = do
let filesDir = dir </> archiveFilesFolder
case filesPath of
@@ -93,16 +97,18 @@ copyDirectoryFiles fromDir toDir = do
whenM (doesFileExist f') $ copyFile f' $ toDir </> fn
deleteStorage :: ChatMonad m => m ()
deleteStorage = do
deleteStorage = handleErr $ do
fs <- storageFiles
liftIO $ closeSQLiteStore `withStores` fs
remove `withDBs` fs
liftIO $ removeSQLiteStore `withStores` fs
mapM_ removeDir $ filesPath fs
mapM_ removeDir =<< chatReadVar tempDirectory
where
remove f = whenM (doesFileExist f) $ removeFile f
removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d
handleErr :: ChatMonad m => m a -> m a
handleErr = E.handle (throwError . mkChatError)
data StorageFiles = StorageFiles
{ chatStore :: SQLiteStore,
agentStore :: SQLiteStore,
@@ -121,17 +127,15 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
when (key /= key') $ do
fs <- storageFiles
checkFile `withDBs` fs
backup `withDBs` fs
liftIO $ backupSQLiteStore `withStores` fs
checkEncryption `withStores` fs
removeExported `withDBs` fs
export `withDBs` fs
-- closing after encryption prevents closing in case wrong encryption key was passed
liftIO $ closeSQLiteStore `withStores` fs
(moveExported `withStores` fs)
`catchChatError` \e -> (restore `withDBs` fs) >> throwError e
`catchChatError` \e -> liftIO (restoreSQLiteStore `withStores` fs) >> throwError e
where
backup f = copyFile f (f <> ".bak")
restore f = copyFile (f <> ".bak") f
checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f
checkEncryption SQLiteStore {dbEncrypted} = do
enc <- readTVarIO dbEncrypted
@@ -161,6 +165,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
T.unlines $
keySQL key
<> [ "ATTACH DATABASE " <> sqlString (f <> ".exported") <> " AS exported KEY " <> sqlString key' <> ";",
"PRAGMA wal_checkpoint(TRUNCATE);",
"SELECT sqlcipher_export('exported');",
"DETACH DATABASE exported;"
]
@@ -173,8 +178,8 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
]
keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)]
withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b
withDBs :: Monad m => (FilePath -> m a) -> StorageFiles -> m a
action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore)
withStores :: Monad m => (SQLiteStore -> m b) -> StorageFiles -> m b
withStores :: Monad m => (SQLiteStore -> m a) -> StorageFiles -> m a
action `withStores` StorageFiles {chatStore, agentStore} = action chatStore >> action agentStore

View File

@@ -54,7 +54,7 @@ import Simplex.Messaging.Agent.Client (AgentLocks, ProtocolTestFailure)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig)
import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, SQLiteJournalMode, UpMigration, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C
@@ -232,6 +232,7 @@ data ChatCommand
| ExecChatStoreSQL Text
| ExecAgentStoreSQL Text
| SlowSQLQueries
| StoreSQLMode (Maybe SQLiteJournalMode)
| APIGetChats {userId :: UserId, pendingConnections :: Bool}
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems ChatPagination (Maybe String)
@@ -588,6 +589,7 @@ data ChatResponse
| CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection}
| CRSQLResult {rows :: [Text]}
| CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]}
| CRStoreSQLMode {chatMode :: SQLiteJournalMode, agentMode :: SQLiteJournalMode}
| CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks}
| CRAgentStats {agentStats :: [[String]]}
| CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]}

View File

@@ -36,7 +36,6 @@ import Database.SQLite.Simple.ToField (ToField (..))
import GHC.Generics (Generic)
import Simplex.Chat.Markdown
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Messages.Events
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
@@ -342,7 +341,6 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta
itemTimed :: Maybe CITimed,
itemLive :: Maybe Bool,
editable :: Bool,
groupIntegrityStatus :: GroupIntegrityStatus,
createdAt :: UTCTime,
updatedAt :: UTCTime
}
@@ -353,22 +351,10 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted item
let editable = case itemContent of
CISndMsgContent _ -> diffUTCTime currentTs itemTs < nominalDay && isNothing itemDeleted
_ -> False
groupIntegrityStatus = GISNoEvent
in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, groupIntegrityStatus, createdAt, updatedAt}
in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, createdAt, updatedAt}
instance ToJSON (CIMeta c d) where toEncoding = J.genericToEncoding J.defaultOptions
data GroupIntegrityStatus
= GISOk -- sent event; or received event with all parents known
| GISIntegrityError GroupEventIntegrityError -- received event has integrity error (if many, order and choose one?)
| GISConfirmedParent GroupEventIntegrityConfirmation -- received event has no errors and was confirmed by other member, higher role is preferred
| GISNoEvent -- direct chat items and group chat items without recorded group events (legacy)
deriving (Show, Generic)
instance ToJSON GroupIntegrityStatus where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GIS"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GIS"
data CITimed = CITimed
{ ttl :: Int, -- seconds
deleteAt :: Maybe UTCTime -- this is initially Nothing for received items, the timer starts when they are read

View File

@@ -137,7 +137,6 @@ data CIContent (d :: MsgDirection) where
CIRcvGroupFeatureRejected :: GroupFeature -> CIContent 'MDRcv
CISndModerated :: CIContent 'MDSnd
CIRcvModerated :: CIContent 'MDRcv
-- CIRcvMissing :: CIContent 'MDRcv -- to display group dag gaps
CIInvalidJSON :: Text -> CIContent d
-- ^ This type is used both in API and in DB, so we use different JSON encodings for the database and for the API
-- ! ^ Nested sum types also have to use different encodings for database and API

View File

@@ -1,80 +0,0 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE TemplateHaskell #-}
module Simplex.Chat.Messages.Events where
import qualified Data.Aeson.TH as JQ
import Data.ByteString.Char8 (ByteString)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON)
import Simplex.Messaging.Version
data StoredGroupEvent d = StoredGroupEvent
{ chatVRange :: VersionRange,
msgId :: SharedMsgId,
eventData :: StoredGroupEventData,
integrityErrors :: [GroupEventIntegrityError],
integrityConfirmations :: [GroupEventIntegrityConfirmation],
sharedHash :: ByteString,
eventDir :: GEDirection d,
parents :: [AStoredGroupEvent]
}
data AStoredGroupEvent = forall d. MsgDirectionI d => AStoredGroupEvent (StoredGroupEvent d)
data GroupEventIntegrityError = GroupEventIntegrityError
{ groupMemberId :: GroupMemberId,
memberRole :: GroupMemberRole,
error :: GroupEventError
}
deriving (Show)
data GroupEventError
= GEErrInvalidHash -- content hash mismatch
| GEErrUnconfirmedParent SharedMsgId -- referenced parent wasn't previously received from author or admin
| GEErrParentHashMismatch SharedMsgId -- referenced parent has different hash
| GEErrChildHashMismatch SharedMsgId -- child referencing this event has different hash (mirrors GEErrParentHashMismatch)
deriving (Show)
data GroupEventIntegrityConfirmation = GroupEventIntegrityConfirmation
{ groupMemberId :: GroupMemberId,
memberRole :: GroupMemberRole
}
deriving (Show)
data GEDirection (d :: MsgDirection) where
GESent :: GEDirection 'MDSnd
GEReceived :: ReceivedEventInfo -> GEDirection 'MDRcv
data StoredGroupEventData = SGEData (ChatMsgEvent 'Json) | SGEAvailable [GroupMemberId]
data ReceivedEventInfo = ReceivedEventInfo
{ authorMemberId :: MemberId,
authorMemberName :: ContactName,
authorMember :: GroupMemberRef,
receivedFrom :: GroupMemberRef,
processing :: EventProcessing
}
data ReceivedFromRole = RFAuthor | RFSufficientPrivilege | RFLower
receivedFromRole' :: ReceivedEventInfo -> ReceivedFromRole
receivedFromRole' = undefined
data EventProcessing
= EPProcessed UTCTime
| EPScheduled UTCTime
| EPPendingConfirmation -- e.g. till it's received from author or member with the same or higher privileges (depending on the event)
-- platform-specific JSON encoding (used in API)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GEErr") ''GroupEventError)
$(JQ.deriveJSON defaultJSON ''GroupEventIntegrityError)
$(JQ.deriveJSON defaultJSON ''GroupEventIntegrityConfirmation)

View File

@@ -1,128 +0,0 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20231101_group_events where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20231101_group_events :: Query
m20231101_group_events =
[sql|
CREATE TABLE group_events (
group_event_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL,
chat_min_version INTEGER NOT NULL DEFAULT 1, -- chatVRange :: VersionRange
chat_max_version INTEGER NOT NULL DEFAULT 1,
shared_msg_id BLOB NOT NULL, -- msgId :: SharedMsgId
event_data TEXT NOT NULL, -- eventData :: StoredGroupEventData
shared_hash BLOB NOT NULL, -- sharedHash :: ByteString
event_sent INTEGER NOT NULL, -- 0 for received, 1 for sent; below `rcvd_` fields are null for sent
-- ReceivedEventInfo fields:
rcvd_author_member_id BLOB, -- authorMemberId :: MemberId
rcvd_author_member_name TEXT, -- authorMemberName :: ContactName
-- authorMember :: GroupMemberRef
rcvd_author_group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
rcvd_author_contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE CASCADE,
rcvd_author_role TEXT,
-- receivedFrom :: GroupMemberRef
rcvd_from_group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
rcvd_from_contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE CASCADE,
rcvd_from_role TEXT,
-- ReceivedEventInfo processing :: EventProcessing
rcvd_processed_at TEXT, -- EPProcessed UTCTime
rcvd_scheduled_at TEXT, -- EPScheduled UTCTime; both this and rcvd_processed_at are null -> EPPendingConfirmation
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_group_events_user_id ON group_events(user_id);
CREATE INDEX idx_group_events_chat_item_id ON group_events(chat_item_id);
CREATE INDEX idx_group_events_shared_msg_id ON group_events(shared_msg_id);
CREATE INDEX idx_group_events_rcvd_author_group_member_id ON group_events(rcvd_author_group_member_id);
CREATE INDEX idx_group_events_rcvd_author_contact_profile_id ON group_events(rcvd_author_contact_profile_id);
CREATE INDEX idx_group_events_rcvd_from_group_member_id ON group_events(rcvd_from_group_member_id);
CREATE INDEX idx_group_events_rcvd_from_contact_profile_id ON group_events(rcvd_from_contact_profile_id);
CREATE TABLE group_events_availabilities (
group_events_availability_id INTEGER PRIMARY KEY,
group_event_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
available_at_group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_group_events_availabilities_group_event_id ON group_events_availabilities(group_event_id);
CREATE INDEX idx_group_events_availabilities_available_at_group_member_id ON group_events_availabilities(available_at_group_member_id);
CREATE TABLE group_events_errors (
group_event_dag_error_id INTEGER PRIMARY KEY,
group_event_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
referred_group_event_id INTEGER REFERENCES group_events ON DELETE SET NULL,
referred_group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
referred_group_member_role TEXT NOT NULL,
error TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_group_events_errors_group_event_id ON group_events_errors(group_event_id);
CREATE INDEX idx_group_events_errors_referred_group_event_id ON group_events_errors(referred_group_event_id);
CREATE INDEX idx_group_events_errors_referred_group_member_id ON group_events_errors(referred_group_member_id);
CREATE TABLE group_events_confirmations (
group_event_confirmation_id INTEGER PRIMARY KEY,
group_event_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
confirming_group_event_id INTEGER REFERENCES group_events ON DELETE SET NULL,
confirmed_by_group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
confirmed_by_group_member_role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_group_events_confirmations_group_event_id ON group_events_confirmations(group_event_id);
CREATE INDEX idx_group_events_confirmations_confirming_group_event_id ON group_events_confirmations(confirming_group_event_id);
CREATE INDEX idx_group_events_confirmations_confirmed_by_group_member_id ON group_events_confirmations(confirmed_by_group_member_id);
CREATE TABLE group_events_parents (
group_event_parent_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
group_event_child_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now')),
UNIQUE(group_event_parent_id, group_event_child_id)
);
CREATE INDEX idx_group_events_parents_group_event_parent_id ON group_events_parents(group_event_parent_id);
CREATE INDEX idx_group_events_parents_group_event_child_id ON group_events_parents(group_event_child_id);
|]
down_m20231101_group_events :: Query
down_m20231101_group_events =
[sql|
DROP INDEX idx_group_events_parents_group_event_parent_id;
DROP INDEX idx_group_events_parents_group_event_child_id;
DROP TABLE group_events_parents;
DROP INDEX idx_group_events_confirmations_group_event_id;
DROP INDEX idx_group_events_confirmations_confirming_group_event_id;
DROP INDEX idx_group_events_confirmations_confirmed_by_group_member_id;
DROP TABLE group_events_confirmations;
DROP INDEX idx_group_events_errors_group_event_id;
DROP INDEX idx_group_events_errors_referred_group_event_id;
DROP INDEX idx_group_events_errors_referred_group_member_id;
DROP TABLE group_events_errors;
DROP INDEX idx_group_events_availabilities_group_event_id;
DROP INDEX idx_group_events_availabilities_available_at_group_member_id;
DROP TABLE group_events_availabilities;
DROP INDEX idx_group_events_user_id;
DROP INDEX idx_group_events_chat_item_id;
DROP INDEX idx_group_events_shared_msg_id;
DROP INDEX idx_group_events_rcvd_author_group_member_id;
DROP INDEX idx_group_events_rcvd_author_contact_profile_id;
DROP INDEX idx_group_events_rcvd_from_group_member_id;
DROP INDEX idx_group_events_rcvd_from_contact_profile_id;
DROP TABLE group_events;
|]

View File

@@ -520,66 +520,6 @@ CREATE TABLE IF NOT EXISTS "received_probes"(
created_at TEXT CHECK(created_at NOT NULL),
updated_at TEXT CHECK(updated_at NOT NULL)
);
CREATE TABLE group_events(
group_event_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL,
chat_min_version INTEGER NOT NULL DEFAULT 1, -- chatVRange :: VersionRange
chat_max_version INTEGER NOT NULL DEFAULT 1,
shared_msg_id BLOB NOT NULL, -- msgId :: SharedMsgId
event_data TEXT NOT NULL, -- eventData :: StoredGroupEventData
shared_hash BLOB NOT NULL, -- sharedHash :: ByteString
event_sent INTEGER NOT NULL, -- 0 for received, 1 for sent; below `rcvd_` fields are null for sent
-- ReceivedEventInfo fields:
rcvd_author_member_id BLOB, -- authorMemberId :: MemberId
rcvd_author_member_name TEXT, -- authorMemberName :: ContactName
-- authorMember :: GroupMemberRef
rcvd_author_group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
rcvd_author_contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE CASCADE,
rcvd_author_role TEXT,
-- receivedFrom :: GroupMemberRef
rcvd_from_group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
rcvd_from_contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE CASCADE,
rcvd_from_role TEXT,
-- ReceivedEventInfo processing :: EventProcessing
rcvd_processed_at TEXT, -- EPProcessed UTCTime
rcvd_scheduled_at TEXT, -- EPScheduled UTCTime; both this and rcvd_processed_at are null -> EPPendingConfirmation
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE group_events_availabilities(
group_events_availability_id INTEGER PRIMARY KEY,
group_event_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
available_at_group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE group_events_errors(
group_event_dag_error_id INTEGER PRIMARY KEY,
group_event_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
referred_group_event_id INTEGER REFERENCES group_events ON DELETE SET NULL,
referred_group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
referred_group_member_role TEXT NOT NULL,
error TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE group_events_confirmations(
group_event_confirmation_id INTEGER PRIMARY KEY,
group_event_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
confirming_group_event_id INTEGER REFERENCES group_events ON DELETE SET NULL,
confirmed_by_group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
confirmed_by_group_member_role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE group_events_parents(
group_event_parent_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
group_event_child_id INTEGER NOT NULL REFERENCES group_events ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now')),
UNIQUE(group_event_parent_id, group_event_child_id)
);
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@@ -808,48 +748,3 @@ CREATE INDEX idx_connections_via_contact_uri_hash ON connections(
user_id,
via_contact_uri_hash
);
CREATE INDEX idx_group_events_user_id ON group_events(user_id);
CREATE INDEX idx_group_events_chat_item_id ON group_events(chat_item_id);
CREATE INDEX idx_group_events_shared_msg_id ON group_events(shared_msg_id);
CREATE INDEX idx_group_events_rcvd_author_group_member_id ON group_events(
rcvd_author_group_member_id
);
CREATE INDEX idx_group_events_rcvd_author_contact_profile_id ON group_events(
rcvd_author_contact_profile_id
);
CREATE INDEX idx_group_events_rcvd_from_group_member_id ON group_events(
rcvd_from_group_member_id
);
CREATE INDEX idx_group_events_rcvd_from_contact_profile_id ON group_events(
rcvd_from_contact_profile_id
);
CREATE INDEX idx_group_events_availabilities_group_event_id ON group_events_availabilities(
group_event_id
);
CREATE INDEX idx_group_events_availabilities_available_at_group_member_id ON group_events_availabilities(
available_at_group_member_id
);
CREATE INDEX idx_group_events_errors_group_event_id ON group_events_errors(
group_event_id
);
CREATE INDEX idx_group_events_errors_referred_group_event_id ON group_events_errors(
referred_group_event_id
);
CREATE INDEX idx_group_events_errors_referred_group_member_id ON group_events_errors(
referred_group_member_id
);
CREATE INDEX idx_group_events_confirmations_group_event_id ON group_events_confirmations(
group_event_id
);
CREATE INDEX idx_group_events_confirmations_confirming_group_event_id ON group_events_confirmations(
confirming_group_event_id
);
CREATE INDEX idx_group_events_confirmations_confirmed_by_group_member_id ON group_events_confirmations(
confirmed_by_group_member_id
);
CREATE INDEX idx_group_events_parents_group_event_parent_id ON group_events_parents(
group_event_parent_id
);
CREATE INDEX idx_group_events_parents_group_event_child_id ON group_events_parents(
group_event_child_id
);

View File

@@ -43,7 +43,7 @@ import Simplex.Chat.Store.Profiles
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Client (agentClientStore)
import Simplex.Messaging.Agent.Env.SQLite (createAgentStore)
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, closeSQLiteStore)
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, closeSQLiteStore, openSQLiteStore)
import Simplex.Messaging.Client (defaultNetworkConfig)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
@@ -57,6 +57,8 @@ foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString
foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString
foreign export ccall "chat_open_store" cChatOpenStore :: StablePtr ChatController -> CString -> IO CString
foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString
foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString
@@ -104,6 +106,12 @@ cChatMigrateInit fp key conf ctrl = do
cChatCloseStore :: StablePtr ChatController -> IO CString
cChatCloseStore cPtr = deRefStablePtr cPtr >>= chatCloseStore >>= newCAString
cChatOpenStore :: StablePtr ChatController -> CString -> IO CString
cChatOpenStore cPtr cKey = do
c <- deRefStablePtr cPtr
key <- peekCAString cKey
newCAString =<< chatOpenStore c key
-- | send command to chat (same syntax as in terminal for now)
cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString
cChatSendCmd cPtr cCmd = do
@@ -214,9 +222,14 @@ chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do
closeSQLiteStore chatStore
closeSQLiteStore $ agentClientStore smpAgent
chatOpenStore :: ChatController -> String -> IO String
chatOpenStore ChatController {chatStore, smpAgent} key = handleErr $ do
openSQLiteStore chatStore key
openSQLiteStore (agentClientStore smpAgent) key
handleErr :: IO () -> IO String
handleErr a = (a $> "") `catch` (pure . show @SomeException)
chatSendCmd :: ChatController -> ByteString -> IO JSONByteString
chatSendCmd cc s = J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc

View File

@@ -124,31 +124,12 @@ data AppMessage (e :: MsgEncoding) where
-- chat message is sent as JSON with these properties
data AppMessageJson = AppMessageJson
{ v :: Maybe ChatVersionRange,
msgId :: Maybe SharedMsgId, -- maybe it's time we make it required? Or we can make it required inside `dag`
msgId :: Maybe SharedMsgId,
event :: Text,
params :: J.Object,
groupEvent :: Maybe JsonGroupEvent
params :: J.Object
}
deriving (Generic, FromJSON)
data JsonGroupEvent = JsonGroupEvent
{ sharedHash :: Text, -- this hash must be computed from the shared part of the message that is sent to all members (e.g., including file hash but excluding file description)
parents :: [JsonGroupEventParent]
}
deriving (Generic, FromJSON, ToJSON)
data JsonGroupEventParent = JsonGroupEventParent
{ msgId :: SharedMsgId,
memberId :: MemberId,
displayName :: ContactName,
groupEvent :: JsonGroupEvent,
groupEventData :: JsonGroupEventData
}
deriving (Generic, FromJSON, ToJSON)
data JsonGroupEventData = JGEData AppMessageJson | JGEAvailable | JGENothing
deriving (Generic, FromJSON, ToJSON)
data AppMessageBinary = AppMessageBinary
{ msgId :: Maybe SharedMsgId,
tag :: Char,
@@ -205,29 +186,10 @@ instance ToJSON MsgRef where
data ChatMessage e = ChatMessage
{ chatVRange :: VersionRange,
msgId :: Maybe SharedMsgId,
chatMsgEvent :: ChatMsgEvent e,
groupEvent :: Maybe (GroupEvent e)
chatMsgEvent :: ChatMsgEvent e
}
deriving (Eq, Show)
data GroupEvent e = GroupEvent
{ sharedHash :: Text, -- this hash must be computed from the shared part of the message that is sent to all members (e.g., including file hash but excluding file description)
parents :: [GroupEventParent e]
}
deriving (Eq, Show)
data GroupEventParent e = GroupEventParent
{ msgId :: SharedMsgId,
memberId :: MemberId,
displayName :: ContactName,
groupEvent :: GroupEvent e,
groupEventData :: GroupEventData e
}
deriving (Eq, Show)
data GroupEventData e = GEData (ChatMessage e) | GEAvailable | GENothing
deriving (Eq, Show)
data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e)
instance MsgEncodingI e => StrEncoding (ChatMessage e) where
@@ -243,51 +205,6 @@ instance StrEncoding AChatMessage where
'{' -> ACMsg SJson <$> ((appJsonToCM <=< J.eitherDecodeStrict') <$?> A.takeByteString)
_ -> ACMsg SBinary <$> (appBinaryToCM <$?> strP)
sharedGroupMsgEvent :: ChatMsgEvent e -> Maybe (ChatMsgEvent e)
sharedGroupMsgEvent ev = case ev of
XMsgNew _ -> Just ev -- TODO remove file description, include file hash
XMsgFileDescr {} -> Nothing
XMsgFileCancel _ -> Just ev
XMsgUpdate {} -> Just ev
XMsgDel {} -> Just ev
XMsgDeleted -> Nothing
XMsgReact {} -> Just ev
XFile _ -> Nothing
XFileAcpt _ -> Nothing
XFileAcptInv {} -> Nothing
XFileCancel _ -> Nothing
XInfo _ -> Just ev
XContact {} -> Just ev -- ?
XDirectDel -> Nothing
XGrpInv _ -> Nothing
XGrpAcpt _ -> Nothing
XGrpLinkInv _ -> Nothing
XGrpLinkMem _ -> Nothing
XGrpMemNew _ -> Just ev
XGrpMemIntro _ -> Nothing
XGrpMemInv {} -> Nothing
XGrpMemFwd {} -> Nothing
XGrpMemInfo {} -> Nothing
XGrpMemRole {} -> Just ev
XGrpMemCon _ -> Nothing -- TODO not implemented
XGrpMemConAll _ -> Nothing -- TODO not implemented
XGrpMemDel _ -> Just ev
XGrpLeave -> Just ev
XGrpDel -> Just ev
XGrpInfo _ -> Just ev
XGrpDirectInv {} -> Nothing
XInfoProbe _ -> Nothing
XInfoProbeCheck _ -> Nothing
XInfoProbeOk _ -> Nothing
XCallInv {} -> Nothing
XCallOffer {} -> Nothing
XCallAnswer {} -> Nothing
XCallExtra {} -> Nothing
XCallEnd _ -> Nothing
XOk -> Nothing
XUnknown {} -> Nothing
BFileChunk {} -> Nothing
data ChatMsgEvent (e :: MsgEncoding) where
XMsgNew :: MsgContainer -> ChatMsgEvent 'Json
XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json
@@ -858,7 +775,7 @@ appBinaryToCM :: AppMessageBinary -> Either String (ChatMessage 'Binary)
appBinaryToCM AppMessageBinary {msgId, tag, body} = do
eventTag <- strDecode $ B.singleton tag
chatMsgEvent <- parseAll (msg eventTag) body
pure ChatMessage {chatVRange = chatInitialVRange, msgId, chatMsgEvent, groupEvent = Nothing}
pure ChatMessage {chatVRange = chatInitialVRange, msgId, chatMsgEvent}
where
msg :: CMEventTag 'Binary -> A.Parser (ChatMsgEvent 'Binary)
msg = \case
@@ -868,7 +785,7 @@ appJsonToCM :: AppMessageJson -> Either String (ChatMessage 'Json)
appJsonToCM AppMessageJson {v, msgId, event, params} = do
eventTag <- strDecode $ encodeUtf8 event
chatMsgEvent <- msg eventTag
pure ChatMessage {chatVRange = maybe chatInitialVRange fromChatVRange v, msgId, chatMsgEvent, groupEvent = Nothing}
pure ChatMessage {chatVRange = maybe chatInitialVRange fromChatVRange v, msgId, chatMsgEvent}
where
p :: FromJSON a => J.Key -> Either String a
p key = JT.parseEither (.: key) params
@@ -926,7 +843,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @
SBinary ->
let (binaryMsgId, body) = toBody chatMsgEvent
in AMBinary AppMessageBinary {msgId = binaryMsgId, tag = B.head $ strEncode tag, body}
SJson -> AMJson AppMessageJson {v = Just $ ChatVersionRange chatVRange, msgId, event = textEncode tag, params = params chatMsgEvent, groupEvent = Nothing}
SJson -> AMJson AppMessageJson {v = Just $ ChatVersionRange chatVRange, msgId, event = textEncode tag, params = params chatMsgEvent}
where
tag = toCMEventTag chatMsgEvent
o :: [(J.Key, J.Value)] -> J.Object

View File

@@ -87,7 +87,6 @@ import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash
import Simplex.Chat.Migrations.M20231010_member_settings
import Simplex.Chat.Migrations.M20231019_indexes
import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received
import Simplex.Chat.Migrations.M20231101_group_events
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -174,8 +173,7 @@ schemaMigrations =
("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash),
("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings),
("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes),
("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received),
("20231101_group_events", m20231101_group_events, Just down_m20231101_group_events)
("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received)
]
-- | The list of migrations in ascending order by date

View File

@@ -710,14 +710,14 @@ instance ToJSON GroupMember where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, role :: GroupMemberRole, profile :: Profile}
data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, profile :: Profile}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON GroupMemberRef where toEncoding = J.genericToEncoding J.defaultOptions
groupMemberRef :: GroupMember -> GroupMemberRef
groupMemberRef GroupMember {groupMemberId, memberRole, memberProfile = p} =
GroupMemberRef {groupMemberId, role = memberRole, profile = fromLocalProfile p}
groupMemberRef GroupMember {groupMemberId, memberProfile = p} =
GroupMemberRef {groupMemberId, profile = fromLocalProfile p}
memberConn :: GroupMember -> Maybe Connection
memberConn GroupMember{activeConn} = activeConn
@@ -791,8 +791,6 @@ fromInvitedBy userCtId = \case
IBContact ctId -> Just ctId
IBUser -> Just userCtId
-- add:
-- | GRUnknown -- used for unconfirmed members (learnt through group event parent)
data GroupMemberRole
= GRObserver -- connects to all group members and receives all messages, can't send messages
| GRAuthor -- reserved, unused
@@ -899,8 +897,6 @@ instance TextEncoding GroupMemberCategory where
GCPreMember -> "pre"
GCPostMember -> "post"
-- add:
-- | GSMemUnconfirmed -- used for unconfirmed members (learnt through group event parent)
data GroupMemberStatus
= GSMemRemoved -- member who was removed from the group
| GSMemLeft -- member who left the group

View File

@@ -272,6 +272,9 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
<> (" :: avg: " <> sShow timeAvg <> " ms")
<> (" :: " <> plain (T.unwords $ T.lines query))
in ("Chat queries" : map viewQuery chatQueries) <> [""] <> ("Agent queries" : map viewQuery agentQueries)
CRStoreSQLMode {chatMode, agentMode} ->
let viewMode mode = plain $ "DB journal mode: " <> strEncode mode
in ["Chat " <> viewMode chatMode, "Agent " <> viewMode agentMode]
CRDebugLocks {chatLockName, agentLocks} ->
[ maybe "no chat lock" (("chat lock: " <>) . plain) chatLockName,
plain $ "agent locks: " <> LB.unpack (J.encode agentLocks)

View File

@@ -49,9 +49,9 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: 0410948b56ea630dfa86441bbcf8ec97aeb1df01
commit: 7ebb63025cc70d0649830b31846deba2348c3c38
- github: kazu-yamamoto/http2
commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517
commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb
# - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher
commit: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9

View File

@@ -24,6 +24,7 @@ import Network.Socket
import Simplex.Chat
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..))
import Simplex.Chat.Core
import Simplex.Chat.Mobile (chatCloseStore)
import Simplex.Chat.Options
import Simplex.Chat.Store
import Simplex.Chat.Store.Profiles
@@ -184,6 +185,7 @@ stopTestChat TestCC {chatController = cc, chatAsync, termAsync} = do
stopChatController cc
uninterruptibleCancel termAsync
uninterruptibleCancel chatAsync
chatCloseStore cc `shouldReturn` ""
threadDelay 200000
withNewTestChat :: HasCallStack => FilePath -> String -> Profile -> (HasCallStack => TestCC -> IO a) -> IO a

View File

@@ -29,7 +29,7 @@ import Simplex.Chat.Mobile.WebRTC
import Simplex.Chat.Store
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Types (AgentUserId (..), Profile (..))
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..))
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), closeSQLiteStore)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..))
import qualified Simplex.Messaging.Crypto.File as CF
@@ -209,9 +209,14 @@ testChatApi tmp = do
f = chatStoreFile dbPrefix
Right st <- createChatStore f "myKey" MCYesUp
Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True
closeSQLiteStore st
Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp"
Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp"
Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" "yesUp"
chatCloseStore cc `shouldReturn` ""
chatOpenStore cc "" >>= (`shouldContain` "file is not a database")
chatOpenStore cc "anotherKey" >>= (`shouldContain` "file is not a database")
chatOpenStore cc "myKey" `shouldReturn` ""
chatSendCmd cc "/u" `shouldReturn` activeUser
chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists
chatSendCmd cc "/_start" `shouldReturn` chatStarted

View File

@@ -76,10 +76,10 @@ s ##==## msg = do
s ==## msg
(==#) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation
s ==# msg = s ==## ChatMessage chatInitialVRange Nothing msg Nothing
s ==# msg = s ==## ChatMessage chatInitialVRange Nothing msg
(#==) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation
s #== msg = s ##== ChatMessage chatInitialVRange Nothing msg Nothing
s #== msg = s ##== ChatMessage chatInitialVRange Nothing msg
(#==#) :: MsgEncodingI e => ByteString -> ChatMsgEvent e -> Expectation
s #==# msg = do
@@ -120,40 +120,37 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
#==# XMsgNew (MCSimple (extMsgContent (MCImage "here's an image" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing))
it "x.msg.new chat message" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) Nothing
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)))
it "x.msg.new chat message with chat version range" $
"{\"v\":\"1-3\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) Nothing
##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)))
it "x.msg.new quote" $
"{\"v\":\"1\",\"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
chatInitialVRange
(Just $ SharedMsgId "\1\2\3\4")
(XMsgNew (MCQuote quotedMsg (extMsgContent (MCText "hello to you too") Nothing)))
Nothing
it "x.msg.new quote - timed message TTL" $
"{\"v\":\"1\",\"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\"}},\"ttl\":3600}}"
##==## ChatMessage
chatInitialVRange
(Just $ SharedMsgId "\1\2\3\4")
(XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing (Just 3600) Nothing)))
Nothing
it "x.msg.new quote - live message" $
"{\"v\":\"1\",\"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\"}},\"live\":true}}"
##==## ChatMessage
chatInitialVRange
(Just $ SharedMsgId "\1\2\3\4")
(XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") Nothing Nothing (Just True))))
Nothing
it "x.msg.new forward" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}"
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) Nothing
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing))
it "x.msg.new forward - timed message TTL" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}"
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing)) Nothing
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing (Just 3600) Nothing))
it "x.msg.new forward - live message" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}"
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True))) Nothing
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing Nothing (Just True)))
it "x.msg.new simple text with file" $
"{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing})))
@@ -174,10 +171,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
)
)
)
Nothing
it "x.msg.new forward with file" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) Nothing
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing})))
it "x.msg.update" $
"{\"v\":\"1\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
#==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") Nothing Nothing