Compare commits
19 Commits
group-inte
...
ep/journal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b956f80132 | ||
|
|
34b07d6a3b | ||
|
|
5bdbba1117 | ||
|
|
fad5128a83 | ||
|
|
4fd38a270c | ||
|
|
4cc20a2d32 | ||
|
|
68873464d7 | ||
|
|
c1a0486c1d | ||
|
|
381346cdba | ||
|
|
7fe940e921 | ||
|
|
5878d4608c | ||
|
|
b26195e581 | ||
|
|
4d37eff26c | ||
|
|
4d2826f490 | ||
|
|
c4ac5a784f | ||
|
|
d32adf6f6c | ||
|
|
8d6fee89db | ||
|
|
eb22f32d18 | ||
|
|
497ef087c5 |
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,3 +23,5 @@ actual fun Modifier.desktopOnExternalDrag(
|
||||
onImage: (Painter) -> Unit,
|
||||
onText: (String) -> Unit
|
||||
): Modifier = this
|
||||
|
||||
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,3 +20,5 @@ expect fun Modifier.desktopOnExternalDrag(
|
||||
onImage: (Painter) -> Unit = {},
|
||||
onText: (String) -> Unit = {}
|
||||
): Modifier
|
||||
|
||||
expect fun Modifier.onRightClick(action: () -> Unit): Modifier
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = { _, _ -> },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = { _, _ -> },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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() }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ compose {
|
||||
notarization {
|
||||
this.appleID.set(appleId)
|
||||
this.password.set(password)
|
||||
this.ascProvider.set(teamId)
|
||||
this.teamID.set(teamId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
124
docs/rfcs/2023-09-25-groups-integrity.md
Normal file
124
docs/rfcs/2023-09-25-groups-integrity.md
Normal 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
|
||||
229
docs/rfcs/2023-10-20-group-integrity.md
Normal file
229
docs/rfcs/2023-10-20-group-integrity.md
Normal 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?
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user