From 805a69da94d7057c119e731b8a983b4f8add5de3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 21 Apr 2025 15:17:21 +0000 Subject: [PATCH] android, desktop: group knocking (#5816) --- .../platform/PlatformTextField.android.kt | 3 + .../chat/simplex/common/model/ChatModel.kt | 342 ++++--- .../chat/simplex/common/model/SimpleXAPI.kt | 180 ++-- .../common/platform/PlatformTextField.kt | 1 + .../chat/simplex/common/views/TerminalView.kt | 1 + .../simplex/common/views/chat/ChatInfoView.kt | 10 +- .../common/views/chat/ChatItemsLoader.kt | 6 +- .../simplex/common/views/chat/ChatView.kt | 898 +++++++++--------- .../ComposeContextPendingMemberActionsView.kt | 116 +++ .../simplex/common/views/chat/ComposeView.kt | 38 +- .../views/chat/SelectableChatItemToolbars.kt | 2 +- .../simplex/common/views/chat/SendMsgView.kt | 7 +- .../views/chat/group/AddGroupMembersView.kt | 2 +- .../views/chat/group/GroupChatInfoView.kt | 132 ++- .../views/chat/group/GroupMemberInfoView.kt | 30 + .../views/chat/group/GroupMembersToolbar.kt | 2 +- .../common/views/chat/group/GroupMentions.kt | 20 + .../views/chat/group/GroupPreferences.kt | 26 + .../views/chat/group/GroupReportsView.kt | 4 +- .../views/chat/group/MemberAdmission.kt | 152 +++ .../views/chat/group/MemberSupportChatView.kt | 122 +++ .../views/chat/group/MemberSupportView.kt | 260 +++++ .../common/views/chat/item/ChatItemView.kt | 5 +- .../views/chatlist/ChatListNavLinkView.kt | 9 +- .../commonMain/resources/MR/base/strings.xml | 29 +- .../platform/PlatformTextField.desktop.kt | 3 + src/Simplex/Chat/Library/Commands.hs | 6 +- src/Simplex/Chat/Library/Internal.hs | 26 +- src/Simplex/Chat/Library/Subscriber.hs | 36 +- src/Simplex/Chat/Store/Groups.hs | 18 +- src/Simplex/Chat/Store/Messages.hs | 41 +- .../SQLite/Migrations/chat_query_plans.txt | 58 +- src/Simplex/Chat/Types.hs | 2 +- tests/ChatTests/Groups.hs | 5 + 34 files changed, 1844 insertions(+), 748 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 54e437afb1..e6038a3fd5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -55,6 +55,7 @@ actual fun PlatformTextField( textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, + userIsPending: Boolean, placeholder: String, showVoiceButton: Boolean, onMessageChange: (ComposeMessage) -> Unit, @@ -198,6 +199,8 @@ actual fun PlatformTextField( } if (composeState.value.preview is ComposePreview.VoicePreview) { ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) + } else if (userIsPending) { + ComposeOverlay(MR.strings.reviewed_by_moderators, textStyle, padding) } else if (userIsObserver) { ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 7e5eed3c42..76f984a396 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -294,7 +294,7 @@ object ChatModel { } } - class ChatsContext(val contentTag: MsgContentTag?) { + class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) /** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on. * If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages]. @@ -309,6 +309,20 @@ object ChatModel { fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } + val contentTag: MsgContentTag? = + when (secondaryContextFilter) { + null -> null + is SecondaryContextFilter.GroupChatScopeContext -> null + is SecondaryContextFilter.MsgContentTagContext -> secondaryContextFilter.contentTag + } + + val groupScopeInfo: GroupChatScopeInfo? = + when (secondaryContextFilter) { + null -> null + is SecondaryContextFilter.GroupChatScopeContext -> secondaryContextFilter.groupScopeInfo + is SecondaryContextFilter.MsgContentTagContext -> null + } + suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = 0) @@ -363,7 +377,7 @@ object ChatModel { updateContact(rhId, updatedContact) } - suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo)) + suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null)) // TODO [knocking] review private suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { if (hasChat(rhId, cInfo.id)) { @@ -415,55 +429,58 @@ object ChatModel { } suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { - // mark chat non deleted - if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { - val updatedContact = cInfo.contact.copy(chatDeleted = false) - updateContact(rhId, updatedContact) - } - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] - val newPreviewItem = when (cInfo) { - is ChatInfo.Group -> { - val currentPreviewItem = chat.chatItems.firstOrNull() - if (currentPreviewItem != null) { - if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { - cItem + // update chat list + if (cInfo.groupChatScope() == null) { + // mark chat non deleted + if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { + val updatedContact = cInfo.contact.copy(chatDeleted = false) + updateContact(rhId, updatedContact) + } + // update preview + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val newPreviewItem = when (cInfo) { + is ChatInfo.Group -> { + val currentPreviewItem = chat.chatItems.firstOrNull() + if (currentPreviewItem != null) { + if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { + cItem + } else { + currentPreviewItem + } } else { - currentPreviewItem + cItem } - } else { - cItem } - } - else -> cItem - } - val wasUnread = chat.unreadTag - chats[i] = chat.copy( - chatItems = arrayListOf(newPreviewItem), - chatStats = - if (cItem.meta.itemStatus is CIStatus.RcvNew) { - increaseUnreadCounter(rhId, currentUser.value!!) - chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) - } - else - chat.chatStats - ) - updateChatTagReadNoContentTag(chats[i], wasUnread) - if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chats[i], 0) + else -> cItem + } + val wasUnread = chat.unreadTag + chats[i] = chat.copy( + chatItems = arrayListOf(newPreviewItem), + chatStats = + if (cItem.meta.itemStatus is CIStatus.RcvNew) { + increaseUnreadCounter(rhId, currentUser.value!!) + chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) + } else + chat.chatStats + ) + updateChatTagReadInPrimaryContext(chats[i], wasUnread) + + if (appPlatform.isDesktop && cItem.chatDir.sent) { + reorderChat(chats[i], 0) + } else { + popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) + } } else { - popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } + // add to current scope withContext(Dispatchers.Main) { - // add to current chat - if (chatId.value == cInfo.id) { + if (chatItemBelongsToScope(cInfo, cItem)) { // Prevent situation when chat item already in the list received from backend if (chatItems.value.none { it.id == cItem.id }) { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { @@ -475,30 +492,47 @@ object ChatModel { } } } - - suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean { - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - val res: Boolean - if (i >= 0) { - chat = chats[i] - val pItem = chat.chatItems.lastOrNull() - if (pItem?.id == cItem.id) { - chats[i] = chat.copy(chatItems = arrayListOf(cItem)) - if (pItem.isRcvNew && !cItem.isRcvNew) { - // status changed from New to Read, update counter - decreaseCounterInChatNoContentTag(rhId, cInfo.id) + + private fun chatItemBelongsToScope(cInfo: ChatInfo, cItem: ChatItem): Boolean = + when (secondaryContextFilter) { + null -> + chatId.value == cInfo.id && cInfo.groupChatScope() == null + is SecondaryContextFilter.GroupChatScopeContext -> { + val cInfoScope = cInfo.groupChatScope() + if (cInfoScope != null) { + chatId.value == cInfo.id && sameChatScope(cInfoScope, secondaryContextFilter.groupScopeInfo.toChatScope()) + } else { + false } } - res = false - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) - res = true + is SecondaryContextFilter.MsgContentTagContext -> + chatId.value == cInfo.id && cItem.isReport } - return withContext(Dispatchers.Main) { - // update current chat - if (chatId.value == cInfo.id) { + + suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean { + var itemAdded = false + // update chat list + if (cInfo.groupChatScope() == null) { + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(cItem)) + if (pItem.isRcvNew && !cItem.isRcvNew) { + // status changed from New to Read, update counter + decreaseCounterInPrimaryContext(rhId, cInfo.id) + } + } + } else { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) + itemAdded = true + } + } + // update current scope + withContext(Dispatchers.Main) { + if (chatItemBelongsToScope(cInfo, cItem)) { if (cItem.isDeletedContent || cItem.meta.itemDeleted != null) { AudioPlayer.stop(cItem) } @@ -506,7 +540,6 @@ object ChatModel { val itemIndex = items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { items[itemIndex] = cItem - false } else { val status = chatItemStatuses.remove(cItem.id) val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { @@ -515,44 +548,52 @@ object ChatModel { cItem } addToChatItems(ci) - true + itemAdded = true } - } else { - res } } + return itemAdded } suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null, atIndex: Int? = null) { + // TODO [knocking] see diff: + // why chatItemStatuses gets updated even if chatId doesn't match? + // should chatItemBelongsToScope replace chatId check? + // should chatItemStatuses be updated only if chatItemBelongsToScope (as in changed code)? this seems to make more sense withContext(Dispatchers.Main) { - if (chatId.value == cInfo.id) { + if (chatItemBelongsToScope(cInfo, cItem)) { val items = chatItems.value val itemIndex = atIndex ?: items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { items[itemIndex] = cItem } - } else if (status != null) { - chatItemStatuses[cItem.id] = status + if (status != null) { + chatItemStatuses[cItem.id] = status + } } } } + // TODO [knocking] why does this function not use `withContext(Dispatchers.Main) { ... }` ? fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { - if (cItem.isRcvNew) { - decreaseCounterInChatNoContentTag(rhId, cInfo.id) - } - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] - val pItem = chat.chatItems.lastOrNull() - if (pItem?.id == cItem.id) { - chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy)) + // update chat list + if (cInfo.groupChatScope() == null) { + if (cItem.isRcvNew) { + decreaseCounterInPrimaryContext(rhId, cInfo.id) + } + // update preview + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy)) + } } } - // remove from current chat - if (chatId.value == cInfo.id) { + // remove from current scope + if (chatItemBelongsToScope(cInfo, cItem)) { chatItems.removeAllAndNotify { // We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted // (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id) @@ -585,7 +626,7 @@ object ChatModel { Log.d(TAG, "exiting removeMemberItems") return } - val cInfo = ChatInfo.Group(groupInfo) + val cInfo = ChatInfo.Group(groupInfo, groupChatScope = null) // TODO [knocking] review if (chatId.value == groupInfo.id) { for (i in 0 until chatItems.value.size) { val updatedItem = removedUpdatedItem(chatItems.value[i]) @@ -687,7 +728,7 @@ object ChatModel { chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions) ) - updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) + updateChatTagReadInPrimaryContext(chats[chatIdx], wasUnread) } } } @@ -728,9 +769,9 @@ object ChatModel { return markedRead to mentionsMarkedRead } - private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { + private fun decreaseCounterInPrimaryContext(rhId: Long?, chatId: ChatId) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return @@ -744,7 +785,7 @@ object ChatModel { unreadCount = unreadCount, ) ) - updateChatTagReadNoContentTag(chats[chatIndex], wasUnread) + updateChatTagReadInPrimaryContext(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { @@ -813,16 +854,16 @@ object ChatModel { } fun increaseUnreadCounter(rhId: Long?, user: UserLike) { - changeUnreadCounterNoContentTag(rhId, user, 1) + changeUnreadCounterInPrimaryContext(rhId, user, 1) } fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { - changeUnreadCounterNoContentTag(rhId, user, -by) + changeUnreadCounterInPrimaryContext(rhId, user, -by) } - private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) { + private fun changeUnreadCounterInPrimaryContext(rhId: Long?, user: UserLike, by: Int) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } if (i != -1) { @@ -830,9 +871,9 @@ object ChatModel { } } - fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) { + fun updateChatTagReadInPrimaryContext(chat: Chat, wasUnread: Boolean) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return val tags = chat.chatInfo.chatTags ?: return val nowUnread = chat.unreadTag @@ -842,21 +883,21 @@ object ChatModel { unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 } } else if (!nowUnread && wasUnread) { - markChatTagReadNoContentTag_(chat, tags) + markChatTagReadInPrimaryContext_(chat, tags) } } fun markChatTagRead(chat: Chat) { if (chat.unreadTag) { chat.chatInfo.chatTags?.let { tags -> - markChatTagReadNoContentTag_(chat, tags) + markChatTagReadInPrimaryContext_(chat, tags) } } } - private fun markChatTagReadNoContentTag_(chat: Chat, tags: List) { + private fun markChatTagReadInPrimaryContext_(chat: Chat, tags: List) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future - if (contentTag != null) return + if (secondaryContextFilter != null) return for (tag in tags) { val count = unreadTags[tag] @@ -888,12 +929,12 @@ object ChatModel { val wasReportsCount = chat.chatStats.reportsCount val nowReportsCount = chats[i].chatStats.reportsCount val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0 - changeGroupReportsTagNoContentTag(by) + changeGroupReportsTagInPrimaryContext(by) } } - private fun changeGroupReportsTagNoContentTag(by: Int = 0) { - if (by == 0 || contentTag != null) return + private fun changeGroupReportsTagInPrimaryContext(by: Int = 0) { + if (by == 0 || secondaryContextFilter != null) return presetTags[PresetTagKind.GROUP_REPORTS] = kotlin.math.max(0, (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by) clearActiveChatFilterIfNeeded() } @@ -1098,6 +1139,28 @@ enum class ChatType(val type: String) { ContactConnection(":"); } +sealed class GroupChatScope { + class MemberSupport(val groupMemberId_: Long?): GroupChatScope() +} + +fun sameChatScope(scope1: GroupChatScope, scope2: GroupChatScope) = + scope1 is GroupChatScope.MemberSupport + && scope2 is GroupChatScope.MemberSupport + && scope1.groupMemberId_ == scope2.groupMemberId_ + +@Serializable +sealed class GroupChatScopeInfo { + @Serializable @SerialName("memberSupport") data class MemberSupport(val groupMember_: GroupMember?) : GroupChatScopeInfo() + + fun toChatScope(): GroupChatScope = + when (this) { + is MemberSupport -> when (groupMember_) { + null -> GroupChatScope.MemberSupport(groupMemberId_ = null) + else -> GroupChatScope.MemberSupport(groupMemberId_ = groupMember_.groupMemberId) + } + } +} + @Serializable data class User( val remoteHostId: Long?, @@ -1235,6 +1298,11 @@ data class Chat( else -> false } + val userIsPending: Boolean get() = when(chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.membership.memberPending + else -> false + } + val unreadTag: Boolean get() = when (chatInfo.chatSettings?.enableNtfs) { All -> chatStats.unreadChat || chatStats.unreadCount > 0 Mentions -> chatStats.unreadChat || chatStats.unreadMentions > 0 @@ -1299,7 +1367,7 @@ sealed class ChatInfo: SomeChat, NamedChat { } @Serializable @SerialName("group") - data class Group(val groupInfo: GroupInfo): ChatInfo() { + data class Group(val groupInfo: GroupInfo, val groupChatScope: GroupChatScopeInfo?): ChatInfo() { override val chatType get() = ChatType.Group override val localDisplayName get() = groupInfo.localDisplayName override val id get() = groupInfo.id @@ -1318,7 +1386,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val localAlias get() = groupInfo.localAlias companion object { - val sampleData = Group(GroupInfo.sampleData) + val sampleData = Group(GroupInfo.sampleData, groupChatScope = null) } } @@ -1419,6 +1487,11 @@ sealed class ChatInfo: SomeChat, NamedChat { } } + fun groupChatScope(): GroupChatScope? = when (this) { + is Group -> groupChatScope?.toChatScope() + else -> null + } + fun ntfsEnabled(ci: ChatItem): Boolean = ntfsEnabled(ci.meta.userMention) @@ -1449,7 +1522,9 @@ sealed class ChatInfo: SomeChat, NamedChat { val userCanSend: Boolean get() = when (this) { is ChatInfo.Direct -> true - is ChatInfo.Group -> groupInfo.membership.memberRole >= GroupMemberRole.Member + is ChatInfo.Group -> + (groupInfo.membership.memberRole >= GroupMemberRole.Member && !groupInfo.membership.memberPending) + || groupChatScope != null is ChatInfo.Local -> true else -> false } @@ -1834,7 +1909,8 @@ data class GroupProfile ( val description: String? = null, override val image: String? = null, override val localAlias: String = "", - val groupPreferences: GroupPreferences? = null + val groupPreferences: GroupPreferences? = null, + val memberAdmission: GroupMemberAdmission? = null ): NamedChat { companion object { val sampleData = GroupProfile( @@ -1844,6 +1920,27 @@ data class GroupProfile ( } } +@Serializable +data class GroupMemberAdmission( + val review: MemberCriteria? = null, +) { + companion object { + val sampleData = GroupMemberAdmission( + review = null, + ) + } +} + +@Serializable +enum class MemberCriteria { + @SerialName("all") All; + + val text: String + get() = when(this) { + MemberCriteria.All -> generalGetString(MR.strings.member_criteria_all) + } +} + @Serializable data class BusinessChatInfo ( val chatType: BusinessChatType, @@ -1872,7 +1969,8 @@ data class GroupMember ( val memberProfile: LocalProfile, val memberContactId: Long? = null, val memberContactProfileId: Long, - var activeConn: Connection? = null + var activeConn: Connection? = null, + val supportChat: GroupSupportChat? = null ): NamedChat { val id: String get() = "#$groupId @$groupMemberId" val ready get() = activeConn?.connStatus == ConnStatus.Ready @@ -1931,6 +2029,7 @@ data class GroupMember ( GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false GroupMemberStatus.MemPendingApproval -> true + GroupMemberStatus.MemPendingReview -> true GroupMemberStatus.MemIntroduced -> false GroupMemberStatus.MemIntroInvited -> false GroupMemberStatus.MemAccepted -> false @@ -1948,6 +2047,7 @@ data class GroupMember ( GroupMemberStatus.MemUnknown -> false GroupMemberStatus.MemInvited -> false GroupMemberStatus.MemPendingApproval -> false + GroupMemberStatus.MemPendingReview -> false GroupMemberStatus.MemIntroduced -> true GroupMemberStatus.MemIntroInvited -> true GroupMemberStatus.MemAccepted -> true @@ -1957,6 +2057,12 @@ data class GroupMember ( GroupMemberStatus.MemCreator -> true } + val memberPending: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemPendingApproval -> true + GroupMemberStatus.MemPendingReview -> true + else -> false + } + fun canBeRemoved(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft @@ -1997,6 +2103,14 @@ data class GroupMember ( } } +@Serializable +class GroupSupportChat ( + val chatTs: Instant, + val unread: Int, + val memberAttention: Int, + val mentions: Int +) + @Serializable data class GroupMemberSettings(val showMessages: Boolean) {} @@ -2053,6 +2167,7 @@ enum class GroupMemberStatus { @SerialName("unknown") MemUnknown, @SerialName("invited") MemInvited, @SerialName("pending_approval") MemPendingApproval, + @SerialName("pending_review") MemPendingReview, @SerialName("introduced") MemIntroduced, @SerialName("intro-inv") MemIntroInvited, @SerialName("accepted") MemAccepted, @@ -2069,6 +2184,7 @@ enum class GroupMemberStatus { MemUnknown -> generalGetString(MR.strings.group_member_status_unknown) MemInvited -> generalGetString(MR.strings.group_member_status_invited) MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval) + MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review) MemIntroduced -> generalGetString(MR.strings.group_member_status_introduced) MemIntroInvited -> generalGetString(MR.strings.group_member_status_intro_invitation) MemAccepted -> generalGetString(MR.strings.group_member_status_accepted) @@ -2086,6 +2202,7 @@ enum class GroupMemberStatus { MemUnknown -> generalGetString(MR.strings.group_member_status_unknown_short) MemInvited -> generalGetString(MR.strings.group_member_status_invited) MemPendingApproval -> generalGetString(MR.strings.group_member_status_pending_approval_short) + MemPendingReview -> generalGetString(MR.strings.group_member_status_pending_review_short) MemIntroduced -> generalGetString(MR.strings.group_member_status_connecting) MemIntroInvited -> generalGetString(MR.strings.group_member_status_connecting) MemAccepted -> generalGetString(MR.strings.group_member_status_connecting) @@ -2508,6 +2625,8 @@ data class ChatItem ( is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberConnected -> false + is RcvGroupEvent.MemberAccepted -> false + is RcvGroupEvent.UserAccepted -> false is RcvGroupEvent.MemberLeft -> false is RcvGroupEvent.MemberRole -> false is RcvGroupEvent.MemberBlocked -> false @@ -2700,6 +2819,11 @@ data class ChatItem ( } } +sealed class SecondaryContextFilter { + class GroupChatScopeContext(val groupScopeInfo: GroupChatScopeInfo): SecondaryContextFilter() + class MsgContentTagContext(val contentTag: MsgContentTag): SecondaryContextFilter() +} + fun MutableState>.add(index: Int, elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(index, elem) } } @@ -4133,6 +4257,8 @@ sealed class RcvDirectEvent() { sealed class RcvGroupEvent() { @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() @Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent() + @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() + @Serializable @SerialName("userAccepted") class UserAccepted(): RcvGroupEvent() @Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent() @Serializable @SerialName("memberRole") class MemberRole(val groupMemberId: Long, val profile: Profile, val role: GroupMemberRole): RcvGroupEvent() @Serializable @SerialName("memberBlocked") class MemberBlocked(val groupMemberId: Long, val profile: Profile, val blocked: Boolean): RcvGroupEvent() @@ -4148,6 +4274,8 @@ sealed class RcvGroupEvent() { val text: String get() = when (this) { is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName) is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected) + is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName) + is UserAccepted -> generalGetString(MR.strings.rcv_group_event_user_accepted) is MemberLeft -> generalGetString(MR.strings.rcv_group_event_member_left) is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text) is MemberBlocked -> if (blocked) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index a557cf93cf..90f8259fb8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -903,8 +903,8 @@ object ChatController { return null } - suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair? { - val r = sendCmd(rh, CC.ApiGetChat(type, id, contentTag, pagination, search)) + suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair? { + val r = sendCmd(rh, CC.ApiGetChat(type, id, scope, contentTag, pagination, search)) if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) { @@ -937,8 +937,8 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) } @@ -996,8 +996,8 @@ object ChatController { } } - suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? { - return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) { + suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long): ChatItemInfo? { + return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, scope, itemId))) { is CR.ApiChatItemInfo -> r.chatItemInfo else -> { apiErrorAlert("apiGetChatItemInfo", generalGetString(MR.strings.error_loading_details), r) @@ -1006,13 +1006,13 @@ object ChatController { } } - suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemIds: List, ttl: Int?): List? { - val cmd = CC.ApiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl) + suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { + val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl) return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } - suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, chatItemIds: List): CR.ForwardPlan? { - return when (val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, chatItemIds))) { + suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, chatItemIds: List): CR.ForwardPlan? { + return when (val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, fromScope, chatItemIds))) { is CR.ForwardPlan -> r else -> { apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r) @@ -1021,8 +1021,8 @@ object ChatController { } } - suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? { - val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, updatedMessage, live)) + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long, updatedMessage: UpdatedMessage, live: Boolean = false): AChatItem? { + val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, scope, itemId, updatedMessage, live)) when { r is CR.ChatItemUpdated -> return r.chatItem r is CR.ChatItemNotChanged -> return r.chatItem @@ -1044,8 +1044,8 @@ object ChatController { return null } - suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { - val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, itemId, add, reaction)) + suspend fun apiChatItemReaction(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemId: Long, add: Boolean, reaction: MsgReaction): ChatItem? { + val r = sendCmd(rh, CC.ApiChatItemReaction(type, id, scope, itemId, add, reaction)) if (r is CR.ChatItemReaction) return r.reaction.chatReaction.chatItem Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null @@ -1059,8 +1059,8 @@ object ChatController { return null } - suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, itemIds: List, mode: CIDeleteMode): List? { - val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, itemIds, mode)) + suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List, mode: CIDeleteMode): List? { + val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode)) if (r is CR.ChatItemsDeleted) return r.chatItemDeletions Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") return null @@ -1761,15 +1761,15 @@ object ChatController { return null } - suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { - val r = sendCmd(rh, CC.ApiChatRead(type, id)) + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } - suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List): Boolean { - val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds)) + suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): Boolean { + val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds)) if (r is CR.CmdOk) return true Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}") return false @@ -2007,6 +2007,19 @@ object ChatController { } } + suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): GroupMember? { + val r = sendCmd(rh, CC.ApiAcceptMember(groupId, groupMemberId, memberRole)) + return when (r) { + is CR.MemberAccepted -> r.member + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiAcceptMember", generalGetString(MR.strings.error_accepting_member), r) + } + null + } + } + } + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): List? = when (val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages))) { is CR.UserDeletedMembers -> r.members @@ -2543,14 +2556,13 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.addChatItem(rhId, cInfo, cItem) + // TODO [knocking] increase support chats unread count; move `isActiveReport` checks inside model functions? if (cItem.isActiveReport) { chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } } withContext(Dispatchers.Main) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) - } + chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) } } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { withContext(Dispatchers.Main) { @@ -2579,9 +2591,7 @@ object ChatController { chatModel.chatsContext.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } withContext(Dispatchers.Main) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) - } + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } } } @@ -2593,9 +2603,7 @@ object ChatController { chatModel.chatsContext.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } withContext(Dispatchers.Main) { - if (r.reaction.chatReaction.chatItem.isReport) { - chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) - } + chatModel.secondaryChatsContext.value?.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } } } @@ -2638,12 +2646,10 @@ object ChatController { } } withContext(Dispatchers.Main) { - if (cItem.isReport) { - if (toChatItem == null) { - chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem) - } else { - chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem) - } + if (toChatItem == null) { + chatModel.secondaryChatsContext.value?.removeChatItem(rhId, cInfo, cItem) + } else { + chatModel.secondaryChatsContext.value?.upsertChatItem(rhId, cInfo, toChatItem.chatItem) } } } @@ -2703,6 +2709,13 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } + is CR.MemberAcceptedByOther -> + if (active(r.user)) { + // TODO [knocking] update secondary context? + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + } + } is CR.DeletedMemberUser -> // TODO update user member if (active(r.user)) { withContext(Dispatchers.Main) { @@ -2783,6 +2796,16 @@ object ChatController { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } + if ( + chatModel.chatId.value == r.groupInfo.id + && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) + && chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + ) { + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value = null + } + ModalManager.end.closeModals() + } } is CR.JoinedGroupMember -> if (active(r.user)) { @@ -3143,11 +3166,11 @@ object ChatController { if (activeUser(rh, user)) { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem - withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } withContext(Dispatchers.Main) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) - } + chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) + } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) } } } @@ -3159,7 +3182,7 @@ object ChatController { chatModel.users.addAll(users) return } - val cInfo = ChatInfo.Group(r.groupInfo) + val cInfo = ChatInfo.Group(r.groupInfo, groupChatScope = null) // TODO [knocking] get scope from items? withContext(Dispatchers.Main) { val chatsCtx = chatModel.chatsContext r.chatItemIDs.forEach { itemId -> @@ -3214,11 +3237,11 @@ object ChatController { if (!activeUser(rh, user)) { notify() } else { - val createdChat = withContext(Dispatchers.Main) { chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) } + val createdChat = withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertChatItem(rh, cInfo, cItem) + } withContext(Dispatchers.Main) { - if (cItem.content.msgContent is MsgContent.MCReport) { - chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) - } + chatModel.secondaryChatsContext.value?.upsertChatItem(rh, cInfo, cItem) } if (createdChat) { notify() @@ -3423,9 +3446,9 @@ sealed class CC { class ApiGetSettings(val settings: AppSettings): CC() class ApiGetChatTags(val userId: Long): CC() class ApiGetChats(val userId: Long): CC() - class ApiGetChat(val type: ChatType, val id: Long, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() - class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() + class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3433,18 +3456,19 @@ sealed class CC { class ApiReorderChatTags(val tagIds: List): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List): CC() class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC() - class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC() - class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() + class ApiUpdateChatItem(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val updatedMessage: UpdatedMessage, val live: Boolean): CC() + class ApiDeleteChatItem(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() class ApiArchiveReceivedReports(val groupId: Long): CC() class ApiDeleteReceivedReports(val groupId: Long, val itemIds: List, val mode: CIDeleteMode): CC() - class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() + class ApiChatItemReaction(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() - class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val chatItemIds: List): CC() - class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List, val ttl: Int?): CC() + class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List): CC() + class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() + class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() class ApiMembersRole(val groupId: Long, val memberIds: List, val memberRole: GroupMemberRole): CC() class ApiBlockMembersForAll(val groupId: Long, val memberIds: List, val blocked: Boolean): CC() class ApiRemoveMembers(val groupId: Long, val memberIds: List, val withMessages: Boolean): CC() @@ -3522,8 +3546,8 @@ sealed class CC { class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() - class ApiChatRead(val type: ChatType, val id: Long): CC() - class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List): CC() + class ApiChatRead(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() + class ApiChatItemsRead(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemIds: List): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() @@ -3595,16 +3619,16 @@ sealed class CC { } else { " content=${contentTag.name.lowercase()}" } - "/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + "/_get chat ${chatRef(type, id, scope)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") } - is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" + is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id, scope)} $itemId" is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)} live=${onOff(live)} ttl=${ttlStr} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" - is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}" + is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" is ApiDeleteChatTag -> "/_delete tag $tagId" is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}" is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}" @@ -3613,23 +3637,24 @@ sealed class CC { "/_create *$noteFolderId json $msgs" } is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText" - is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}" - is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" + is ApiUpdateChatItem -> "/_update item ${chatRef(type, id, scope)} $itemId live=${onOff(live)} ${updatedMessage.cmdString}" + is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id, scope)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" is ApiArchiveReceivedReports -> "/_archive reports #$groupId" is ApiDeleteReceivedReports -> "/_delete reports #$groupId ${itemIds.joinToString(",")} ${mode.deleteMode}" - is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" + is ApiChatItemReaction -> "/_reaction ${chatRef(type, id, scope)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { val ttlStr = if (ttl != null) "$ttl" else "default" - "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}" + "/_forward ${chatRef(toChatType, toChatId, toScope)} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" } is ApiPlanForwardChatItems -> { - "/_forward plan ${chatRef(fromChatType, fromChatId)} ${chatItemIds.joinToString(",")}" + "/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}" } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" + is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}" @@ -3653,13 +3678,13 @@ sealed class CC { is ApiAcceptConditions -> "/_accept_conditions ${conditionsId} ${operatorIds.joinToString(",")}" is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" is APIGetChatItemTTL -> "/_ttl $userId" - is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id)} ${chatItemTTLStr(seconds)}" + is APISetChatTTL -> "/_ttl $userId ${chatRef(chatType, id, scope = null)} ${chatItemTTLStr(seconds)}" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}" is ReconnectServer -> "/reconnect $userId $server" is ReconnectAllServers -> "/reconnect" - is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" + is APISetChatSettings -> "/_settings ${chatRef(type, id, scope = null)} ${json.encodeToString(chatSettings)}" is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" @@ -3681,8 +3706,8 @@ sealed class CC { is APIConnectPlan -> "/_connect plan $userId $connLink" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} ${connLink.connFullLink} ${connLink.connShortLink ?: ""}" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" - is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}" - is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" + is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}" + is ApiClearChat -> "/_clear chat ${chatRef(type, id, scope = null)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" @@ -3707,9 +3732,9 @@ sealed class CC { is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" is ApiGetNetworkStatuses -> "/_network_statuses" - is ApiChatRead -> "/_read chat ${chatRef(type, id)}" - is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}" - is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" + is ApiChatRead -> "/_read chat ${chatRef(type, id, scope)}" + is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id, scope)} ${itemIds.joinToString(",")}" + is ApiChatUnread -> "/_unread chat ${chatRef(type, id, scope = null)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId" + (" approved_relays=${onOff(userApprovedRelays)}") + @@ -3793,6 +3818,7 @@ sealed class CC { is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" + is ApiAcceptMember -> "apiAcceptMember" is ApiMembersRole -> "apiMembersRole" is ApiBlockMembersForAll -> "apiBlockMembersForAll" is ApiRemoveMembers -> "apiRemoveMembers" @@ -3929,7 +3955,13 @@ sealed class CC { private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd) companion object { - fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}" + fun chatRef(chatType: ChatType, id: Long, scope: GroupChatScope?) = when (scope) { + null -> "${chatType.type}${id}" + is GroupChatScope.MemberSupport -> when (scope.groupMemberId_) { + null -> "${chatType.type}${id}(_support)" + else -> "${chatType.type}${id}(_support:${scope.groupMemberId_})" + } + } } } @@ -5846,6 +5878,8 @@ sealed class CR { @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() + @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() @Serializable @SerialName("membersRoleUser") class MembersRoleUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val toRole: GroupMemberRole): CR() @Serializable @SerialName("memberBlockedForAll") class MemberBlockedForAll(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val blocked: Boolean): CR() @@ -6031,6 +6065,8 @@ sealed class CR { is ReceivedGroupInvitation -> "receivedGroupInvitation" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" + is MemberAccepted -> "memberAccepted" + is MemberAcceptedByOther -> "memberAcceptedByOther" is MemberRole -> "memberRole" is MembersRoleUser -> "membersRoleUser" is MemberBlockedForAll -> "memberBlockedForAll" @@ -6209,6 +6245,8 @@ sealed class CR { is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") + is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") is MembersRoleUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\ntoRole: $toRole") is MemberBlockedForAll -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nblocked: $blocked") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 1daf5a7ba7..3502d7049c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -16,6 +16,7 @@ expect fun PlatformTextField( textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, + userIsPending: Boolean, placeholder: String, showVoiceButton: Boolean, onMessageChange: (ComposeMessage) -> Unit, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 22b013ff60..66347c5ba8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -104,6 +104,7 @@ fun TerminalLayout( needToAllowVoiceToContact = false, allowedVoiceByPrefs = false, userIsObserver = false, + userIsPending = false, userCanSend = true, allowVoiceToContact = {}, placeholder = "", diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 2a77d0a6dc..a8b77e8fdd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -40,9 +40,8 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* -import chat.simplex.common.views.chat.group.ChatTTLSection +import chat.simplex.common.views.chat.group.ChatTTLOption import chat.simplex.common.views.chatlist.updateChatSettings -import chat.simplex.common.views.database.* import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -617,7 +616,10 @@ fun ChatInfoLayout( } SectionDividerSpaced(maxBottomPadding = false) - ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) + SectionView { + ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + } SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) val conn = contact.activeConn @@ -1384,7 +1386,7 @@ private fun setChatTTL( private suspend fun afterSetChatTTL(chatsCtx: ChatModel.ChatsContext, rhId: Long?, chatInfo: ChatInfo, progressIndicator: MutableState) { try { val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT) - val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, null, pagination) ?: return + val (chat, navInfo) = controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, scope = null, contentTag = null, pagination) ?: return if (chat.chatItems.isEmpty()) { // replacing old chat with the same old chat but without items. Less intrusive way of clearing a preview withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index eabe9cb60a..f6f5b6fcb7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -17,7 +17,7 @@ suspend fun apiLoadSingleMessage( apiId: Long, itemId: Long ): ChatItem? = coroutineScope { - val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null + val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null chat.chatItems.firstOrNull() } @@ -31,7 +31,7 @@ suspend fun apiLoadMessages( openAroundItemId: Long? = null, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.contentTag, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, pagination, search) ?: return@coroutineScope // For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes /** When [openAroundItemId] is provided, chatId can be different too */ if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null) @@ -54,7 +54,7 @@ suspend fun processLoadedChat( when (pagination) { is ChatPagination.Initial -> { val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList() - if (chatsCtx.contentTag == null) { + if (chatsCtx.secondaryContextFilter == null) { // update main chats, not content tagged withContext(Dispatchers.Main) { val oldChat = chatModel.chatsContext.getChat(chat.id) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index ef82b9a35b..d3dad36d62 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -65,7 +65,17 @@ fun ChatView( val showSearch = rememberSaveable { mutableStateOf(false) } // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } - val activeChatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo } } + val activeChatInfo = remember { derivedStateOf { + var chatInfo = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo + if ( + chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + && chatInfo is ChatInfo.Group + ) { + val scopeInfo = chatsCtx.secondaryContextFilter.groupScopeInfo + chatInfo = chatInfo.copy(groupChatScope = scopeInfo) + } + chatInfo + } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value if (chatInfo == null || user == null) { @@ -99,7 +109,7 @@ fun ChatView( .distinctUntilChanged() .filterNotNull() .collect { chatId -> - if (chatsCtx.contentTag == null) { + if (chatsCtx.secondaryContextFilter == null) { markUnreadChatAsRead(chatId) } showSearch.value = false @@ -108,6 +118,18 @@ fun ChatView( } } } + if (chatsCtx.secondaryContextFilter == null && chatInfo is ChatInfo.Group && chatInfo.groupInfo.membership.memberPending) { + LaunchedEffect(Unit) { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) + val supportChatInfo = ChatInfo.Group(chatInfo.groupInfo, groupChatScope = scopeInfo) + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = mutableStateOf(null), + supportChatInfo, + scopeInfo + ) + } + } val view = LocalMultiplatformView() val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button @@ -133,7 +155,7 @@ fun ChatView( val sameText = searchText.value == value // showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it // (required on Android to have this check to prevent call to search with old text) - val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.contentTag == null + val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null val c = chatModel.getChat(chatInfo.id) if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { @@ -167,7 +189,7 @@ fun ChatView( ) } ComposeView( - chatModel, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, + rhId = remoteHostId.value, chatModel, chatsCtx, Chat(remoteHostId = chatRh, chatInfo = chatInfo, chatItems = emptyList()), composeState, attachmentOption, showChooseAttachment = { scope.launch { attachmentBottomSheetState.show() } }, focusRequester = focusRequester ) @@ -220,6 +242,7 @@ fun ChatView( rh = chatRh, fromChatType = chatInfo.chatType, fromChatId = chatInfo.apiId, + fromScope = chatInfo.groupChatScope(), chatItemIds = chatItemIds ) @@ -343,7 +366,7 @@ fun ChatView( setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - if (chatsCtx.contentTag == null) { + if (chatsCtx.secondaryContextFilter == null) { ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(true) { close -> @@ -379,6 +402,7 @@ fun ChatView( chatRh, type = chatInfo.chatType, id = chatInfo.apiId, + scope = chatInfo.groupChatScope(), itemIds = listOf(itemId), mode = mode ) @@ -399,12 +423,10 @@ fun ChatView( } } withContext(Dispatchers.Main) { - if (deletedChatItem.isReport) { - if (toChatItem != null) { - chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) - } else { - chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, deletedChatItem) - } + if (toChatItem != null) { + chatModel.secondaryChatsContext.value?.upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + chatModel.secondaryChatsContext.value?.removeChatItem(chatRh, chatInfo, deletedChatItem) } } } @@ -512,6 +534,7 @@ fun ChatView( rh = chatRh, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), itemId = cItem.id, add = add, reaction = reaction @@ -521,16 +544,14 @@ fun ChatView( chatModel.chatsContext.updateChatItem(cInfo, updatedCI) } withContext(Dispatchers.Main) { - if (cItem.isReport) { - chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, updatedCI) - } + chatModel.secondaryChatsContext.value?.updateChatItem(cInfo, updatedCI) } } } }, showItemDetails = { cInfo, cItem -> suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope { - val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) + val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cInfo.groupChatScope(), cItem.id) if (ciInfo != null) { if (chatInfo is ChatInfo.Group) { setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) @@ -583,6 +604,7 @@ fun ChatView( chatRh, chatInfo.chatType, chatInfo.apiId, + chatInfo.groupChatScope(), itemsIds ) } @@ -599,7 +621,8 @@ fun ChatView( chatModel.controller.apiChatRead( chatRh, chatInfo.chatType, - chatInfo.apiId + chatInfo.apiId, + chatInfo.groupChatScope() ) } withContext(Dispatchers.Main) { @@ -745,7 +768,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.contentTag == null)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.secondaryContextFilter == null)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -773,6 +796,7 @@ fun ChatLayout( .padding(bottom = composeViewHeight.value) ) { GroupMentions( + chatsCtx = chatsCtx, rhId = remoteHostId, composeState = composeState, composeViewFocusRequester = composeViewFocusRequester, @@ -782,7 +806,7 @@ fun ChatLayout( } } } - if (chatsCtx.contentTag == MsgContentTag.Report) { + if (chatsCtx.contentTag == MsgContentTag.Report) { // TODO [knocking] similar bar for support chats, without archiveItems? Column( Modifier .layoutId(CHAT_COMPOSE_LAYOUT_ID) @@ -833,7 +857,7 @@ fun ChatLayout( } val reportsCount = reportsCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (chatsCtx.contentTag == null && reportsCount > 0) { + if (chatsCtx.secondaryContextFilter == null && reportsCount > 0) { // TODO [knocking] support chats unread count toolbar ReportedCountToolbar(reportsCount, withStatusBar = true, showGroupReports) } else { StatusBarBackground() @@ -841,32 +865,58 @@ fun ChatLayout( } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - if (chatsCtx.contentTag == MsgContentTag.Report) { - if (oneHandUI.value) { - StatusBarBackground() - } - Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - Box { - if (selectedChatItems.value == null) { - GroupReportsAppBar(chatsCtx, { ModalManager.end.closeModal() }, onSearchValueChanged) - } else { - SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) - } - } - } - } else { - Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - Box { - if (selectedChatItems.value == null) { - if (chatInfo != null) { - ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + when (chatsCtx.secondaryContextFilter) { + is SecondaryContextFilter.GroupChatScopeContext -> { + when (chatsCtx.secondaryContextFilter.groupScopeInfo) { + is GroupChatScopeInfo.MemberSupport -> { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + MemberSupportChatAppBar(chatsCtx, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) + } + } } - } else { - SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if (chatsCtx.contentTag == null && reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { - ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) + } + is SecondaryContextFilter.MsgContentTagContext -> { + when (chatsCtx.secondaryContextFilter.contentTag) { + MsgContentTag.Report -> { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + GroupReportsAppBar(chatsCtx, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) + } + } + } + } + else -> TODO() + } + } + null -> { + Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + if (chatInfo != null) { + ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + } + } else { + SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) + } + } + if (reportsCount > 0 && (!oneHandUI.value || !chatBottomBar.value)) { + ReportedCountToolbar(reportsCount, withStatusBar = false, showGroupReports) + } } } } @@ -900,7 +950,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid && chatsCtx.contentTag == null) { + if (appPlatform.isAndroid && chatsCtx.secondaryContextFilter == null) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -1198,298 +1248,282 @@ fun BoxScope.ChatItemsList( val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state - if (chatsCtx != null) { - val mergedItems = remember { - derivedStateOf { - MergedItems.create(chatsCtx.chatItems.value.asReversed(), unreadCount, revealedItems.value, chatsCtx.chatState) - } + val mergedItems = remember { + derivedStateOf { + MergedItems.create(chatsCtx.chatItems.value.asReversed(), unreadCount, revealedItems.value, chatsCtx.chatState) } - val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } - val reportsCount = reportsCount(chatInfo.id) - val topPaddingToContent = topPaddingToContent( - chatView = chatsCtx.contentTag == null, - additionalTopBar = chatsCtx.contentTag == null && reportsCount > 0 - ) - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) - val numberOfBottomAppBars = numberOfBottomAppBars() + } + val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } + val reportsCount = reportsCount(chatInfo.id) + val topPaddingToContent = topPaddingToContent( + chatView = chatsCtx.secondaryContextFilter == null, + additionalTopBar = chatsCtx.secondaryContextFilter == null && reportsCount > 0 // TODO [knocking] && support chats unread count > 0 ? + ) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) + val numberOfBottomAppBars = numberOfBottomAppBars() - /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of - * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears - * */ - val maxHeightForList = rememberUpdatedState( - with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } - ) - val resetListState = remember { mutableStateOf(false) } - remember(chatModel.openAroundItemId.value) { - if (chatModel.openAroundItemId.value != null) { - closeSearch() - resetListState.value = !resetListState.value - } + /** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of + * [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears + * */ + val maxHeightForList = rememberUpdatedState( + with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } + ) + val resetListState = remember { mutableStateOf(false) } + remember(chatModel.openAroundItemId.value) { + if (chatModel.openAroundItemId.value != null) { + closeSearch() + resetListState.value = !resetListState.value } - val highlightedItems = remember { mutableStateOf(setOf()) } - val hoveredItemId = remember { mutableStateOf(null as Long?) } - val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { - val openAroundItemId = chatModel.openAroundItemId.value - val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } - val reportsState = reportsListState - if (openAroundItemId != null) { - highlightedItems.value += openAroundItemId - chatModel.openAroundItemId.value = null - } - hoveredItemId.value = null - if (reportsState != null) { - reportsListState = null - reportsState - } else if (index <= 0 || !searchValueIsEmpty.value) { - LazyListState(0, 0) - } else { - LazyListState(index + 1, -maxHeightForList.value) - } - }) - SaveReportsStateOnDispose(chatsCtx, listState) - val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } - val loadingMoreItems = remember { mutableStateOf(false) } - val animatedScrollingInProgress = remember { mutableStateOf(false) } - val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } - LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { - if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) - ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) + } + val highlightedItems = remember { mutableStateOf(setOf()) } + val hoveredItemId = remember { mutableStateOf(null as Long?) } + val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { + val openAroundItemId = chatModel.openAroundItemId.value + val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } + val reportsState = reportsListState + if (openAroundItemId != null) { + highlightedItems.value += openAroundItemId + chatModel.openAroundItemId.value = null } - PreloadItems(chatsCtx, chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> - if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false - loadingMoreItems.value = true - withContext(NonCancellable) { - try { - loadMessages(chatId, pagination) { - visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) - } - } finally { - loadingMoreItems.value = false + hoveredItemId.value = null + if (reportsState != null) { + reportsListState = null + reportsState + } else if (index <= 0 || !searchValueIsEmpty.value) { + LazyListState(0, 0) + } else { + LazyListState(index + 1, -maxHeightForList.value) + } + }) + SaveReportsStateOnDispose(chatsCtx, listState) + val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } + val loadingMoreItems = remember { mutableStateOf(false) } + val animatedScrollingInProgress = remember { mutableStateOf(false) } + val ignoreLoadingRequests = remember(remoteHostId) { mutableSetOf() } + LaunchedEffect(chatInfo.id, searchValueIsEmpty.value) { + if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) + ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) + } + PreloadItems(chatsCtx, chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false + loadingMoreItems.value = true + withContext(NonCancellable) { + try { + loadMessages(chatId, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } + } finally { + loadingMoreItems.value = false } - true } - val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) - val chatInfoUpdated = rememberUpdatedState(chatInfo) - val scope = rememberCoroutineScope() - val scrollToItem: (Long) -> Unit = remember { - // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling - if (chatsCtx.contentTag == MsgContentTag.Report) return@remember { scrollToItemId.value = it } - scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) - } - val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } - if (chatsCtx.contentTag == null) { - LaunchedEffect(Unit) { - snapshotFlow { scrollToItemId.value }.filterNotNull().collect { - if (appPlatform.isAndroid) { - ModalManager.end.closeModals() - } - scrollToItem(it) - scrollToItemId.value = null + true + } + val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) + val chatInfoUpdated = rememberUpdatedState(chatInfo) + val scope = rememberCoroutineScope() + val scrollToItem: (Long) -> Unit = remember { + // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling + if (chatsCtx.contentTag == MsgContentTag.Report) return@remember { scrollToItemId.value = it } + scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + } + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } + if (chatsCtx.secondaryContextFilter == null) { + LaunchedEffect(Unit) { + snapshotFlow { scrollToItemId.value }.filterNotNull().collect { + if (appPlatform.isAndroid) { + ModalManager.end.closeModals() } + scrollToItem(it) + scrollToItemId.value = null } } - SmallScrollOnNewMessage(listState, reversedChatItems) - val finishedInitialComposition = remember { mutableStateOf(false) } - NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) + } + SmallScrollOnNewMessage(listState, reversedChatItems) + val finishedInitialComposition = remember { mutableStateOf(false) } + NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) - DisposableEffectOnGone( - whenGone = { - VideoPlayerHolder.releaseAll() - } - ) + DisposableEffectOnGone( + whenGone = { + VideoPlayerHolder.releaseAll() + } + ) - @Composable - fun ChatViewListItem( - itemAtZeroIndexInWholeList: Boolean, - range: State, - showAvatar: Boolean, - cItem: ChatItem, - itemSeparation: ItemSeparation, - previousItemSeparationLargeGap: Boolean, - revealed: State, - reveal: (Boolean) -> Unit + @Composable + fun ChatViewListItem( + itemAtZeroIndexInWholeList: Boolean, + range: State, + showAvatar: Boolean, + cItem: ChatItem, + itemSeparation: ItemSeparation, + previousItemSeparationLargeGap: Boolean, + revealed: State, + reveal: (Boolean) -> Unit + ) { + val itemScope = rememberCoroutineScope() + CompositionLocalProvider( + // Makes horizontal and vertical scrolling to coexist nicely. + // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view + LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() ) { - val itemScope = rememberCoroutineScope() - CompositionLocalProvider( - // Makes horizontal and vertical scrolling to coexist nicely. - // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view - LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() - ) { - val provider = { - providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> + val provider = { + providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> + itemScope.launch { + listState.value.scrollToItem( + min(reversedChatItems.value.lastIndex, indexInReversed + 1), + -maxHeight.value + ) + } + } + } + + @Composable + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { + tryOrShowError("${cItem.id}ChatItem", error = { + CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) + }) { + val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } + LaunchedEffect(Unit) { + snapshotFlow { highlighted.value } + .distinctUntilChanged() + .filter { it } + .collect { + delay(500) + highlightedItems.value = setOf() + } + } + ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + } + } + + @Composable + fun ChatItemView(cItem: ChatItem, range: State, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean) { + val dismissState = rememberDismissState(initialValue = DismissValue.Default) { + if (it == DismissValue.DismissedToStart) { itemScope.launch { - listState.value.scrollToItem( - min(reversedChatItems.value.lastIndex, indexInReversed + 1), - -maxHeight.value - ) - } - } - } - - @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { - tryOrShowError("${cItem.id}ChatItem", error = { - CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) - }) { - val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } - LaunchedEffect(Unit) { - snapshotFlow { highlighted.value } - .distinctUntilChanged() - .filter { it } - .collect { - delay(500) - highlightedItems.value = setOf() - } - } - ChatItemView(chatsCtx, remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) - } - } - - @Composable - fun ChatItemView(cItem: ChatItem, range: State, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean) { - val dismissState = rememberDismissState(initialValue = DismissValue.Default) { - if (it == DismissValue.DismissedToStart) { - itemScope.launch { - if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } + if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local && !cItem.isReport) { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) } } } - false } - val swipeableModifier = SwipeToDismissModifier( - state = dismissState, - directions = setOf(DismissDirection.EndToStart), - swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, - ) - val sent = cItem.chatDir.sent + false + } + val swipeableModifier = SwipeToDismissModifier( + state = dismissState, + directions = setOf(DismissDirection.EndToStart), + swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, + ) + val sent = cItem.chatDir.sent - @Composable - fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { - Box( - modifier = modifier.padding( - bottom = if (itemSeparation.largeGap) { - if (itemAtZeroIndexInWholeList) { - 8.dp - } else { - 4.dp - } - } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp - ), - contentAlignment = Alignment.CenterStart - ) { - content() - } + @Composable + fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { + Box( + modifier = modifier.padding( + bottom = if (itemSeparation.largeGap) { + if (itemAtZeroIndexInWholeList) { + 8.dp + } else { + 4.dp + } + } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp + ), + contentAlignment = Alignment.CenterStart + ) { + content() } + } - @Composable - fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) - val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + @Composable + fun adjustTailPaddingOffset(originalPadding: Dp, start: Boolean): Dp { + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp) - } + return originalPadding + (if (tailRendered) 0.dp else if (start) msgTailWidthDp * 2 else msgTailWidthDp) + } + + Box { + val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null + val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf + val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) + val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } + if (chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { + if (showAvatar) { + Column( + Modifier + .padding(top = 8.dp) + .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .fillMaxWidth() + .then(swipeableModifier), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + @Composable + fun MemberNameAndRole(range: State) { + Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { + val member = cItem.chatDir.groupMember + val rangeValue = range.value + val (prevMember, memCount) = + if (rangeValue != null) { + chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) + } else { + null to 1 + } + Text( + memberNames(member, prevMember, memCount), + Modifier + .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) + .weight(1f, false), + fontSize = 13.5.sp, + color = MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + if (memCount == 1 && member.memberRole > GroupMemberRole.Member) { + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - Box { - val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null - val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf - val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) - val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } - if (chatInfo is ChatInfo.Group) { - if (cItem.chatDir is CIDirection.GroupRcv) { - if (showAvatar) { - Column( - Modifier - .padding(top = 8.dp) - .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) - .fillMaxWidth() - .then(swipeableModifier), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.Start - ) { - @Composable - fun MemberNameAndRole(range: State) { - Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { - val member = cItem.chatDir.groupMember - val rangeValue = range.value - val (prevMember, memCount) = - if (rangeValue != null) { - chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) - } else { - null to 1 - } Text( - memberNames(member, prevMember, memCount), - Modifier - .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) - .weight(1f, false), + member.memberRole.text, + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, color = MaterialTheme.colors.secondary, - overflow = TextOverflow.Ellipsis, maxLines = 1 ) - if (memCount == 1 && member.memberRole > GroupMemberRole.Member) { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) - val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - - Text( - member.memberRole.text, - Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), - fontSize = 13.5.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colors.secondary, - maxLines = 1 - ) - } } } - - @Composable - fun Item() { - ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { - androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedListItem(Modifier, cItem.id, selectedChatItems) - } - Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { - val member = cItem.chatDir.groupMember - Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { - MemberImage(member) - } - Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { - ChatItemViewShortHand(cItem, itemSeparation, range, false) - } - } - } - } - if (cItem.content.showMemberName) { - DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { - MemberNameAndRole(range) - Item() - } - } else { - Item() - } } - } else { - ChatItemBox { - AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { - SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + + @Composable + fun Item() { + ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { + androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier, cItem.id, selectedChatItems) + } + Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + val member = cItem.chatDir.groupMember + Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { + MemberImage(member) + } + Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { + ChatItemViewShortHand(cItem, itemSeparation, range, false) + } + } } - Row( - Modifier - .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) - .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(swipeableOrSelectionModifier) - ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + } + if (cItem.content.showMemberName) { + DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { + MemberNameAndRole(range) + Item() } + } else { + Item() } } } else { @@ -1497,138 +1531,152 @@ fun BoxScope.ChatItemsList( AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } - Box( + Row( Modifier - .padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp) + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(if (selectionVisible) Modifier else swipeableModifier) + .then(swipeableOrSelectionModifier) ) { ChatItemViewShortHand(cItem, itemSeparation, range) } } } - } else { // direct message + } else { ChatItemBox { AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) } - Box( - Modifier.padding( - start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp, - end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false), - ) + Modifier + .padding(start = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(104.dp, start = true), end = 12.dp) .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) - .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) + .then(if (selectionVisible) Modifier else swipeableModifier) ) { ChatItemViewShortHand(cItem, itemSeparation, range) } } } - if (selectionVisible) { - Box(Modifier.matchParentSize().clickable { - val checked = selectedChatItems.value?.contains(cItem.id) == true - selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) - }) + } else { // direct message + ChatItemBox { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + + Box( + Modifier.padding( + start = if (sent && !voiceWithTransparentBack) adjustTailPaddingOffset(76.dp, start = true) else 12.dp, + end = if (sent || voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(76.dp, start = false), + ) + .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) + .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range) + } } } - } - if (itemSeparation.date != null) { - DateSeparator(itemSeparation.date) - } - ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap) - } - } - LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), - state = listState.value, - contentPadding = PaddingValues( - top = topPaddingToContent, - bottom = composeViewHeight.value - ), - reverseLayout = true, - additionalBarOffset = composeViewHeight, - additionalTopBar = rememberUpdatedState(chatsCtx.contentTag == null && reportsCount > 0), - chatBottomBar = remember { appPrefs.chatBottomBar.state } - ) { - val mergedItemsValue = mergedItems.value - itemsIndexed(mergedItemsValue.items, key = { _, merged -> keyForItem(merged.newest().item) }) { index, merged -> - val isLastItem = index == mergedItemsValue.items.lastIndex - val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null - val listItem = merged.newest() - val item = listItem.item - val range = if (merged is MergedItem.Grouped) { - merged.rangeInReversed.value - } else { - null - } - val showAvatar = shouldShowAvatar(item, listItem.nextItem) - val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } - val itemSeparation: ItemSeparation - val prevItemSeparationLargeGap: Boolean - if (merged is MergedItem.Single || isRevealed.value) { - val prev = listItem.prevItem - itemSeparation = getItemSeparation(item, prev) - val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem - prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) - } else { - itemSeparation = getItemSeparation(item, null) - prevItemSeparationLargeGap = false - } - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { - if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) - } - - if (last != null) { - // no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items - DateSeparator(last.meta.itemTs) - } - if (item.isRcvNew) { - val itemIds = when (merged) { - is MergedItem.Single -> listOf(merged.item.item.id) - is MergedItem.Grouped -> merged.items.map { it.item.id } - } - MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead) - } - } - } - FloatingButtons( - chatsCtx, - reversedChatItems, - chatInfoUpdated, - topPaddingToContent, - topPaddingToContentPx, - loadingMoreItems, - loadingTopItems, - loadingBottomItems, - animatedScrollingInProgress, - mergedItems, - unreadCount, - maxHeight, - composeViewHeight, - searchValue, - markChatRead, - listState, - loadMessages - ) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) - - LaunchedEffect(Unit) { - snapshotFlow { listState.value.isScrollInProgress } - .collect { - chatViewScrollState.value = it - } - } - LaunchedEffect(Unit) { - snapshotFlow { listState.value.isScrollInProgress } - .filter { !it } - .collect { - if (animatedScrollingInProgress.value) { - animatedScrollingInProgress.value = false + if (selectionVisible) { + Box(Modifier.matchParentSize().clickable { + val checked = selectedChatItems.value?.contains(cItem.id) == true + selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) + }) } } + } + if (itemSeparation.date != null) { + DateSeparator(itemSeparation.date) + } + ChatItemView(cItem, range, itemSeparation, previousItemSeparationLargeGap) } } + LazyColumnWithScrollBar( + Modifier.align(Alignment.BottomCenter), + state = listState.value, + contentPadding = PaddingValues( + top = topPaddingToContent, + bottom = composeViewHeight.value + ), + reverseLayout = true, + additionalBarOffset = composeViewHeight, + additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && reportsCount > 0), // TODO [knocking] && support chats unread count > 0 ? + chatBottomBar = remember { appPrefs.chatBottomBar.state } + ) { + val mergedItemsValue = mergedItems.value + itemsIndexed(mergedItemsValue.items, key = { _, merged -> keyForItem(merged.newest().item) }) { index, merged -> + val isLastItem = index == mergedItemsValue.items.lastIndex + val last = if (isLastItem) reversedChatItems.value.lastOrNull() else null + val listItem = merged.newest() + val item = listItem.item + val range = if (merged is MergedItem.Grouped) { + merged.rangeInReversed.value + } else { + null + } + val showAvatar = shouldShowAvatar(item, listItem.nextItem) + val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } + val itemSeparation: ItemSeparation + val prevItemSeparationLargeGap: Boolean + if (merged is MergedItem.Single || isRevealed.value) { + val prev = listItem.prevItem + itemSeparation = getItemSeparation(item, prev) + val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem + prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) + } else { + itemSeparation = getItemSeparation(item, null) + prevItemSeparationLargeGap = false + } + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + } + + if (last != null) { + // no using separate item(){} block in order to have total number of items in LazyColumn match number of merged items + DateSeparator(last.meta.itemTs) + } + if (item.isRcvNew) { + val itemIds = when (merged) { + is MergedItem.Single -> listOf(merged.item.item.id) + is MergedItem.Grouped -> merged.items.map { it.item.id } + } + MarkItemsReadAfterDelay(keyForItem(item), itemIds, finishedInitialComposition, chatInfo.id, listState, markItemsRead) + } + } + } + FloatingButtons( + chatsCtx, + reversedChatItems, + chatInfoUpdated, + topPaddingToContent, + topPaddingToContentPx, + loadingMoreItems, + loadingTopItems, + loadingBottomItems, + animatedScrollingInProgress, + mergedItems, + unreadCount, + maxHeight, + composeViewHeight, + searchValue, + markChatRead, + listState, + loadMessages + ) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) + + LaunchedEffect(Unit) { + snapshotFlow { listState.value.isScrollInProgress } + .collect { + chatViewScrollState.value = it + } + } + LaunchedEffect(Unit) { + snapshotFlow { listState.value.isScrollInProgress } + .filter { !it } + .collect { + if (animatedScrollingInProgress.value) { + animatedScrollingInProgress.value = false + } + } + } } private suspend fun loadLastItems(chatsCtx: ChatModel.ChatsContext, chatId: State, listState: State, loadItems: State Boolean>) { @@ -2119,6 +2167,7 @@ private fun FloatingDate( } } +// TODO [knocking] same for member support chats? @Composable private fun SaveReportsStateOnDispose(chatsCtx: ChatModel.ChatsContext, listState: State) { DisposableEffect(Unit) { @@ -2499,6 +2548,7 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List Unit)? = null) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.accept_pending_member_alert_title), + text = generalGetString(MR.strings.accept_pending_member_alert_question), + buttons = { + Column { + // Accept as member + SectionItemView({ + AlertManager.shared.hideAlert() + acceptMember(rhId, groupInfo, member, GroupMemberRole.Member, close) + }) { + Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_member), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Accept as observer + SectionItemView({ + AlertManager.shared.hideAlert() + acceptMember(rhId, groupInfo, member, GroupMemberRole.Observer, close) + }) { + Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_observer), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, role: GroupMemberRole, close: (() -> Unit)?) { + withBGApi { + val acceptedMember = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) + if (acceptedMember != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, acceptedMember) + } + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, acceptedMember) + } + } + close?.invoke() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index de9fc26905..0f96a551d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -333,7 +333,9 @@ suspend fun MutableState.processPickedMedia(uris: List, text: @Composable fun ComposeView( + rhId: Long?, chatModel: ChatModel, + chatsCtx: ChatModel.ChatsContext, chat: Chat, composeState: MutableState, attachmentOption: MutableState, @@ -466,6 +468,7 @@ fun ComposeView( rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), live = live, ttl = ttl, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) @@ -473,7 +476,7 @@ fun ComposeView( if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatModel.chatsContext.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) + chatsCtx.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -498,15 +501,17 @@ fun ComposeView( rh = rhId, toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, + toScope = chat.chatInfo.groupChatScope(), fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, + fromScope = fromChatInfo.groupChatScope(), itemIds = forwardedItem.map { it.id }, ttl = ttl ) withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - chatModel.chatsContext.addChatItem(rhId, chat.chatInfo, chatItem) + chatsCtx.addChatItem(rhId, chat.chatInfo, chatItem) } } @@ -566,9 +571,10 @@ fun ComposeView( suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List? { val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText) if (cItems != null) { + // TODO [knocking] create report chat items in support scope withContext(Dispatchers.Main) { cItems.forEach { chatItem -> - chatModel.chatsContext.addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) + chatsCtx.addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem) } } } @@ -581,7 +587,7 @@ fun ComposeView( val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.updateContact(chat.remoteHostId, contact) + chatsCtx.updateContact(chat.remoteHostId, contact) } } } @@ -594,13 +600,14 @@ fun ComposeView( rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, + scope = cInfo.groupChatScope(), itemId = ei.meta.itemId, updatedMessage = UpdatedMessage(updateMsgContent(oldMsgContent), cs.memberMentions), live = live ) if (updatedItem != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + chatsCtx.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) } } return updatedItem?.chatItem @@ -891,7 +898,7 @@ fun ComposeView( fun editPrevMessage() { if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return - val lastEditable = chatModel.chatsContext.chatItems.value.findLast { it.meta.editable } + val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) } @@ -1002,9 +1009,25 @@ fun ComposeView( val userCanSend = rememberUpdatedState(chat.chatInfo.userCanSend) val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled) val userIsObserver = rememberUpdatedState(chat.userIsObserver) + val userIsPending = rememberUpdatedState(chat.userIsPending) val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) Column { + if ( + chat.chatInfo is ChatInfo.Group + && chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext + && chatsCtx.secondaryContextFilter.groupScopeInfo is GroupChatScopeInfo.MemberSupport + && chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ != null + && chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_.memberPending + && composeState.value.contextItem == ComposeContextItem.NoContextItem + && composeState.value.preview == ComposePreview.NoPreview + ) { + ComposeContextPendingMemberActionsView( + rhId = rhId, + groupInfo = chat.chatInfo.groupInfo, + member = chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ + ) + } if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } @@ -1164,7 +1187,8 @@ fun ComposeView( needToAllowVoiceToContact, allowedVoiceByPrefs, allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, + userIsObserver = if (chatsCtx.secondaryContextFilter == null) userIsObserver.value else false, + userIsPending = if (chatsCtx.secondaryContextFilter == null) userIsPending.value else false, userCanSend = userCanSend.value, sendButtonColor = sendButtonColor, timedMessageAllowed = timedMessageAllowed, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index b9538bc691..c934b6d5d7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -77,7 +77,7 @@ fun SelectedItemsButtonsToolbar( val forwardCountProhibited = remember { mutableStateOf(false) } Box { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty - ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) + ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) Row( Modifier .matchParentSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 5524eff655..f2e636a01a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -15,9 +15,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.* import chat.simplex.common.model.* @@ -46,6 +44,7 @@ fun SendMsgView( needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, userIsObserver: Boolean, + userIsPending: Boolean, userCanSend: Boolean, sendButtonColor: Color = MaterialTheme.colors.primary, allowVoiceToContact: () -> Unit, @@ -90,6 +89,7 @@ fun SendMsgView( textStyle, showDeleteTextButton, userIsObserver, + userIsPending, if (clicksOnTextFieldDisabled) "" else placeholder, showVoiceButton, onMessageChange, @@ -581,6 +581,7 @@ fun PreviewSendMsgView() { needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, userIsObserver = false, + userIsPending = false, userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, @@ -617,6 +618,7 @@ fun PreviewSendMsgViewEditing() { needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, userIsObserver = false, + userIsPending = false, userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, @@ -653,6 +655,7 @@ fun PreviewSendMsgViewInProgress() { needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, userIsObserver = false, + userIsPending = false, userCanSend = true, allowVoiceToContact = {}, timedMessageAllowed = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 10694d13bf..369a498c25 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -144,7 +144,7 @@ fun AddGroupMembersLayout( horizontalArrangement = Arrangement.Center ) { ChatInfoToolbarTitle( - ChatInfo.Group(groupInfo), + ChatInfo.Group(groupInfo, groupChatScope = null), imageSize = 60.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 22956738e7..aeb4041008 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -74,6 +74,11 @@ fun ModalData.GroupChatInfoView( val chatItemTTL = remember(groupInfo.id) { mutableStateOf(if (groupInfo.chatItemTTL != null) ChatItemTTL.fromSeconds(groupInfo.chatItemTTL) else null) } val deletingItems = rememberSaveable(groupInfo.id) { mutableStateOf(false) } val scope = rememberCoroutineScope() + val activeSortedMembers = remember { chatModel.groupMembers }.value + .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } + .sortedByDescending { it.memberRole } + + Log.e(TAG, "######### GroupChatInfoView chatModel.groupMembers length = ${chatModel.groupMembers.value.count()}") GroupChatInfoLayout( chat, @@ -95,9 +100,7 @@ fun ModalData.GroupChatInfoView( setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, - activeSortedMembers = remember { chatModel.groupMembers }.value - .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } - .sortedByDescending { it.memberRole }, + activeSortedMembers = activeSortedMembers, developerTools, onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) }, groupLink, @@ -140,6 +143,15 @@ fun ModalData.GroupChatInfoView( addOrEditWelcomeMessage = { ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, rhId, groupInfo, close) } }, + openMemberSupport = { + ModalManager.end.showCustomModal { close -> + MemberSupportView( + chat, + groupInfo, + close + ) + } + }, openPreferences = { ModalManager.end.showCustomModal { close -> GroupPreferencesView( @@ -317,6 +329,30 @@ fun AddGroupMembersButton( ) } +@Composable +fun UserSupportChatButton(groupInfo: GroupInfo) { + val scope = rememberCoroutineScope() + val scrollToItemId: MutableState = remember { mutableStateOf(null) } + + SettingsActionItem( + painterResource(MR.images.ic_flag), + stringResource(MR.strings.button_support_chat), + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = null) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + iconColor = MaterialTheme.colors.secondary, + ) +} + @Composable fun ModalData.GroupChatInfoLayout( chat: Chat, @@ -337,6 +373,7 @@ fun ModalData.GroupChatInfoLayout( showMemberInfo: (GroupMember) -> Unit, editGroupProfile: () -> Unit, addOrEditWelcomeMessage: () -> Unit, + openMemberSupport: () -> Unit, openPreferences: () -> Unit, deleteGroup: () -> Unit, clearChat: () -> Unit, @@ -422,6 +459,29 @@ fun ModalData.GroupChatInfoLayout( SectionSpacer() + var anyTopSectionRowShow = false + SectionView { + if (groupInfo.membership.supportChat != null) { + anyTopSectionRowShow = true + UserSupportChatButton(groupInfo) + } + if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(openMemberSupport) + } + if (groupInfo.canModerate) { + anyTopSectionRowShow = true + GroupReportsButton { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } + } + if (anyTopSectionRowShow) { + SectionDividerSpaced(maxBottomPadding = false) + } + SectionView { if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { EditGroupProfileButton(editGroupProfile) @@ -431,19 +491,17 @@ fun ModalData.GroupChatInfoLayout( } val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences GroupPreferencesButton(prefsTitleId, openPreferences) - if (groupInfo.canModerate) { - GroupReportsButton { - scope.launch { - showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) - } - } - } + } + val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs + SectionTextFooter(stringResource(footerId)) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + + SectionView { if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) } else { SendReceiptsOptionDisabled() } - WallpaperButton { ModalManager.end.showModal { val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } @@ -453,12 +511,9 @@ fun ModalData.GroupChatInfoLayout( } } } + ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) } - val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs - SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - - ChatTTLSection(chatItemTTL, setChatItemTTL, deletingItems) SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { @@ -481,7 +536,7 @@ fun ModalData.GroupChatInfoLayout( } if (activeSortedMembers.size > 8) { SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { - SearchRowView(searchText) + MemberListSearchRowView(searchText) } } SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { @@ -631,17 +686,14 @@ private fun SelectedItemsCounterToolbarSetter( } @Composable -fun ChatTTLSection(chatItemTTL: State, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State) { +fun ChatTTLOption(chatItemTTL: State, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State) { Box { - SectionView { - TtlOptions( - chatItemTTL, - enabled = remember { derivedStateOf { !deletingItems.value } }, - onSelected = setChatItemTTL, - default = chatModel.chatItemTTL - ) - SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) - } + TtlOptions( + chatItemTTL, + enabled = remember { derivedStateOf { !deletingItems.value } }, + onSelected = setChatItemTTL, + default = chatModel.chatItemTTL + ) if (deletingItems.value) { Box(Modifier.matchParentSize()) { ProgressIndicator() @@ -683,6 +735,15 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { } } +@Composable +private fun MemberSupportButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_flag), // TODO [knocking] change icon + stringResource(MR.strings.member_support), + click = onClick + ) +} + @Composable private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) { SettingsActionItem( @@ -820,7 +881,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr } @Composable -private fun MemberVerifiedShield() { +fun MemberVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary) } @@ -941,7 +1002,7 @@ private fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) { } @Composable -private fun SearchRowView( +fun MemberListSearchRowView( searchText: MutableState = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } ) { Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) { @@ -1016,7 +1077,18 @@ fun PreviewGroupChatInfoLayout() { selectedItems = remember { mutableStateOf(null) }, appBar = remember { mutableStateOf(null) }, scrollToItemId = remember { mutableStateOf(null) }, - addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, deletingItems = remember { mutableStateOf(true) } + addMembers = {}, + showMemberInfo = {}, + editGroupProfile = {}, + addOrEditWelcomeMessage = {}, + openMemberSupport = {}, + openPreferences = {}, + deleteGroup = {}, + clearChat = {}, + leaveGroup = {}, + manageGroupLink = {}, + onSearchClicked = {}, + deletingItems = remember { mutableStateOf(true) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 285c96165c..a6c21574ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -299,6 +299,30 @@ fun GroupMemberInfoLayout( } } + @Composable + fun SupportChatButton() { + val scope = rememberCoroutineScope() + val scrollToItemId: MutableState = remember { mutableStateOf(null) } // TODO [knocking] scroll to report from support chat? + + SettingsActionItem( + painterResource(MR.images.ic_flag), + stringResource(MR.strings.button_support_chat), + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + iconColor = MaterialTheme.colors.secondary, + ) + } + @Composable fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) @@ -413,6 +437,12 @@ fun GroupMemberInfoLayout( if (member.memberActive) { SectionView { + // TODO [knocking] allow creating support chat with members that don't have it (currently doesn't work) + // groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + // (member.memberRole <= GroupMemberRole.Moderator || member.supportChat != null) + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator && member.supportChat != null) { + SupportChatButton() + } if (connectionCode != null) { VerifyCodeButton(member.verified, verifyClicked) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt index 2c4d4b16a8..62f1a4337c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -44,7 +44,7 @@ fun SelectedItemsMembersToolbar( ) { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty Box(Modifier.alpha(0f)) { - ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) + ComposeView(rhId = null, chatModel = chatModel, chatModel.chatsContext, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}, remember { FocusRequester() }) } Row( Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt index 1a63375432..8f57d01619 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -29,6 +29,7 @@ private val MAX_PICKER_HEIGHT = (PICKER_ROW_SIZE * 4) + (MEMBER_ROW_AVATAR_SIZE @Composable fun GroupMentions( + chatsCtx: ChatModel.ChatsContext, rhId: Long?, composeState: MutableState, composeViewFocusRequester: FocusRequester?, @@ -42,12 +43,31 @@ fun GroupMentions( val mentionName = remember { mutableStateOf("") } val mentionRange = remember { mutableStateOf(null) } val mentionMemberId = remember { mutableStateOf(null) } + + fun contextMemberFilter(member: GroupMember): Boolean = + when (chatsCtx.secondaryContextFilter) { + null -> true + is SecondaryContextFilter.GroupChatScopeContext -> + when (chatsCtx.secondaryContextFilter.groupScopeInfo) { + is GroupChatScopeInfo.MemberSupport -> { + val scopeMember = chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_ + if (scopeMember != null) { + member.memberRole >= GroupMemberRole.Moderator || member.groupMemberId == scopeMember.groupMemberId + } else { + member.memberRole >= GroupMemberRole.Moderator + } + } + } + is SecondaryContextFilter.MsgContentTagContext -> false + } + val filteredMembers = remember { derivedStateOf { val members = chatModel.groupMembers.value .filter { val status = it.memberStatus status != GroupMemberStatus.MemLeft && status != GroupMemberStatus.MemRemoved && status != GroupMemberStatus.MemInvited + && contextMemberFilter(it) } .sortedByDescending { it.memberRole } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 12c5b65769..b8db5969a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -17,7 +17,9 @@ import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* private val featureRoles: List> = listOf( @@ -71,6 +73,16 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> preferences = currentPreferences }, savePrefs = ::savePrefs, + openMemberAdmission = { + ModalManager.end.showCustomModal { close -> + MemberAdmissionView( + chatModel, + rhId, + chatId, + close + ) + } + } ) } } @@ -83,10 +95,15 @@ private fun GroupPreferencesLayout( applyPrefs: (FullGroupPreferences) -> Unit, reset: () -> Unit, savePrefs: () -> Unit, + openMemberAdmission: () -> Unit, ) { ColumnWithScrollBar { val titleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences AppBarTitle(stringResource(titleId)) + if (groupInfo.businessChat == null) { + MemberAdmissionButton(openMemberAdmission) + SectionDividerSpaced(maxBottomPadding = false) + } val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) } val onTTLUpdated = { ttl: Int? -> applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl))) @@ -156,6 +173,15 @@ private fun GroupPreferencesLayout( } } +@Composable +private fun MemberAdmissionButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.member_admission), + click = onClick + ) +} + @Composable private fun FeatureSection( feature: GroupFeature, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt index 1eeeb99c93..bb05ede81b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -53,7 +53,7 @@ fun GroupReportsAppBar( } @Composable -private fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { +fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { LaunchedEffect(Unit) { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() @@ -69,7 +69,7 @@ private fun ItemsReload(chatsCtx: ChatModel.ChatsContext,) { } suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo) { - val reportsChatsCtx = ChatModel.ChatsContext(contentTag = MsgContentTag.Report) + val reportsChatsCtx = ChatModel.ChatsContext(secondaryContextFilter = SecondaryContextFilter.MsgContentTagContext(MsgContentTag.Report)) openChat(secondaryChatsCtx = reportsChatsCtx, chatModel.remoteHostId(), chatInfo) ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> ModalView({}, showAppBar = false) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt new file mode 100644 index 0000000000..48171bfeb7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -0,0 +1,152 @@ +package chat.simplex.common.views.chat.group + +import InfoRow +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit) { + val groupInfo = remember { derivedStateOf { + val ch = m.getChat(chatId) + val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo + if (g == null || ch.remoteHostId != rhId) null else g + }} + val gInfo = groupInfo.value ?: return + var admission by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.groupProfile.memberAdmission) } + var currentAdmission by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(admission) } + + fun saveAdmission(afterSave: () -> Unit = {}) { + withBGApi { + val gp = gInfo.groupProfile.copy(memberAdmission = admission) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) + if (g != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, g) + currentAdmission = admission + } + } + afterSave() + } + } + ModalView( + close = { + if (admission == currentAdmission) close() + else showUnsavedChangesAlert({ saveAdmission(close) }, close) + }, + ) { + MemberAdmissionLayout( + admission, + currentAdmission, + gInfo, + applyAdmission = { admsn -> + admission = admsn + }, + reset = { + admission = currentAdmission + }, + saveAdmission = ::saveAdmission, + ) + } +} + +@Composable +private fun MemberAdmissionLayout( + admission: GroupMemberAdmission?, + currentAdmission: GroupMemberAdmission?, + groupInfo: GroupInfo, + applyAdmission: (GroupMemberAdmission) -> Unit, + reset: () -> Unit, + saveAdmission: () -> Unit, +) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.member_admission)) + val review = remember(admission) { mutableStateOf(admission?.review) } + AdmissionSection(MR.strings.admission_stage_review, MR.strings.admission_stage_review_descr, review, groupInfo) { criteria -> + if (admission != null) { + applyAdmission(admission.copy(review = criteria)) + } else { + applyAdmission(GroupMemberAdmission(review = criteria)) + } + } + if (groupInfo.isOwner) { + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + ResetSaveButtons( + reset = reset, + save = saveAdmission, + disabled = admission == currentAdmission + ) + } + SectionBottomSpacer() + } +} + +private val memberCriterias: List> = listOf( + null to generalGetString(MR.strings.member_criteria_off), + MemberCriteria.All to generalGetString(MR.strings.member_criteria_all) +) + +@Composable +private fun AdmissionSection( + admissionStageStrId: StringResource, + admissionStageDescrStrId: StringResource, + memberCriteria: State, + groupInfo: GroupInfo, + onSelected: (MemberCriteria?) -> Unit +) { + SectionView { + if (groupInfo.isOwner) { + ExposedDropDownSettingRow( + generalGetString(admissionStageStrId), + memberCriterias, + memberCriteria, + onSelected = { value -> + onSelected(value) + } + ) + } else { + InfoRow( + stringResource(admissionStageStrId), + memberCriteria.value?.text ?: generalGetString(MR.strings.member_criteria_off) + ) + } + } + SectionTextFooter(stringResource( admissionStageDescrStrId)) +} + +@Composable +private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { + SectionView { + SectionItemView(reset, disabled = disabled) { + Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + } + SectionItemView(save, disabled = disabled) { + Text(stringResource(MR.strings.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + } + } +} + +private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.save_admission_question), + confirmText = generalGetString(MR.strings.save_and_notify_group_members), + dismissText = generalGetString(MR.strings.exit_without_saving), + onConfirm = save, + onDismiss = revert, + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt new file mode 100644 index 0000000000..86bb9b7255 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -0,0 +1,122 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +private fun MemberSupportChatView(memberSupportChatsCtx: ChatModel.ChatsContext, staleChatId: State, scrollToItemId: MutableState) { + ChatView(memberSupportChatsCtx, staleChatId, scrollToItemId, onComposed = {}) +} + +@Composable +fun MemberSupportChatAppBar( + chatsCtx: ChatModel.ChatsContext, + scopeMember_: GroupMember?, + close: () -> Unit, + onSearchValueChanged: (String) -> Unit +) { + val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } + val showSearch = rememberSaveable { mutableStateOf(false) } + val onBackClicked = { + if (!showSearch.value) { + close() + } else { + onSearchValueChanged("") + showSearch.value = false + } + } + BackHandler(onBack = onBackClicked) + if (scopeMember_ != null) { + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + title = { MemberSupportChatToolbarTitle(scopeMember_) }, + onTitleClick = null, + onTop = !oneHandUI.value, + showSearch = showSearch.value, + onSearchValueChanged = onSearchValueChanged, + buttons = { + IconButton({ showSearch.value = true }) { + Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) + } + } + ) + } else { + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + fixedTitleText = stringResource(MR.strings.support_chat), + onTitleClick = null, + onTop = !oneHandUI.value, + showSearch = showSearch.value, + onSearchValueChanged = onSearchValueChanged, + buttons = { + IconButton({ showSearch.value = true }) { + Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) + } + } + ) + } + ItemsReload(chatsCtx) +} + +@Composable +fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + MemberProfileImage(size = imageSize * fontSizeSqrtMultiplier, member, iconColor) + Column( + Modifier.padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.displayName, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } + if (member.fullName != "" && member.fullName != member.displayName && member.localAlias.isEmpty()) { + Text( + member.fullName, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +suspend fun showMemberSupportChatView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo, scopeInfo: GroupChatScopeInfo) { + val memberSupportChatsCtx = ChatModel.ChatsContext(secondaryContextFilter = SecondaryContextFilter.GroupChatScopeContext(scopeInfo)) + openChat(secondaryChatsCtx = memberSupportChatsCtx, chatModel.remoteHostId(), chatInfo) + ModalManager.end.showCustomModal(true, id = ModalViewId.SECONDARY_CHAT) { close -> + ModalView({}, showAppBar = false) { + if (chatInfo is ChatInfo.Group && chatInfo.groupChatScope != null) { + MemberSupportChatView(memberSupportChatsCtx, staleChatId, scrollToItemId) + } else { + LaunchedEffect(Unit) { + close() + } + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt new file mode 100644 index 0000000000..55c2126771 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -0,0 +1,260 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewLongClickable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.views.helpers.* +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.chat.item.ItemAction +import chat.simplex.common.views.chatlist.unreadCountStr +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.launch + +@Composable +fun ModalData.MemberSupportView( + chat: Chat, + groupInfo: GroupInfo, + close: () -> Unit +) { + KeyChangeEffect(chat.id) { + close() + } + ModalView(close = close) { + MemberSupportViewLayout( + chat, + groupInfo + ) + } +} + +@Composable +private fun ModalData.MemberSupportViewLayout( + chat: Chat, + groupInfo: GroupInfo +) { + val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } + val scope = rememberCoroutineScope() + val scrollToItemId: MutableState = remember { mutableStateOf(null) } // TODO [knocking] scroll to report from support chat? + + val membersWithChats = remember { chatModel.groupMembers }.value + .filter { it.supportChat != null && it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } + .sortedWith( + compareByDescending { it.memberPending } + .thenByDescending { (it.supportChat?.mentions ?: 0) > 0 } + .thenByDescending { (it.supportChat?.memberAttention ?: 0) > 0 } + .thenByDescending { (it.supportChat?.unread ?: 0) > 0 } + .thenByDescending { it.supportChat?.chatTs } + ) + + val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } } + val filteredmembersWithChats = remember(membersWithChats) { + derivedStateOf { + val s = searchText.value.text.trim().lowercase() + if (s.isEmpty()) membersWithChats else membersWithChats.filter { m -> m.anyNameContains(s) } + } + } + + LazyColumnWithScrollBar( + contentPadding = + PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false) + ) + ) { + item { + AppBarTitle(stringResource(MR.strings.member_support)) + } + + if (membersWithChats.isEmpty()) { + item { + Box(Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) { + Text(generalGetString(MR.strings.no_support_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } + } + } else { + item { + SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { + MemberListSearchRowView(searchText) + } + } + items(filteredmembersWithChats.value, key = { it.groupMemberId }) { member -> + Divider() + val showMenu = remember { mutableStateOf(false) } + SectionItemViewLongClickable( + click = { + val scopeInfo = GroupChatScopeInfo.MemberSupport(groupMember_ = member) + val supportChatInfo = ChatInfo.Group(groupInfo, groupChatScope = scopeInfo) + scope.launch { + showMemberSupportChatView( + chatModel.chatId, + scrollToItemId = scrollToItemId, + supportChatInfo, + scopeInfo + ) + } + }, + longClick = { showMenu.value = true }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + Box(contentAlignment = Alignment.CenterStart) { + DropDownMenuForSupportChat(chat.remoteHostId, member, groupInfo, showMenu) + SupportChatRow(member) + } + } + } + item { + Divider() + SectionBottomSpacer() + } + } + } +} + +@Composable +fun SupportChatRow(member: GroupMember) { + fun memberStatus(): String { + return if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else if (member.memberPending) { + member.memberStatus.text + } else { + member.memberRole.text + } + } + + @Composable + fun SupportChatUnreadIndicator(supportChat: GroupSupportChat) { + Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.sp.toDp())) { + if (supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0) { + val indicatorTint = when { + supportChat.mentions > 0 || supportChat.memberAttention > 0 -> MaterialTheme.colors.primaryVariant + else -> MaterialTheme.colors.secondary + } + if (supportChat.mentions == 1 && supportChat.unread == 1) { + Box(modifier = Modifier.offset(y = 2.sp.toDp()).size(15.sp.toDp()).background(indicatorTint, shape = CircleShape), contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = Color.White, + modifier = Modifier.size(9.sp.toDp()) + ) + } + } else { + if (supportChat.mentions > 0 && supportChat.unread > 1) { + Icon( + painterResource(MR.images.ic_alternate_email), + contentDescription = generalGetString(MR.strings.notifications), + tint = indicatorTint, + modifier = Modifier.size(12.sp.toDp()).offset(y = 3.sp.toDp()) + ) + } + + Text( + unreadCountStr(supportChat.unread), + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = 3.sp.toDp()) + .background(indicatorTint, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 1.sp.toDp()) + ) + } + } + } + } + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + Modifier.weight(1f).padding(top = MEMBER_ROW_VERTICAL_PADDING, end = DEFAULT_PADDING, bottom = MEMBER_ROW_VERTICAL_PADDING), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified + ) + } + + Text( + memberStatus(), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row { + if (member.memberPending) { + Icon( + painterResource(MR.images.ic_flag_filled), + contentDescription = null, + Modifier.padding(end = 3.dp).size(16.dp), + tint = MaterialTheme.colors.primaryVariant + ) + } + if (member.supportChat != null) { + SupportChatUnreadIndicator(member.supportChat) + } + } + } +} + +@Composable +private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { + DefaultDropdownMenu(showMenu) { + if (member.memberPending) { + ItemAction(stringResource(MR.strings.accept_pending_member_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = { + acceptMemberDialog(rhId, groupInfo, member) + showMenu.value = false + }) + } + ItemAction(stringResource(MR.strings.remove_pending_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + removeMemberDialog(rhId, groupInfo, member, chatModel) + showMenu.value = false + }) + // TODO [knocking] mark read, mark unread + // ItemAction(stringResource(MR.strings.mark_unread), painterResource(MR.images.ic_mark_chat_unread), onClick = { + // showMenu.value = false + // }) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 5bbbc33741..3ea75a74a7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -271,6 +271,7 @@ fun ChatItemView( } } + // improvement could be to track "forwarded from" scope and open it @Composable fun GoToItemButton(alignStart: Boolean, parentActivated: State) { val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId @@ -1422,7 +1423,7 @@ fun PreviewChatItemView( chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), Clock.System.now(), "hello") ) { ChatItemView( - chatsCtx = ChatModel.ChatsContext(contentTag = null), + chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, ChatInfo.Direct.sampleData, chatItem, @@ -1472,7 +1473,7 @@ fun PreviewChatItemView( fun PreviewChatItemViewDeletedContent() { SimpleXTheme { ChatItemView( - chatsCtx = ChatModel.ChatsContext(contentTag = null), + chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 958b794bd7..b6b7a1aa64 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -615,7 +615,8 @@ fun markChatRead(c: Chat) { chatModel.controller.apiChatRead( chat.remoteHostId, chat.chatInfo.chatType, - chat.chatInfo.apiId + chat.chatInfo.apiId, + chat.chatInfo.groupChatScope() ) chat = chatModel.getChat(chat.id) ?: return@withApi } @@ -651,7 +652,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (success) { withContext(Dispatchers.Main) { chatModel.chatsContext.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) - chatModel.chatsContext.updateChatTagReadNoContentTag(chat, wasUnread) + chatModel.chatsContext.updateChatTagReadInPrimaryContext(chat, wasUnread) } } } @@ -886,7 +887,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch ChatInfo.Direct(contact.copy(chatSettings = chatSettings)) } is ChatInfo.Group -> with(chatInfo) { - ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings)) + ChatInfo.Group(groupInfo.copy(chatSettings = chatSettings), groupChatScope = null) } else -> null } @@ -914,7 +915,7 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch val updatedChat = chatModel.getChat(chatInfo.id) if (updatedChat != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.updateChatTagReadNoContentTag(updatedChat, wasUnread) + chatModel.chatsContext.updateChatTagReadInPrimaryContext(updatedChat, wasUnread) } } val current = currentState?.value diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 90a176658d..53ae1abb50 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -158,6 +158,7 @@ Error loading details Error adding member(s) Error joining group + Error accepting member Cannot receive file Sender cancelled file transfer. Unknown servers! @@ -487,6 +488,7 @@ The image cannot be decoded. Please, try a different image or contact developers. The video cannot be decoded. Please, try a different video or contact developers. you are observer + reviewed by moderators You can\'t send messages! Please contact group admin. Files and media prohibited! @@ -1048,6 +1050,7 @@ Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. Edit image Delete image + Save admission settings? Save preferences? Save and notify contact Save and notify contacts @@ -1567,6 +1570,8 @@ invited %1$s connected + accepted %1$s + accepted you left changed role of %s to %s blocked %s @@ -1644,6 +1649,8 @@ invited pending approval pending + pending review + review connecting (introduced) connecting (introduction invitation) connecting (accepted) @@ -1750,7 +1757,7 @@ Remove member? Remove members? Remove member - + Support chat Send direct message Member will be removed from group - this cannot be undone! Members will be removed from group - this cannot be undone! @@ -2144,6 +2151,26 @@ owners Enabled for + + Member admission + Review + Review new members before admitting to group. + off + all + + + Member support + No support chats + + + Support + Remove + Accept + Accept member + Member will join the group, accept member? + Accept as member + Accept as observer + What\'s new New in %s diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index d0d4fb5e92..c6b9a2f73e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -48,6 +48,7 @@ actual fun PlatformTextField( textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, + userIsPending: Boolean, placeholder: String, showVoiceButton: Boolean, onMessageChange: (ComposeMessage) -> Unit, @@ -204,6 +205,8 @@ actual fun PlatformTextField( showDeleteTextButton.value = cs.message.text.split("\n").size >= 4 && !cs.inProgress if (composeState.value.preview is ComposePreview.VoicePreview) { ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) + } else if (userIsPending) { + ComposeOverlay(MR.strings.reviewed_by_moderators, textStyle, padding) } else if (userIsObserver) { ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) } diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9d064ea691..d0e6803015 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1578,7 +1578,7 @@ processChatCommand' vr = \case case memberConnId m of Just connId -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId PQSupportOff force - (g', m', scopeInfo) <- liftIO $ mkGroupChatScope g m + (g', m', scopeInfo) <- mkGroupChatScope g m createInternalChatItem user (CDGroupSnd g' scopeInfo) (CISndConnEvent . SCERatchetSync rss . Just $ groupMemberRef m') Nothing pure $ CRGroupMemberRatchetSyncStarted user g' m' cStats _ -> throwChatError CEGroupMemberNotActive @@ -2244,7 +2244,7 @@ processChatCommand' vr = \case pure m {memberStatus = GSMemRemoved} deletePendingMember :: ([ChatError], [GroupMember], [AChatItem]) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem]) deletePendingMember (accErrs, accDeleted, accACIs) user gInfo recipients m = do - (m', scopeInfo) <- liftIO $ mkMemberSupportChatInfo m + (m', scopeInfo) <- mkMemberSupportChatInfo m (errs, deleted, acis) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m'] pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs) deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) @@ -2280,7 +2280,7 @@ processChatCommand' vr = \case cancelFilesInProgress user filesInfo let recipients = filter memberCurrentOrPending members msg <- sendGroupMessage' user gInfo recipients XGrpLeave - (gInfo', scopeInfo) <- liftIO $ mkLocalGroupChatScope gInfo + (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] -- TODO delete direct connections that were unused diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 897c36fd6a..13e58eb36d 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1323,7 +1323,7 @@ getGroupRecipients vr user gInfo@GroupInfo {membership} scope modsCompatVersion compatible GroupMember {activeConn, memberChatVRange} = maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= modsCompatVersion -mkLocalGroupChatScope :: GroupInfo -> IO (GroupInfo, Maybe GroupChatScopeInfo) +mkLocalGroupChatScope :: GroupInfo -> CM (GroupInfo, Maybe GroupChatScopeInfo) mkLocalGroupChatScope gInfo@GroupInfo {membership} | memberPending membership = do (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo @@ -1331,7 +1331,7 @@ mkLocalGroupChatScope gInfo@GroupInfo {membership} | otherwise = pure (gInfo, Nothing) -mkGroupChatScope :: GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGroupChatScope :: GroupInfo -> GroupMember -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) mkGroupChatScope gInfo@GroupInfo {membership} m | memberPending membership = do (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo @@ -1344,38 +1344,40 @@ mkGroupChatScope gInfo@GroupInfo {membership} m mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ = - liftIO (mkGroupChatScope gInfo m) >>= \case + mkGroupChatScope gInfo m >>= \case groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope (_, _, Nothing) -> case msgScope_ of Nothing -> pure (gInfo, m, Nothing) Just (MSMember mId) | sameMemberId mId membership -> do - (gInfo', scopeInfo) <- liftIO $ mkGroupSupportChatInfo gInfo + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo pure (gInfo', m, Just scopeInfo) | otherwise -> do referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId -- TODO [knocking] return patched _referredMember' too? - (_referredMember', scopeInfo) <- liftIO $ mkMemberSupportChatInfo referredMember + (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember pure (gInfo, m, Just scopeInfo) -mkGroupSupportChatInfo :: GroupInfo -> IO (GroupInfo, GroupChatScopeInfo) +mkGroupSupportChatInfo :: GroupInfo -> CM (GroupInfo, GroupChatScopeInfo) mkGroupSupportChatInfo gInfo@GroupInfo {membership} = case supportChat membership of Nothing -> do - chatTs <- getCurrentTime - let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 1 0 0}} + chatTs <- liftIO getCurrentTime + withStore' $ \db -> setSupportChatTs db (groupMemberId' membership) chatTs + let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 0 0 0}} scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} pure (gInfo', scopeInfo) Just _supportChat -> let scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} in pure (gInfo, scopeInfo) -mkMemberSupportChatInfo :: GroupMember -> IO (GroupMember, GroupChatScopeInfo) -mkMemberSupportChatInfo m@GroupMember {supportChat} = +mkMemberSupportChatInfo :: GroupMember -> CM (GroupMember, GroupChatScopeInfo) +mkMemberSupportChatInfo m@GroupMember {groupMemberId, supportChat} = case supportChat of Nothing -> do - chatTs <- getCurrentTime - let m' = m {supportChat = Just $ GroupSupportChat chatTs 1 0 0} + chatTs <- liftIO getCurrentTime + withStore' $ \db -> setSupportChatTs db groupMemberId chatTs + let m' = m {supportChat = Just $ GroupSupportChat chatTs 0 0 0} scopeInfo = GCSIMemberSupport {groupMember_ = Just m'} pure (m', scopeInfo) Just _supportChat -> diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index bdd481d48b..41d7bdad72 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -777,7 +777,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure $ memberStatus membership pure (GSMemConnected, membershipStatus) toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = membershipStatus}} m {memberStatus = mStatus} - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m let cd = CDGroupRcv gInfo' scopeInfo m' createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing createGroupFeatureItems user cd CIRcvGroupFeature gInfo' @@ -788,7 +788,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if not (memberPending m) then withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected else pure $ memberStatus m - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m memberConnectedChatItem gInfo' scopeInfo m' toView $ CRJoinedGroupMember user gInfo' m' {memberStatus = mStatus} let Connection {viaUserContactLink} = conn @@ -843,7 +843,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = tags <- newTVarIO [] withAckMessage "group msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do -- possible improvement is to choose scope based on event (some events specify scope) - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> @@ -936,12 +936,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when continued $ sendPendingGroupMessages user m conn SWITCH qd phase cStats -> do toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m when (phase == SPStarted || phase == SPCompleted) $ case qd of QDRcv -> createInternalChatItem user (CDGroupSnd gInfo' scopeInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m') Nothing QDSnd -> createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvConnEvent $ RCESwitchQueue phase) Nothing RSYNC rss cryptoErr_ cStats -> do - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m case (rss, connectionCode, cryptoErr_) of (RSRequired, _, Just cryptoErr) -> processErr gInfo' scopeInfo m' cryptoErr (RSAllowed, _, Just cryptoErr) -> processErr gInfo' scopeInfo m' cryptoErr @@ -1234,7 +1234,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo mem <- acceptGroupJoinRequestAsync user gInfo cReq acceptance useRole profileMode - (gInfo', mem', scopeInfo) <- liftIO $ mkGroupChatScope gInfo mem + (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CRAcceptingGroupJoinRequestMember user gInfo' mem' Left rjctReason @@ -1379,7 +1379,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> CM () notifyMemberConnected gInfo m ct_ = do - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m memberConnectedChatItem gInfo' scopeInfo m' lift $ mapM_ (`setContactNetworkStatus` NSConnected) ct_ toView $ CRConnectedToGroupMember user gInfo' m' ct_ @@ -2165,7 +2165,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = BCCustomer -> customerId == memberId createProfileUpdatedItem m' = when createItems $ do - (gInfo', m'', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m' + (gInfo', m'', scopeInfo) <- mkGroupChatScope gInfo m' let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p' createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m'') ciContent itemTs_ @@ -2458,7 +2458,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just (MSMember _) -> GSMemPendingReview _ -> GSMemAnnounced memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do - (announcedMember', scopeInfo) <- liftIO $ getMemNewChatScope announcedMember + (announcedMember', scopeInfo) <- getMemNewChatScope announcedMember let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent event) groupMsgToView gInfo scopeInfo ci @@ -2555,7 +2555,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | senderRole < GRAdmin || senderRole < fromRole = messageError "x.grp.mem.role with insufficient member permissions" | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole - (gInfo'', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo' m + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView gInfo'' scopeInfo ci toView CRMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} @@ -2584,7 +2584,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = bm' <- setMemberBlocked bm toggleNtf user bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent groupMsgToView gInfo' scopeInfo ci toView CRMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm, blocked} @@ -2669,7 +2669,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.del with insufficient member permissions" | otherwise = a deleteMemberItem gEvent = do - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView gInfo' scopeInfo ci deleteMessages :: MsgDirectionI d => GroupMember -> SMsgDirection d -> CM () @@ -2682,7 +2682,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = deleteMemberConnection user m -- member record is not deleted to allow creation of "member left" chat item withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView gInfo' scopeInfo ci toView $ CRLeftMember user gInfo' m' {memberStatus = GSMemLeft} @@ -2696,7 +2696,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure members -- member records are not deleted to keep history deleteMembersConnections user ms - (gInfo'', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView gInfo'' scopeInfo ci toView $ CRGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' @@ -2707,7 +2707,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = case businessChat of Nothing -> unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' - (g'', m', scopeInfo) <- liftIO $ mkGroupChatScope g' m + (g'', m', scopeInfo) <- mkGroupChatScope g' m toView $ CRGroupUpdated user g g'' (Just m') let cd = CDGroupRcv g'' scopeInfo m' unless (sameGroupProfileInfo p p') $ do @@ -2726,7 +2726,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (groupPreferences p == Just ps') $ do g' <- withStore' $ \db -> updateGroupPreferences db user g ps' toView $ CRGroupUpdated user g g' (Just m) - (g'', m', scopeInfo) <- liftIO $ mkGroupChatScope g' m + (g'', m', scopeInfo) <- mkGroupChatScope g' m let cd = CDGroupRcv g'' scopeInfo m' createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' @@ -2768,7 +2768,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = dm <- encodeConnInfo $ XInfo p joinAgentConnectionAsync user True connReq dm subMode createItems mCt' m' = do - (g', m'', scopeInfo) <- liftIO $ mkGroupChatScope g m' + (g', m'', scopeInfo) <- mkGroupChatScope g m' createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CRNewMemberContactReceivedInv user mCt' g' m'' forM_ mContent_ $ \mc -> do @@ -2827,7 +2827,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do - (gInfo', m', scopeInfo) <- liftIO $ mkGroupChatScope gInfo m + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 5db180aec9..24f598a7e6 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -321,7 +321,7 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do -- | creates completely new group with a single member - the current user createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile @@ -329,8 +329,8 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -390,7 +390,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) createGroupInvitation_ :: ExceptT StoreError IO (GroupInfo, GroupMemberId) createGroupInvitation_ = do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences ExceptT $ withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do @@ -398,8 +398,8 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db @@ -558,13 +558,13 @@ createGroupViaLink' (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where insertGroup_ currentTs = ExceptT $ do - let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + let GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) profileId <- insertedRowId db DB.execute db diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 54d40794b0..d1daace916 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -37,6 +37,7 @@ module Simplex.Chat.Store.Messages deleteOldMessages, MemberAttention (..), updateChatTsStats, + setSupportChatTs, createNewSndChatItem, createNewRcvChatItem, createNewChatItemNoMsg, @@ -419,6 +420,10 @@ updateChatTsStats db User {userId} chatDirection chatTs chatStats_ = case toChat (chatTs, userId, noteFolderId) _ -> pure () +setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () +setSupportChatTs db groupMemberId chatTs = + DB.execute db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId) + createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt @@ -750,7 +755,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -1812,14 +1817,22 @@ getLocalNavInfo_ db User {userId} NoteFolder {noteFolderId} afterCI = do :. (userId, noteFolderId, ciCreatedAt afterCI, cChatItemId afterCI) ) -toChatItemRef :: (ChatItemId, Maybe Int64, Maybe Int64, Maybe Int64) -> Either StoreError (ChatRef, ChatItemId) +toChatItemRef :: + (ChatItemId, Maybe ContactId, Maybe GroupId, Maybe GroupChatScopeTag, Maybe GroupMemberId, Maybe NoteFolderId) -> + Either StoreError (ChatRef, ChatItemId) toChatItemRef = \case - (itemId, Just contactId, Nothing, Nothing) -> Right (ChatRef CTDirect contactId Nothing, itemId) - -- For "chat item refs" we don't care about scope, as they are only used for getting item by id (via getAChatItem). - -- To get the scope without joining group_members, we could additionally store group scope "tag" to differentiate member support chats. - (itemId, Nothing, Just groupId, Nothing) -> Right (ChatRef CTGroup groupId Nothing, itemId) - (itemId, Nothing, Nothing, Just folderId) -> Right (ChatRef CTLocal folderId Nothing, itemId) - (itemId, _, _, _) -> Left $ SEBadChatItem itemId Nothing + (itemId, Just contactId, Nothing, Nothing, Nothing, Nothing) -> + Right (ChatRef CTDirect contactId Nothing, itemId) + (itemId, Nothing, Just groupId, Nothing, Nothing, Nothing) -> + Right (ChatRef CTGroup groupId Nothing, itemId) + (itemId, Nothing, Just groupId, Just GCSTMemberSupport_, Nothing, Nothing) -> + Right (ChatRef CTGroup groupId (Just (GCSMemberSupport Nothing)), itemId) + (itemId, Nothing, Just groupId, Just GCSTMemberSupport_, Just scopeGMId, Nothing) -> + Right (ChatRef CTGroup groupId (Just (GCSMemberSupport $ Just scopeGMId)), itemId) + (itemId, Nothing, Nothing, Nothing, Nothing, Just folderId) -> + Right (ChatRef CTLocal folderId Nothing, itemId) + (itemId, _, _, _, _, _) -> + Left $ SEBadChatItem itemId Nothing updateDirectChatItemsRead :: DB.Connection -> User -> ContactId -> IO () updateDirectChatItemsRead db User {userId} contactId = do @@ -2170,7 +2183,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do <$> DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC @@ -2181,7 +2194,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) @@ -2194,7 +2207,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do <$> DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) @@ -2206,7 +2219,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do DB.query db [sql| - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE chat_item_id = ? |] @@ -2952,7 +2965,7 @@ getChatItemByFileId db vr user@User {userId} fileId = do DB.query db [sql| - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i JOIN files f ON f.chat_item_id = i.chat_item_id WHERE f.user_id = ? AND f.file_id = ? @@ -2974,7 +2987,7 @@ getChatItemByGroupId db vr user@User {userId} groupId = do DB.query db [sql| - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i JOIN groups g ON g.chat_item_id = i.chat_item_id WHERE g.user_id = ? AND g.group_id = ? diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 0a11f668ff..95795c6aa7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -462,7 +462,7 @@ SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) Query: - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts < ? OR (item_ts = ? AND chat_item_id < ?)) @@ -474,7 +474,7 @@ SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY Query: - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' ORDER BY item_ts DESC, chat_item_id DESC @@ -578,7 +578,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) LEFT-JOIN Query: - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i JOIN files f ON f.chat_item_id = i.chat_item_id WHERE f.user_id = ? AND f.file_id = ? @@ -1014,7 +1014,7 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_group_id (group_id=?) Query: - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE chat_item_id = ? @@ -1022,7 +1022,7 @@ Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT chat_item_id, contact_id, group_id, note_folder_id + SELECT chat_item_id, contact_id, group_id, group_scope_tag, group_scope_group_member_id, note_folder_id FROM chat_items WHERE user_id = ? AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' AND (item_ts > ? OR (item_ts = ? AND chat_item_id > ?)) @@ -1179,7 +1179,7 @@ SEARCH i USING INDEX idx_chat_items_group_id (group_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT i.chat_item_id, i.contact_id, i.group_id, i.note_folder_id + SELECT i.chat_item_id, i.contact_id, i.group_id, i.group_scope_tag, i.group_scope_group_member_id, i.note_folder_id FROM chat_items i JOIN groups g ON g.chat_item_id = i.chat_item_id WHERE g.user_id = ? AND g.group_id = ? @@ -1952,7 +1952,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -1995,7 +1995,7 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2004,7 +2004,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2041,7 +2041,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2050,7 +2050,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2086,7 +2086,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2140,7 +2140,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2176,7 +2176,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2185,7 +2185,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2221,7 +2221,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2275,7 +2275,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2311,7 +2311,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2320,7 +2320,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2353,7 +2353,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -2404,7 +2404,7 @@ Query: ( SELECT chat_item_id FROM chat_items ci - WHERE ci.user_id = ? AND ci.group_id = g.group_id + WHERE ci.user_id = ? AND ci.group_id = g.group_id AND ci.group_scope_tag IS NULL ORDER BY ci.item_ts DESC LIMIT 1 ) AS chat_item_id, @@ -2437,7 +2437,7 @@ SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 -SEARCH ci USING COVERING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) +SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) Query: SELECT @@ -5460,7 +5460,7 @@ Plan: Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?) +Query: INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO group_profiles (display_name, full_name, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 49660b99ba..60bd409067 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1858,7 +1858,7 @@ $(JQ.deriveJSON defaultJSON ''LocalProfile) $(JQ.deriveJSON defaultJSON ''UserContactRequest) -$(JQ.deriveJSON (enumJSON $ dropPrefix "MC") ''MemberCriteria) +$(JQ.deriveJSON (enumJSON $ dropPrefix "MC") {J.tagSingleConstructors = True} ''MemberCriteria) $(JQ.deriveJSON defaultJSON ''GroupMemberAdmission) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 00d25b533f..9bee2da58c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -6955,6 +6955,11 @@ testScopedSupportManyModerators = alice <# "#team (support: bob) dan> 5" bob <# "#team (support) dan> 5" + alice @@@ [("#team","2"), ("@dan","sent invitation to join group team as moderator"), ("@cath","sent invitation to join group team as member"), ("@bob","sent invitation to join group team as member")] + bob @@@ [("#team","2"), ("@alice","received invitation to join group team as member")] + dan @@@ [("#team","2"), ("@alice","received invitation to join group team as moderator")] + cath @@@ [("#team","2"), ("@alice","received invitation to join group team as member")] + alice #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (1, "1"), (0, "2")]) alice #$> ("/_get chat #1(_support:2) count=100", chat, [(1, "3"), (0, "4"), (0, "5")]) bob #$> ("/_get chat #1 count=3", chat, [(0, "connected"), (0, "1"), (1, "2")])