From 94815bf644a99836813ef28045bd98cda4c60458 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 11 Jan 2025 02:41:33 +0700 Subject: [PATCH] android, desktop: reports dashboard (#5471) * android, desktop: reports dashboard * changes * changes * unneeded updates and fixes * changes * api change * item moderated/deleted * a lot of changes * changes * reports tag and icon in ChatList * archived by * increasing counter when new report arrives * refactor * groupInfo button and closing when needed * fix archived by * reorder * simplify * rename * filled flag * Revert "filled flag" This reverts commit 8b5da851018dcf0f6ab5d3e64ea84daaea75aebf. * removed support of archived page and counter * fix closing modal * show search button in bar without menu * removed content filter * no icon * Revert "no icon" This reverts commit 86c725b53ecb1e0373769940e08efe6602112bb8. * fix tags * unlogs * unlogs * chat item statuses * background color * refactor * refactor --------- Co-authored-by: Evgeny Poberezkin --- .../platform/ScrollableColumn.android.kt | 2 + .../simplex/common/platform/UI.android.kt | 3 + .../kotlin/chat/simplex/common/App.kt | 4 +- .../chat/simplex/common/model/ChatModel.kt | 402 +++++++++------ .../chat/simplex/common/model/SimpleXAPI.kt | 157 +++++- .../common/platform/ScrollableColumn.kt | 2 + .../chat/simplex/common/views/TerminalView.kt | 5 +- .../simplex/common/views/chat/ChatInfoView.kt | 1 + .../common/views/chat/ChatItemsLoader.kt | 36 +- .../common/views/chat/ChatItemsMerger.kt | 6 +- .../simplex/common/views/chat/ChatView.kt | 461 +++++++++++++----- .../simplex/common/views/chat/ComposeView.kt | 2 +- .../views/chat/SelectableChatItemToolbars.kt | 11 +- .../views/chat/group/AddGroupMembersView.kt | 4 + .../views/chat/group/GroupChatInfoView.kt | 29 +- .../views/chat/group/GroupMemberInfoView.kt | 45 +- .../views/chat/group/GroupPreferences.kt | 3 + .../views/chat/group/GroupProfileView.kt | 1 + .../views/chat/group/GroupReportsView.kt | 106 ++++ .../views/chat/group/WelcomeMessageView.kt | 1 + .../views/chat/item/CIChatFeatureView.kt | 5 +- .../common/views/chat/item/ChatItemView.kt | 23 +- .../common/views/chat/item/FramedItemView.kt | 98 ++-- .../views/chat/item/MarkedDeletedItemView.kt | 26 +- .../views/chatlist/ChatListNavLinkView.kt | 54 +- .../common/views/chatlist/ChatListView.kt | 28 +- .../common/views/chatlist/ChatPreviewView.kt | 16 +- .../common/views/chatlist/TagListView.kt | 3 +- .../common/views/database/DatabaseView.kt | 6 + .../common/views/helpers/DefaultTopAppBar.kt | 3 +- .../simplex/common/views/helpers/ModalView.kt | 41 +- .../common/views/newchat/AddGroupView.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 6 + .../resources/MR/images/ic_flag_filled.svg | 1 + .../kotlin/chat/simplex/common/DesktopApp.kt | 4 + .../platform/ScrollableColumn.desktop.kt | 10 +- 36 files changed, 1171 insertions(+), 436 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt index 60197f3851..b3d8e9b52f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/ScrollableColumn.android.kt @@ -29,6 +29,7 @@ actual fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, fillMaxSize: Boolean, content: LazyListScope.() -> Unit @@ -92,6 +93,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, content: LazyListScope.() -> Unit ) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index 1a4d0b72e9..a1698ae28a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -81,6 +81,9 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { chatModel.chatId.value = null chatItems.clearAndNotify() } + withChats { + chatItems.clearAndNotify() + } } } else { // ChatList, nothing to do. Maybe to show other view except ChatList diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 1542c35e58..ba1eda8a7c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow) { .graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() } ) Box2@{ currentChatId.value?.let { - ChatView(currentChatId, onComposed) + ChatView(currentChatId, reportsView = false, onComposed = onComposed) } } } @@ -393,7 +393,7 @@ fun CenterPartOfScreen() { ModalManager.center.showInView() } } - else -> ChatView(currentChatId) {} + else -> ChatView(currentChatId, reportsView = false) {} } } 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 4c6d693f63..a9599cebc3 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 @@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration -import chat.simplex.common.model.ChatModel.chatItemsChangesListener import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* @@ -59,28 +58,18 @@ object ChatModel { val ctrlInitInProgress = mutableStateOf(false) val dbMigrationInProgress = mutableStateOf(false) val incompleteInitializedDbRemoved = mutableStateOf(false) - private val _chats = mutableStateOf(SnapshotStateList()) - val chats: State> = _chats // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) // current chat val chatId = mutableStateOf(null) - /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [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]. - * If you use api call to get the items, use just [add] instead of [addAndNotify]. - * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ - private val _chatItems = mutableStateOf(SnapshotStateList()) - val chatItems: State> = _chatItems - // declaration of chatsContext should be after any other variable that is directly attached to ChatsContext class, otherwise, strange crash with NullPointerException for "this" parameter in random functions - private val chatsContext = ChatsContext() - // set listener here that will be notified on every add/delete of a chat item - var chatItemsChangesListener: ChatItemsChangesListener? = null - val chatState = ActiveChatState() + val chatsContext = ChatsContext(null) + val reportsChatsContext = ChatsContext(MsgContentTag.Report) + // declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions + val chats: State> = chatsContext.chats // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) - val chatItemStatuses = mutableMapOf() val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) @@ -178,6 +167,36 @@ object ChatModel { // return true if you handled the click var centerPanelBackgroundClickHandler: (() -> Boolean)? = null + fun chatsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { + null -> chatsContext.chats + MsgContentTag.Report -> reportsChatsContext.chats + else -> TODO() + } + + fun chatItemsForContent(contentTag: MsgContentTag?): State> = when(contentTag) { + null -> chatsContext.chatItems + MsgContentTag.Report -> reportsChatsContext.chatItems + else -> TODO() + } + + fun chatStateForContent(contentTag: MsgContentTag?): ActiveChatState = when(contentTag) { + null -> chatsContext.chatState + MsgContentTag.Report -> reportsChatsContext.chatState + else -> TODO() + } + + fun chatItemsChangesListenerForContent(contentTag: MsgContentTag?): ChatItemsChangesListener? = when(contentTag) { + null -> chatsContext.chatItemsChangesListener + MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener + else -> TODO() + } + + fun setChatItemsChangeListenerForContent(listener: ChatItemsChangesListener?, contentTag: MsgContentTag?) = when(contentTag) { + null -> chatsContext.chatItemsChangesListener = listener + MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener = listener + else -> TODO() + } + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -210,7 +229,7 @@ object ChatModel { for (chat in chats.value.filter { it.remoteHostId == rhId }) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chat.chatInfo)) { + if (presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)) { newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1 } } @@ -250,17 +269,17 @@ object ChatModel { } } - fun addPresetChatTags(chatInfo: ChatInfo) { + private fun addPresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chatInfo)) { + if (presetTagMatchesChat(tag, chatInfo, chatStats)) { presetTags[tag] = (presetTags[tag] ?: 0) + 1 } } } - fun removePresetChatTags(chatInfo: ChatInfo) { + fun removePresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) { for (tag in PresetTagKind.entries) { - if (presetTagMatchesChat(tag, chatInfo)) { + if (presetTagMatchesChat(tag, chatInfo, chatStats)) { val count = presetTags[tag] if (count != null) { presetTags[tag] = maxOf(0, count - 1) @@ -269,27 +288,6 @@ object ChatModel { } } - fun markChatTagRead(chat: Chat) { - if (chat.unreadTag) { - chat.chatInfo.chatTags?.let { tags -> - markChatTagRead_(chat, tags) - } - } - } - - fun updateChatTagRead(chat: Chat, wasUnread: Boolean) { - val tags = chat.chatInfo.chatTags ?: return - val nowUnread = chat.unreadTag - - if (nowUnread && !wasUnread) { - tags.forEach { tag -> - unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 - } - } else if (!nowUnread && wasUnread) { - markChatTagRead_(chat, tags) - } - } - fun moveChatTagUnread(chat: Chat, oldTags: List?, newTags: List) { if (chat.unreadTag) { oldTags?.forEach { t -> @@ -304,18 +302,6 @@ object ChatModel { } } } - - private fun markChatTagRead_(chat: Chat, tags: List) { - for (tag in tags) { - val count = unreadTags[tag] - if (count != null) { - unreadTags[tag] = maxOf(0, count - 1) - } - } - } - - // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens - fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null // TODO pass rhId? fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } @@ -340,13 +326,35 @@ object ChatModel { } // running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread - suspend fun withChats(action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) { - chatsContext.action() + suspend fun withChats(contentTag: MsgContentTag? = null, action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) { + when { + contentTag == null -> chatsContext.action() + contentTag == MsgContentTag.Report -> reportsChatsContext.action() + else -> TODO() + } } - class ChatsContext { - val chats = _chats - val chatItems = _chatItems + suspend fun withReportsChatsIfOpen(action: suspend ChatsContext.() -> T) = withContext(Dispatchers.Main) { + if (ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { + reportsChatsContext.action() + } + } + + class ChatsContext(private val contentTag: MsgContentTag?) { + val chats = mutableStateOf(SnapshotStateList()) + /** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [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]. + * If you use api call to get the items, use just [add] instead of [addAndNotify]. + * Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */ + val chatItems = mutableStateOf(SnapshotStateList()) + val chatItemStatuses = mutableMapOf() + // set listener here that will be notified on every add/delete of a chat item + var chatItemsChangesListener: ChatItemsChangesListener? = null + val chatState = ActiveChatState() + + fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null + 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 } suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) @@ -385,6 +393,13 @@ object ChatModel { } } + fun updateChatStats(rhId: Long?, chatId: ChatId, chatStats: Chat.ChatStats) { + val i = getChatIndex(rhId, chatId) + if (i >= 0) { + chats[i] = chats[i].copy(chatStats = chatStats) + } + } + suspend fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection)) suspend fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed) @@ -402,7 +417,7 @@ object ChatModel { updateChatInfo(rhId, cInfo) } else if (addMissing) { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) - addPresetChatTags(cInfo) + addPresetChatTags(cInfo, Chat.ChatStats()) } } @@ -463,7 +478,7 @@ object ChatModel { else chat.chatStats ) - updateChatTagRead(chats[i], wasUnread) + updateChatTagReadNoContentTag(chats[i], wasUnread) if (appPlatform.isDesktop && cItem.chatDir.sent) { reorderChat(chats[i], 0) @@ -479,9 +494,9 @@ object ChatModel { // 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) { - chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem) + chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem, contentTag) } else { - chatItems.addAndNotify(cItem) + chatItems.addAndNotify(cItem, contentTag) } } } @@ -500,7 +515,7 @@ object ChatModel { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) if (pItem.isRcvNew && !cItem.isRcvNew) { // status changed from New to Read, update counter - decreaseCounterInChat(rhId, cInfo.id) + decreaseCounterInChatNoContentTag(rhId, cInfo.id) } } res = false @@ -526,7 +541,7 @@ object ChatModel { } else { cItem } - chatItems.addAndNotify(ci) + chatItems.addAndNotify(ci, contentTag) true } } else { @@ -551,7 +566,7 @@ object ChatModel { fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { if (cItem.isRcvNew) { - decreaseCounterInChat(rhId, cInfo.id) + decreaseCounterInChatNoContentTag(rhId, cInfo.id) } // update previews val i = getChatIndex(rhId, cInfo.id) @@ -590,9 +605,9 @@ object ChatModel { } } - val popChatCollector = PopChatCollector() + val popChatCollector = PopChatCollector(contentTag) - class PopChatCollector { + class PopChatCollector(contentTag: MsgContentTag?) { private val subject = MutableSharedFlow() private var remoteHostId: Long? = null private val chatsToPop = mutableMapOf() @@ -602,7 +617,7 @@ object ChatModel { subject .throttleLatest(2000) .collect { - withChats { + withChats(contentTag) { chats.replaceAll(popCollectedChats()) } } @@ -640,11 +655,10 @@ object ChatModel { } } - fun markChatItemsRead(remoteHostId: Long?, chatInfo: ChatInfo, itemIds: List? = null) { - val cInfo = chatInfo - val markedRead = markItemsReadInCurrentChat(chatInfo, itemIds) + fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List? = null) { + val markedRead = markItemsReadInCurrentChat(id, itemIds) // update preview - val chatIdx = getChatIndex(remoteHostId, cInfo.id) + val chatIdx = getChatIndex(remoteHostId, id) if (chatIdx >= 0) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id @@ -655,12 +669,47 @@ object ChatModel { chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount) ) - updateChatTagRead(chats[chatIdx], wasUnread) + updateChatTagReadNoContentTag(chats[chatIdx], wasUnread) } } } - private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) { + private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List? = null): Int { + var markedRead = 0 + if (chatId.value == id) { + val items = chatItems.value + var i = items.lastIndex + val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf() + val markedReadIds = mutableSetOf() + while (i >= 0) { + val item = items[i] + if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) { + val newItem = item.withStatus(CIStatus.RcvRead()) + items[i] = newItem + if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { + items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( + deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) + ) + } + markedReadIds.add(item.id) + markedRead++ + if (itemIds != null) { + itemIdsFromRange.remove(item.id) + // already set all needed items as read, can finish the loop + if (itemIdsFromRange.isEmpty()) break + } + } + i-- + } + chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) + } + return markedRead + } + + private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + val chatIndex = getChatIndex(rhId, chatId) if (chatIndex == -1) return @@ -673,21 +722,21 @@ object ChatModel { unreadCount = unreadCount, ) ) - updateChatTagRead(chats[chatIndex], wasUnread) + updateChatTagReadNoContentTag(chats[chatIndex], wasUnread) } fun removeChat(rhId: Long?, id: String) { - var removed: ChatInfo? = null + var removed: Chat? = null chats.removeAll { val found = it.id == id && it.remoteHostId == rhId if (found) { - removed = it.chatInfo + removed = it } found } removed?.let { - removePresetChatTags(it) + removePresetChatTags(it.chatInfo, it.chatStats) } } @@ -741,9 +790,92 @@ object ChatModel { upsertGroupMember(rhId, groupInfo, updatedMember) } } - } - private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } + fun increaseUnreadCounter(rhId: Long?, user: UserLike) { + changeUnreadCounterNoContentTag(rhId, user, 1) + } + + fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { + changeUnreadCounterNoContentTag(rhId, user, -by) + } + + private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } + if (i != -1) { + users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) + } + } + + fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + val tags = chat.chatInfo.chatTags ?: return + val nowUnread = chat.unreadTag + + if (nowUnread && !wasUnread) { + tags.forEach { tag -> + unreadTags[tag] = (unreadTags[tag] ?: 0) + 1 + } + } else if (!nowUnread && wasUnread) { + markChatTagReadNoContentTag_(chat, tags) + } + } + + fun markChatTagRead(chat: Chat) { + if (chat.unreadTag) { + chat.chatInfo.chatTags?.let { tags -> + markChatTagReadNoContentTag_(chat, tags) + } + } + } + + private fun markChatTagReadNoContentTag_(chat: Chat, tags: List) { + // updates anything only in main ChatView, not GroupReportsView or anything else from the future + if (contentTag != null) return + + for (tag in tags) { + val count = unreadTags[tag] + if (count != null) { + unreadTags[tag] = maxOf(0, count - 1) + } + } + } + + fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { + changeGroupReportsCounter(rhId, chatId, 1) + } + + fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { + changeGroupReportsCounter(rhId, chatId, -1) + } + + private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { + if (by == 0) return + + val i = getChatIndex(rhId, chatId) + if (i >= 0) { + val chat = chats.value[i] + chats[i] = chat.copy( + chatStats = chat.chatStats.copy( + reportsCount = (chat.chatStats.reportsCount + by).coerceAtLeast(0), + ) + ) + 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) + } + } + + private fun changeGroupReportsTagNoContentTag(by: Int = 0) { + if (by == 0 || contentTag != null) return + presetTags[PresetTagKind.GROUP_REPORTS] = (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by + } + } fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return @@ -773,82 +905,32 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) withChats { - chatItems.addAndNotify(cItem) + chatItems.addAndNotify(cItem, contentTag = null) } return cItem } fun removeLiveDummy() { - if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + if (chatItemsForContent(null).value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { withApi { withChats { - chatItems.removeLastAndNotify() + chatItems.removeLastAndNotify(contentTag = null) } } } } - private fun markItemsReadInCurrentChat(chatInfo: ChatInfo, itemIds: List? = null): Int { - val cInfo = chatInfo - var markedRead = 0 - if (chatId.value == cInfo.id) { - val items = chatItems.value - var i = items.lastIndex - val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf() - val markedReadIds = mutableSetOf() - while (i >= 0) { - val item = items[i] - if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) { - val newItem = item.withStatus(CIStatus.RcvRead()) - items[i] = newItem - if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { - items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( - deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) - ) - } - markedReadIds.add(item.id) - markedRead++ - if (itemIds != null) { - itemIdsFromRange.remove(item.id) - // already set all needed items as read, can finish the loop - if (itemIdsFromRange.isEmpty()) break - } - } - i-- - } - chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items) - } - return markedRead - } - - fun increaseUnreadCounter(rhId: Long?, user: UserLike) { - changeUnreadCounter(rhId, user, 1) - } - - fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { - changeUnreadCounter(rhId, user, -by) - } - - private fun changeUnreadCounter(rhId: Long?, user: UserLike, by: Int) { - val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId } - if (i != -1) { - users[i] = users[i].copy(unreadCount = users[i].unreadCount + by) - } - } - - fun getChatItemIndexOrNull(cItem: ChatItem): Int? { - val reversedChatItems = chatItems.asReversed() + fun getChatItemIndexOrNull(cItem: ChatItem, reversedChatItems: List): Int? { val index = reversedChatItems.indexOfFirst { it.id == cItem.id } return if (index != -1) index else null } // this function analyses "connected" events and assumes that each member will be there only once - fun getConnectedMemberNames(cItem: ChatItem): Pair> { + fun getConnectedMemberNames(cItem: ChatItem, reversedChatItems: List): Pair> { var count = 0 val ns = mutableListOf() - var idx = getChatItemIndexOrNull(cItem) + var idx = getChatItemIndexOrNull(cItem, reversedChatItems) if (cItem.mergeCategory != null && idx != null) { - val reversedChatItems = chatItems.asReversed() while (idx < reversedChatItems.size) { val ci = reversedChatItems[idx] if (ci.mergeCategory != cItem.mergeCategory) break @@ -865,9 +947,8 @@ object ChatModel { // returns the index of the first item in the same merged group (the first hidden item) // and the previous visible item with another merge category - fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair { + fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?, reversedChatItems: List): Pair { var i = ciIndex ?: return null to null - val reversedChatItems = chatItems.asReversed() val fst = reversedChatItems.lastIndex while (i < fst) { i++ @@ -880,8 +961,7 @@ object ChatModel { } // returns the previous member in the same merge group and the count of members in this group - fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair { - val reversedChatItems = chatItems.asReversed() + fun getPrevHiddenMember(member: GroupMember, range: IntRange, reversedChatItems: List): Pair { var prevMember: GroupMember? = null val names: MutableSet = mutableSetOf() for (i in range) { @@ -1157,7 +1237,13 @@ data class Chat( } @Serializable - data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false) + data class ChatStats( + val unreadCount: Int = 0, + // actual only via getChats() and getChat(.initial), otherwise, zero + val reportsCount: Int = 0, + val minUnreadItemId: Long = 0, + val unreadChat: Boolean = false + ) companion object { val sampleData = Chat( @@ -1677,6 +1763,9 @@ data class GroupInfo ( val canAddMembers: Boolean get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive + val canModerate: Boolean + get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive + companion object { val sampleData = GroupInfo( groupId = 1, @@ -2310,6 +2399,8 @@ data class ChatItem ( else -> false } + val isActiveReport: Boolean get() = isReport && !isDeletedContent && meta.itemDeleted == null + val canBeDeletedForSelf: Boolean get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete @@ -2526,8 +2617,8 @@ fun MutableState>.add(index: Int, elem: Chat) { value = SnapshotStateList().apply { addAll(value); add(index, elem) } } -fun MutableState>.addAndNotify(index: Int, elem: ChatItem) { - value = SnapshotStateList().apply { addAll(value); add(index, elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) } +fun MutableState>.addAndNotify(index: Int, elem: ChatItem, contentTag: MsgContentTag?) { + value = SnapshotStateList().apply { addAll(value); add(index, elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, index) } } fun MutableState>.add(elem: Chat) { @@ -2538,8 +2629,8 @@ fun MutableState>.add(elem: Chat) { fun MutableList.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate) // Adds item to chatItems and notifies a listener about newly added item -fun MutableState>.addAndNotify(elem: ChatItem) { - value = SnapshotStateList().apply { addAll(value); add(elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) } +fun MutableState>.addAndNotify(elem: ChatItem, contentTag: MsgContentTag?) { + value = SnapshotStateList().apply { addAll(value); add(elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, lastIndex) } } fun MutableState>.addAll(index: Int, elems: List) { @@ -2568,7 +2659,8 @@ fun MutableState>.removeAllAndNotify(block: (ChatIte } } if (toRemove.isNotEmpty()) { - chatItemsChangesListener?.removed(toRemove, value) + chatModel.chatsContext.chatItemsChangesListener?.removed(toRemove, value) + chatModel.reportsChatsContext.chatItemsChangesListener?.removed(toRemove, value) } } @@ -2580,7 +2672,7 @@ fun MutableState>.removeAt(index: Int): Chat { return res } -fun MutableState>.removeLastAndNotify() { +fun MutableState>.removeLastAndNotify(contentTag: MsgContentTag?) { val removed: Triple value = SnapshotStateList().apply { addAll(value) @@ -2588,7 +2680,7 @@ fun MutableState>.removeLastAndNotify() { val rem = removeLast() removed = Triple(rem.id, remIndex, rem.isRcvNew) } - chatItemsChangesListener?.removed(listOf(removed), value) + chatModel.chatItemsChangesListenerForContent(contentTag)?.removed(listOf(removed), value) } fun MutableState>.replaceAll(elems: List) { @@ -2602,7 +2694,8 @@ fun MutableState>.clear() { // Removes all chatItems and notifies a listener about it fun MutableState>.clearAndNotify() { value = SnapshotStateList() - chatItemsChangesListener?.cleared() + chatModel.chatsContext.chatItemsChangesListener?.cleared() + chatModel.reportsChatsContext.chatItemsChangesListener?.cleared() } fun State>.asReversed(): MutableList = value.asReversed() @@ -3688,6 +3781,17 @@ object MsgContentSerializer : KSerializer { } } +@Serializable +enum class MsgContentTag { + @SerialName("text") Text, + @SerialName("link") Link, + @SerialName("image") Image, + @SerialName("video") Video, + @SerialName("voice") Voice, + @SerialName("file") File, + @SerialName("report") Report, +} + @Serializable class FormattedText(val text: String, val format: Format? = null) { // TODO make it dependent on simplexLinkMode preference 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 4630d77aa8..f2bd8283bb 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 @@ -18,6 +18,7 @@ import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -893,8 +894,8 @@ object ChatController { return null } - suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair? { - val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search)) + 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)) 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) { @@ -1497,6 +1498,9 @@ object ChatController { withChats { clearChat(chat.remoteHostId, updatedChatInfo) } + withChats(MsgContentTag.Report) { + clearChat(chat.remoteHostId, updatedChatInfo) + } ntfManager.cancelNotificationsForChat(chat.chatInfo.id) close?.invoke() } @@ -2402,7 +2406,7 @@ object ChatController { val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { withChats { - if (chatModel.hasChat(rhId, contactRequest.id)) { + if (hasChat(rhId, contactRequest.id)) { updateChatInfo(rhId, cInfo) } else { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) @@ -2412,7 +2416,7 @@ object ChatController { ntfManager.notifyContactRequestReceived(r.user, cInfo) } is CR.ContactUpdated -> { - if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) { + if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) withChats { updateChatInfo(rhId, cInfo) @@ -2424,10 +2428,13 @@ object ChatController { withChats { upsertGroupMember(rhId, r.groupInfo, r.toMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.toMember) + } } } is CR.ContactsMerged -> { - if (active(r.user) && chatModel.hasChat(rhId, r.mergedContact.id)) { + if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } @@ -2472,9 +2479,19 @@ object ChatController { if (active(r.user)) { withChats { addChatItem(rhId, cInfo, cItem) + if (cItem.isActiveReport) { + increaseGroupReportsCounter(rhId, cInfo.id) + } + } + withReportsChatsIfOpen { + if (cItem.isReport) { + addChatItem(rhId, cInfo, cItem) + } } } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { - chatModel.increaseUnreadCounter(rhId, r.user) + withChats { + increaseUnreadCounter(rhId, r.user) + } } val file = cItem.file val mc = cItem.content.msgContent @@ -2497,6 +2514,11 @@ object ChatController { withChats { updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } + withReportsChatsIfOpen { + if (cItem.isReport) { + updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + } + } } } is CR.ChatItemUpdated -> @@ -2506,13 +2528,20 @@ object ChatController { withChats { updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) } + withReportsChatsIfOpen { + if (r.reaction.chatReaction.chatItem.isReport) { + updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + } + } } } is CR.ChatItemsDeleted -> { if (!active(r.user)) { r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) -> if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) { - chatModel.decreaseUnreadCounter(rhId, r.user) + withChats { + decreaseUnreadCounter(rhId, r.user) + } } } return @@ -2541,6 +2570,67 @@ object ChatController { upsertChatItem(rhId, cInfo, toChatItem.chatItem) } } + withReportsChatsIfOpen { + if (cItem.isReport) { + if (toChatItem == null) { + removeChatItem(rhId, cInfo, cItem) + } else { + upsertChatItem(rhId, cInfo, toChatItem.chatItem) + } + } + } + } + } + is CR.GroupChatItemsDeleted -> { + if (!active(r.user)) { + val users = chatController.listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) + return + } + val cInfo = ChatInfo.Group(r.groupInfo) + withChats { + r.chatItemIDs.forEach { itemId -> + val cItem = chatItems.value.firstOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id + if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { + ntfManager.cancelNotificationsForChat(cInfo.id) + ntfManager.displayNotification( + r.user, + cInfo.id, + cInfo.displayName, + generalGetString(MR.strings.marked_deleted_description) + ) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember != r.member_) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + if (cItem.isActiveReport) { + decreaseGroupReportsCounter(rhId, cInfo.id) + } + } + } + withReportsChatsIfOpen { + r.chatItemIDs.forEach { itemId -> + val cItem = chatItems.value.firstOrNull { it.id == itemId } ?: return@forEach + if (chatModel.chatId.value != null) { + // Stop voice playback only inside a chat, allow to play in a chat list + AudioPlayer.stop(cItem) + } + val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember != r.member_) { + CIDeleted.Moderated(Clock.System.now(), r.member_) + } else { + CIDeleted.Deleted(Clock.System.now()) + } + upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted))) + } } } is CR.ReceivedGroupInvitation -> { @@ -2606,30 +2696,45 @@ object ChatController { withChats { upsertGroupMember(rhId, r.groupInfo, r.deletedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + } } is CR.LeftMember -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberRole -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberRoleUser -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberBlockedForAll -> if (active(r.user)) { withChats { upsertGroupMember(rhId, r.groupInfo, r.member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { @@ -3002,6 +3107,11 @@ object ChatController { val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem withChats { upsertChatItem(rh, cInfo, cItem) } + withReportsChatsIfOpen { + if (cItem.isReport) { + upsertChatItem(rh, cInfo, cItem) + } + } } } @@ -3011,10 +3121,14 @@ object ChatController { val notify = { ntfManager.notifyMessageReceived(rh, user, cInfo, cItem) } if (!activeUser(rh, user)) { notify() - } else if (withChats { upsertChatItem(rh, cInfo, cItem) }) { - notify() - } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { - notify() + } else { + val createdChat = withChats { upsertChatItem(rh, cInfo, cItem) } + withReportsChatsIfOpen { if (cItem.content.msgContent is MsgContent.MCReport) { upsertChatItem(rh, cInfo, cItem) } } + if (createdChat) { + notify() + } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { + notify() + } } } @@ -3059,6 +3173,11 @@ object ChatController { chats.clear() popChatCollector.clear() } + withReportsChatsIfOpen { + chatItems.clearAndNotify() + chats.clear() + popChatCollector.clear() + } } val statuses = apiGetNetworkStatuses(rhId) if (statuses != null) { @@ -3205,7 +3324,7 @@ 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 pagination: ChatPagination, val search: String = ""): 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 ApiCreateChatTag(val tag: ChatTagData): CC() @@ -3367,7 +3486,14 @@ sealed class CC { is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}" is ApiGetChatTags -> "/_get tags $userId" is ApiGetChats -> "/_get chats $userId pcc=on" - is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + is ApiGetChat -> { + val tag = if (contentTag == null) { + "" + } else { + " content=${contentTag.name.lowercase()}" + } + "/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search") + } is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) @@ -5540,6 +5666,7 @@ sealed class CR { @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("reactionMembers") class ReactionMembers(val user: UserRef, val memberReactions: List): CR() @Serializable @SerialName("chatItemsDeleted") class ChatItemsDeleted(val user: UserRef, val chatItemDeletions: List, val byUser: Boolean): CR() + @Serializable @SerialName("groupChatItemsDeleted") class GroupChatItemsDeleted(val user: UserRef, val groupInfo: GroupInfo, val chatItemIDs: List, val byUser: Boolean, val member_: GroupMember?): CR() @Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List, val forwardConfirmation: ForwardConfirmation? = null): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @@ -5724,6 +5851,7 @@ sealed class CR { is ChatItemReaction -> "chatItemReaction" is ReactionMembers -> "reactionMembers" is ChatItemsDeleted -> "chatItemsDeleted" + is GroupChatItemsDeleted -> "groupChatItemsDeleted" is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" @@ -5826,7 +5954,7 @@ sealed class CR { is ChatRunning -> noDetails() is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) - is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}") + is ApiChat -> withUser(user, "remoteHostId: ${chat.remoteHostId}\nchatInfo: ${chat.chatInfo}\nchatStats: ${chat.chatStats}\nnavInfo: ${navInfo}\nchatItems: ${chat.chatItems}") is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") @@ -5900,6 +6028,7 @@ sealed class CR { is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ReactionMembers -> withUser(user, "memberReactions: ${json.encodeToString(memberReactions)}") is ChatItemsDeleted -> withUser(user, "${chatItemDeletions.map { (deletedChatItem, toChatItem) -> "deletedChatItem: ${json.encodeToString(deletedChatItem)}\ntoChatItem: ${json.encodeToString(toChatItem)}" }} \nbyUser: $byUser") + is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_") is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt index b4e823bd45..e6d4514875 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/ScrollableColumn.kt @@ -23,6 +23,7 @@ expect fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, additionalBarOffset: State? = null, + additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, // by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here // maxSize (at least maxHeight) is needed for blur on appBars to work correctly @@ -42,6 +43,7 @@ expect fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), userScrollEnabled: Boolean = true, additionalBarOffset: State? = null, + additionalTopBar: State = remember { mutableStateOf(false) }, chatBottomBar: State = remember { mutableStateOf(true) }, content: LazyListScope.() -> 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 c2fd52a58c..67fae65897 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 @@ -26,6 +26,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout import chat.simplex.common.views.chatlist.NavigationBarBackground +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @@ -154,12 +155,12 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State) { } } LazyColumnWithScrollBar ( - reverseLayout = true, + state = listState, contentPadding = PaddingValues( top = topPaddingToContent(false), bottom = composeViewHeight.value ), - state = listState, + reverseLayout = true, additionalBarOffset = composeViewHeight ) { items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item -> 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 9b580edb62..afff6a9561 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 @@ -36,6 +36,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* 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 5cbc01271a..6419aa884d 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 @@ -14,9 +14,10 @@ suspend fun apiLoadSingleMessage( rhId: Long?, chatType: ChatType, apiId: Long, - itemId: Long + itemId: Long, + contentTag: MsgContentTag?, ): ChatItem? = coroutineScope { - val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null + val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null chat.chatItems.firstOrNull() } @@ -24,29 +25,36 @@ suspend fun apiLoadMessages( rhId: Long?, chatType: ChatType, apiId: Long, + contentTag: MsgContentTag?, pagination: ChatPagination, - chatState: ActiveChatState, search: String = "", visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = coroutineScope { - val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, pagination, search) ?: return@coroutineScope + val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, 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 if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last) || !isActive) return@coroutineScope + val chatState = chatModel.chatStateForContent(contentTag) val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState - val oldItems = chatModel.chatItems.value + val oldItems = chatModel.chatItemsForContent(contentTag).value val newItems = SnapshotStateList() when (pagination) { is ChatPagination.Initial -> { val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList() - withChats { - if (chatModel.getChat(chat.id) == null) { - addChat(chat) + if (contentTag == null) { + // update main chats, not content tagged + withChats { + if (getChat(chat.id) == null) { + addChat(chat) + } else { + updateChatInfo(chat.remoteHostId, chat.chatInfo) + updateChatStats(chat.remoteHostId, chat.id, chat.chatStats) + } } } - withChats { - chatModel.chatItemStatuses.clear() + withChats(contentTag) { + chatItemStatuses.clear() chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id splits.value = newSplits @@ -70,7 +78,7 @@ suspend fun apiLoadMessages( ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) - withChats { + withChats(contentTag) { chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) @@ -89,7 +97,7 @@ suspend fun apiLoadMessages( val indexToAdd = min(indexInCurrentItems + 1, newItems.size) val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) - withChats { + withChats(contentTag) { chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) @@ -104,7 +112,7 @@ suspend fun apiLoadMessages( val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) // currently, items will always be added on top, which is index 0 newItems.addAll(0, chat.chatItems) - withChats { + withChats(contentTag) { chatItems.replaceAll(newItems) splits.value = listOf(chat.chatItems.last().id) + newSplits unreadAfterItemId.value = chat.chatItems.last().id @@ -119,7 +127,7 @@ suspend fun apiLoadMessages( newItems.addAll(oldItems) removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) - withChats { + withChats(contentTag) { chatItems.replaceAll(newItems) unreadAfterNewestLoaded.value = 0 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index fda5c35e01..d318cf05fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.* import chat.simplex.common.model.* -import chat.simplex.common.platform.chatModel import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -240,14 +239,13 @@ data class ActiveChatState ( } } -fun visibleItemIndexesNonReversed(mergedItems: State, listState: LazyListState): IntRange { +fun visibleItemIndexesNonReversed(mergedItems: State, reversedItemsSize: Int, listState: LazyListState): IntRange { val zero = 0 .. 0 if (listState.layoutInfo.totalItemsCount == 0) return zero val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed() if (newest == null || oldest == null) return zero - val size = chatModel.chatItems.value.size - val range = size - oldest .. size - newest + val range = reversedItemsSize - oldest .. reversedItemsSize - newest if (range.first < 0 || range.last < 0) return zero // visible items mapped to their underlying data structure which is chatModel.chatItems 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 285d823ec1..ff954e0f18 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 @@ -31,8 +31,8 @@ import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.activeCall import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.markChatTagRead import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.group.* @@ -57,10 +57,17 @@ data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val dat @Composable // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts -fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) -> Unit) { - val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } +fun ChatView( + staleChatId: State, + reportsView: Boolean, + scrollToItemId: MutableState = remember { mutableStateOf(null) }, + onComposed: suspend (chatId: String) -> Unit +) { 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 activeChatStats = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats } } val user = chatModel.currentUser.value val chatInfo = activeChatInfo.value if (chatInfo == null || user == null) { @@ -69,6 +76,11 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - ModalManager.end.closeModals() } } else { + val groupReports = remember { derivedStateOf { + val reportsCount = if (activeChatInfo.value is ChatInfo.Group) activeChatStats.value?.reportsCount ?: 0 else 0 + GroupReports(reportsCount, reportsView) } + } + val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(groupReports.value.contentTag).value.asReversed() } } val searchText = rememberSaveable { mutableStateOf("") } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { @@ -94,7 +106,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - .distinctUntilChanged() .filterNotNull() .collect { chatId -> - markUnreadChatAsRead(chatId) + if (!groupReports.value.reportsView) { + markUnreadChatAsRead(chatId) + } showSearch.value = false searchText.value = "" selectedChatItems.value = null @@ -107,11 +121,14 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == activeChatInfo.value?.id }?.chatStats?.unreadCount ?: 0 + chatModel.chatsForContent(if (reportsView) MsgContentTag.Report else null).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0 } } val clipboard = LocalClipboardManager.current - CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) { + CompositionLocalProvider( + LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), + LocalContentTag provides groupReports.value.contentTag + ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { var groupMembersJob: Job = remember { Job() } @@ -119,9 +136,19 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { + val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value -> + if (searchText.value == value) return@onSearchValueChanged + val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged + if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged + withBGApi { + apiFindMessages(c, value, groupReports.value.toContentTag()) + searchText.value = value + } + } ChatLayout( remoteHostId = remoteHostId, chatInfo = activeChatInfo, + reversedChatItems = reversedChatItems, unreadCount, composeState, composeView = { @@ -150,7 +177,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } else { SelectedItemsBottomToolbar( - chatItems = remember { chatModel.chatItems }.value, + reversedChatItems = reversedChatItems, selectedChatItems = selectedChatItems, chatInfo = chatInfo, deleteItems = { canDeleteForAll -> @@ -211,6 +238,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - ) } }, + groupReports, + scrollToItemId, attachmentOption, attachmentBottomSheetState, searchText, @@ -266,7 +295,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) preloadedLink = link } - GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, { + GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, scrollToItemId, { link = it preloadedLink = it }, close, { showSearch.value = true }) @@ -278,6 +307,17 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, + showGroupReports = { + val info = activeChatInfo.value ?: return@ChatLayout + if (ModalManager.end.hasModalsOpen()) { + ModalManager.end.closeModals() + return@ChatLayout + } + hideKeyboard(view) + scope.launch { + showGroupReportsView(staleChatId, scrollToItemId, info) + } + }, showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) groupMembersJob.cancel() @@ -293,7 +333,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - setGroupMembers(chatRh, groupInfo, chatModel) if (!isActive) return@launch - ModalManager.end.closeModals() + if (!groupReports.value.reportsView) { + ModalManager.end.closeModals() + } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close) @@ -301,16 +343,16 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, - loadMessages = { chatId, pagination, chatState, visibleItemIndexes -> + loadMessages = { chatId, pagination, visibleItemIndexes -> val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, chatState, searchText.value, visibleItemIndexes) + apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, groupReports.value.toContentTag(), pagination, searchText.value, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> withBGApi { - val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } + val toDeleteItem = reversedChatItems.value.lastOrNull { it.id == itemId } val toModerate = toDeleteItem?.memberToModerate(chatInfo) val groupInfo = toModerate?.first val groupMember = toModerate?.second @@ -341,6 +383,19 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } else { removeChatItem(chatRh, chatInfo, deletedChatItem) } + val deletedItem = deleted.deletedChatItem.chatItem + if (deletedItem.isActiveReport) { + decreaseGroupReportsCounter(chatRh, chatInfo.id) + } + } + withReportsChatsIfOpen { + if (deletedChatItem.isReport) { + if (toChatItem != null) { + upsertChatItem(chatRh, chatInfo, toChatItem) + } else { + removeChatItem(chatRh, chatInfo, deletedChatItem) + } + } } } } @@ -454,6 +509,11 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { updateChatItem(cInfo, updatedCI) } + withReportsChatsIfOpen { + if (cItem.isReport) { + updateChatItem(cInfo, updatedCI) + } + } } } }, @@ -471,7 +531,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - groupMembersJob.cancel() groupMembersJob = scope.launch(Dispatchers.Default) { var initialCiInfo = loadChatItemInfo() ?: return@launch - ModalManager.end.closeModals() + if (!ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) { + ModalManager.end.closeModals() + } ModalManager.end.showModalCloseable(endButtons = { ShareButton { clipboard.shareText(itemInfoShareText(chatModel, cItem, initialCiInfo, chatModel.controller.appPrefs.developerTools.get())) @@ -506,7 +568,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo, itemsIds) + markChatItemsRead(chatRh, chatInfo.id, itemsIds) } ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatItemsRead( @@ -516,6 +578,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - itemsIds ) } + withReportsChatsIfOpen { + markChatItemsRead(chatRh, chatInfo.id, itemsIds) + } } }, markChatRead = { @@ -523,7 +588,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - withChats { // It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace withContext(Dispatchers.Main) { - markChatItemsRead(chatRh, chatInfo) + markChatItemsRead(chatRh, chatInfo.id) } ntfManager.cancelNotificationsForChat(chatInfo.id) chatModel.controller.apiChatRead( @@ -532,18 +597,13 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - chatInfo.apiId ) } + withReportsChatsIfOpen { + markChatItemsRead(chatRh, chatInfo.id) + } } }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, - onSearchValueChanged = { value -> - if (searchText.value == value) return@ChatLayout - val c = chatModel.getChat(chatInfo.id) ?: return@ChatLayout - if (chatModel.chatId.value != chatInfo.id) return@ChatLayout - withBGApi { - apiFindMessages(c, value) - searchText.value = value - } - }, + onSearchValueChanged = onSearchValueChanged, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), @@ -600,9 +660,12 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType) fun ChatLayout( remoteHostId: State, chatInfo: State, + reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeView: (@Composable () -> Unit), + groupReports: State, + scrollToItemId: MutableState, attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, searchValue: State, @@ -611,8 +674,9 @@ fun ChatLayout( selectedChatItems: MutableState?>, back: () -> Unit, info: () -> Unit, + showGroupReports: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -671,7 +735,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)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, !groupReports.value.reportsView)) { val remoteHostId = remember { remoteHostId }.value val chatInfo = remember { chatInfo }.value val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -684,8 +748,8 @@ fun ChatLayout( override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f }) { ChatItemsList( - remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue, - useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, + remoteHostId, chatInfo, reversedChatItems, unreadCount, composeState, composeViewHeight, searchValue, + useLinkPreviews, linkMode, groupReports, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, @@ -693,29 +757,90 @@ fun ChatLayout( } } } - Box( - Modifier - .layoutId(CHAT_COMPOSE_LAYOUT_ID) - .align(Alignment.BottomCenter) - .imePadding() - .navigationBarsPadding() - .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) - ) { - composeView() + if (groupReports.value.reportsView) { + Column( + Modifier + .layoutId(CHAT_COMPOSE_LAYOUT_ID) + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .imePadding() + ) { + AnimatedVisibility(selectedChatItems.value != null) { + if (chatInfo != null) { + SelectedItemsBottomToolbar( + reversedChatItems = reversedChatItems, + selectedChatItems = selectedChatItems, + chatInfo = chatInfo, + deleteItems = { _ -> + val itemIds = selectedChatItems.value + val questionText = generalGetString(MR.strings.delete_messages_cannot_be_undone_warning) + if (itemIds != null) { + deleteMessagesAlertDialog(itemIds.sorted(), questionText = questionText, forAll = false, deleteMessages = { ids, _ -> + deleteMessages(remoteHostId, chatInfo, ids, false, moderate = false) { + selectedChatItems.value = null + } + }) + } + }, + moderateItems = {}, + forwardItems = {} + ) + } + } + if (oneHandUI.value) { + // That's placeholder to take some space for bottom app bar in oneHandUI + Box(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier)) + } + } + } else { + Box( + Modifier + .layoutId(CHAT_COMPOSE_LAYOUT_ID) + .align(Alignment.BottomCenter) + .imePadding() + .navigationBarsPadding() + .then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier) + ) { + composeView() + } } } if (oneHandUI.value && chatBottomBar.value) { - StatusBarBackground() + if (groupReports.value.showBar) { + ReportedCountToolbar(groupReports, withStatusBar = true, showGroupReports) + } else { + StatusBarBackground() + } } else { NavigationBarBackground(true, oneHandUI.value, noAlpha = true) } - Box(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { - if (selectedChatItems.value == null) { - if (chatInfo != null) { - ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + if (groupReports.value.reportsView) { + if (oneHandUI.value) { + StatusBarBackground() + } + Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { + Box { + if (selectedChatItems.value == null) { + GroupReportsAppBar(groupReports, { ModalManager.end.closeModal() }, onSearchValueChanged) + } else { + SelectedItemsTopToolbar(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(chatInfo, groupReports, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) + } + } else { + SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) + } + } + if (groupReports.value.showBar && (!oneHandUI.value || !chatBottomBar.value)) { + ReportedCountToolbar(groupReports, withStatusBar = false, showGroupReports) } - } else { - SelectedItemsTopToolbar(selectedChatItems) } } } @@ -726,6 +851,7 @@ fun ChatLayout( @Composable fun BoxScope.ChatInfoToolbar( chatInfo: ChatInfo, + groupReports: State, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, @@ -747,7 +873,7 @@ fun BoxScope.ChatInfoToolbar( showSearch.value = false } } - if (appPlatform.isAndroid) { + if (appPlatform.isAndroid && !groupReports.value.reportsView) { BackHandler(onBack = onBackClicked) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() @@ -941,25 +1067,65 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo } } +@Composable +private fun ReportedCountToolbar( + groupReports: State, + withStatusBar: Boolean, + showGroupReports: () -> Unit +) { + Box { + val statusBarPadding = if (withStatusBar) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else 0.dp + Row( + Modifier + .fillMaxWidth() + .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) + .background(MaterialTheme.colors.background) + .clickable(onClick = showGroupReports) + .padding(top = statusBarPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error) + Spacer(Modifier.width(4.dp)) + val reports = groupReports.value.reportsCount + Text( + if (reports == 1) { + stringResource(MR.strings.group_reports_active_one) + } else { + stringResource(MR.strings.group_reports_active).format(reports) + }, + style = MaterialTheme.typography.button + ) + } + Divider(Modifier.align(Alignment.BottomStart)) + } +} + @Composable private fun ContactVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } +/** Saves current scroll position when [GroupReports] are open and user opens [ChatItemInfoView], for example, and goes back */ +private var reportsListState: LazyListState? = null + @Composable fun BoxScope.ChatItemsList( remoteHostId: Long?, chatInfo: ChatInfo, + reversedChatItems: State>, unreadCount: State, composeState: MutableState, composeViewHeight: State, searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, + groupReports: State, + scrollToItemId: MutableState, selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, showChatInfo: () -> Unit, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -984,10 +1150,9 @@ fun BoxScope.ChatItemsList( showViaProxy: Boolean ) { val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } - val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } - val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatState) } } - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatStateForContent(groupReports.value.contentTag)) } } + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) /** 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 * */ @@ -996,12 +1161,17 @@ fun BoxScope.ChatItemsList( ) val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) { val index = mergedItems.value.items.indexOfLast { it.hasUnread() } - if (index <= 0) { + val reportsState = reportsListState + if (reportsState != null) { + reportsListState = null + reportsState + } else if (index <= 0) { LazyListState(0, 0) } else { LazyListState(index + 1, -maxHeightForList.value) } }) + SaveReportsStateOnDispose(groupReports, listState) val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } } val loadingMoreItems = remember { mutableStateOf(false) } val animatedScrollingInProgress = remember { mutableStateOf(false) } @@ -1011,12 +1181,12 @@ fun BoxScope.ChatItemsList( ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) } if (!loadingMoreItems.value) { - PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> + PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), reversedChatItems, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value) return@PreloadItems false try { loadingMoreItems.value = true - loadMessages(chatId, pagination, chatModel.chatState) { - visibleItemIndexesNonReversed(mergedItems, listState.value) + loadMessages(chatId, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } } finally { loadingMoreItems.value = false @@ -1029,21 +1199,33 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val highlightedItems = remember { mutableStateOf(setOf()) } val scope = rememberCoroutineScope() - val scrollToItem: (Long) -> Unit = remember { scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } - val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) } - - LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) - SmallScrollOnNewMessage(listState, chatModel.chatItems) + val scrollToItem: (Long) -> Unit = remember { + // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling + if (groupReports.value.reportsView) return@remember { scrollToItemId.value = it } + scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + } + val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, groupReports.value.contentTag) } + if (!groupReports.value.reportsView) { + LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect { + if (appPlatform.isAndroid) { + ModalManager.end.closeModals() + } + scrollToItem(it) + scrollToItemId.value = null } + } + } + LoadLastItems(loadingMoreItems, remoteHostId, chatInfo, groupReports) + SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) DisposableEffectOnGone( always = { - chatModel.chatItemsChangesListener = recalculateChatStatePositions(chatModel.chatState) + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) }, whenGone = { VideoPlayerHolder.releaseAll() - chatModel.chatItemsChangesListener = null + chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag) } ) @@ -1065,7 +1247,7 @@ fun BoxScope.ChatItemsList( LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop() ) { val provider = { - providerForGallery(chatModel.chatItems.value, cItem.id) { indexInReversed -> + providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> itemScope.launch { listState.value.scrollToItem( min(reversedChatItems.value.lastIndex, indexInReversed + 1), @@ -1090,7 +1272,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, 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) + ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, 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) } } @@ -1168,7 +1350,7 @@ fun BoxScope.ChatItemsList( val rangeValue = range.value val (prevMember, memCount) = if (rangeValue != null) { - chatModel.getPrevHiddenMember(member, rangeValue) + chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) } else { null to 1 } @@ -1276,7 +1458,7 @@ fun BoxScope.ChatItemsList( if (selectionVisible) { Box(Modifier.matchParentSize().clickable { val checked = selectedChatItems.value?.contains(cItem.id) == true - selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems) + selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems) }) } } @@ -1290,12 +1472,13 @@ fun BoxScope.ChatItemsList( LazyColumnWithScrollBar( Modifier.align(Alignment.BottomCenter), state = listState.value, - reverseLayout = true, contentPadding = PaddingValues( - top = topPaddingToContent(true), + top = topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar), bottom = composeViewHeight.value ), + reverseLayout = true, additionalBarOffset = composeViewHeight, + additionalTopBar = remember { derivedStateOf { groupReports.value.showBar } }, chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -1339,8 +1522,8 @@ fun BoxScope.ChatItemsList( } } } - FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState) - FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(true)).align(Alignment.TopCenter), mergedItems, listState) + FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, groupReports, markChatRead, listState) + FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopCenter), mergedItems, listState, groupReports) LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } @@ -1360,14 +1543,14 @@ fun BoxScope.ChatItemsList( } @Composable -private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo) { +private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo, groupReports: State) { LaunchedEffect(remoteHostId, chatInfo.id) { try { loadingMoreItems.value = true - if (chatModel.chatState.totalAfter.value <= 0) return@LaunchedEffect + if (chatModel.chatStateForContent(groupReports.value.contentTag).totalAfter.value <= 0) return@LaunchedEffect delay(500) withContext(Dispatchers.Default) { - apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState) + apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, groupReports.value.toContentTag(), ChatPagination.Last(ChatPagination.INITIAL_COUNT)) } } finally { loadingMoreItems.value = false @@ -1376,20 +1559,20 @@ private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: } @Composable -private fun SmallScrollOnNewMessage(listState: State, chatItems: State>) { +private fun SmallScrollOnNewMessage(listState: State, reversedChatItems: State>) { val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() } LaunchedEffect(Unit) { - var lastTotalItems = listState.value.layoutInfo.totalItemsCount - var lastItemId = chatItems.value.lastOrNull()?.id + var prevTotalItems = listState.value.layoutInfo.totalItemsCount + var newestItemId = reversedChatItems.value.firstOrNull()?.id snapshotFlow { listState.value.layoutInfo.totalItemsCount } .distinctUntilChanged() .drop(1) .collect { - val diff = listState.value.layoutInfo.totalItemsCount - lastTotalItems - val sameLastItem = lastItemId == chatItems.value.lastOrNull()?.id - lastTotalItems = listState.value.layoutInfo.totalItemsCount - lastItemId = chatItems.value.lastOrNull()?.id - if (diff < 1 || diff > 2 || sameLastItem) { + val diff = listState.value.layoutInfo.totalItemsCount - prevTotalItems + val sameNewestItem = newestItemId == reversedChatItems.value.firstOrNull()?.id + prevTotalItems = listState.value.layoutInfo.totalItemsCount + newestItemId = reversedChatItems.value.firstOrNull()?.id + if (diff < 1 || diff > 2 || sameNewestItem) { return@collect } try { @@ -1400,7 +1583,7 @@ private fun SmallScrollOnNewMessage(listState: State, chatItems: } } catch (e: CancellationException) { /** - * When you tap and hold a finger on a lazy column with chatItems, and then you receive a message, + * When you tap and hold a finger on a lazy column with reversedChatItems, and then you receive a message, * this coroutine will be canceled with the message "Current mutation had a higher priority" because of animatedScroll. * Which breaks auto-scrolling to bottom. So just ignoring the exception * */ @@ -1440,11 +1623,12 @@ fun BoxScope.FloatingButtons( maxHeight: State, composeViewHeight: State, searchValue: State, + groupReports: State, markChatRead: () -> Unit, listState: State ) { val scope = rememberCoroutineScope() - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val bottomUnreadCount = remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 @@ -1490,7 +1674,7 @@ fun BoxScope.FloatingButtons( val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( - Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(true)).align(Alignment.TopEnd), + Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopEnd), topUnreadCount, animatedScrollingInProgress, onClick = { @@ -1512,7 +1696,7 @@ fun BoxScope.FloatingButtons( DefaultDropdownMenu( showDropDown, modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } }, - offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(true)) + offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)) ) { ItemAction( generalGetString(MR.strings.mark_read), @@ -1529,6 +1713,7 @@ fun BoxScope.FloatingButtons( fun PreloadItems( chatId: String, ignoreLoadingRequests: MutableSet, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1539,8 +1724,8 @@ fun PreloadItems( val chatId = rememberUpdatedState(chatId) val loadItems = rememberUpdatedState(loadItems) val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) - PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, mergedItems, listState, remaining, loadItems) - PreloadItemsAfter(allowLoad, chatId, mergedItems, listState, remaining, loadItems) + PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, reversedChatItems, mergedItems, listState, remaining, loadItems) + PreloadItemsAfter(allowLoad, chatId, reversedChatItems, mergedItems, listState, remaining, loadItems) } @Composable @@ -1548,6 +1733,7 @@ private fun PreloadItemsBefore( allowLoad: State, chatId: State, ignoreLoadingRequests: State>, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1560,9 +1746,9 @@ private fun PreloadItemsBefore( val splits = mergedItems.value.splits val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - val items = chatModel.chatItems.value + val items = reversedChatItems.value if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { - lastIndexToLoadFrom = items.lastIndex + lastIndexToLoadFrom = 0 } if (allowLoad.value && lastIndexToLoadFrom != null) { items.getOrNull(items.lastIndex - lastIndexToLoadFrom)?.id @@ -1574,10 +1760,10 @@ private fun PreloadItemsBefore( .filter { !ignoreLoadingRequests.value.contains(it) } .collect { loadFromItemId -> withBGApi { - val sizeWas = chatModel.chatItems.value.size - val firstItemIdWas = chatModel.chatItems.value.firstOrNull()?.id + val sizeWas = reversedChatItems.value.size + val oldestItemIdWas = reversedChatItems.value.lastOrNull()?.id val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT)) - if (triedToLoad && sizeWas == chatModel.chatItems.value.size && firstItemIdWas == chatModel.chatItems.value.firstOrNull()?.id) { + if (triedToLoad && sizeWas == reversedChatItems.value.size && oldestItemIdWas == reversedChatItems.value.lastOrNull()?.id) { ignoreLoadingRequests.value.add(loadFromItemId) } } @@ -1589,6 +1775,7 @@ private fun PreloadItemsBefore( private fun PreloadItemsAfter( allowLoad: MutableState, chatId: State, + reversedChatItems: State>, mergedItems: State, listState: State, remaining: Int, @@ -1609,12 +1796,12 @@ private fun PreloadItemsAfter( snapshotFlow { listState.value.firstVisibleItemIndex } .distinctUntilChanged() .map { firstVisibleIndex -> - val items = chatModel.chatItems.value + val items = reversedChatItems.value val splits = mergedItems.value.splits val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) { - items.getOrNull(items.lastIndex - split.indexRangeInReversed.first)?.id + items.getOrNull(split.indexRangeInReversed.first)?.id } else { null } @@ -1663,13 +1850,14 @@ private fun TopEndFloatingButton( } @Composable -fun topPaddingToContent(chatView: Boolean): Dp { +fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): Dp { val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } + val reportsPadding = if (additionalTopBar) AppBarHeight * fontSizeSqrtMultiplier else 0.dp return if (oneHandUI.value && (!chatView || chatBottomBar.value)) { - WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + reportsPadding } else { - AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + reportsPadding } } @@ -1678,12 +1866,13 @@ private fun FloatingDate( modifier: Modifier, mergedItems: State, listState: State, + groupReports: State ) { val isNearBottom = remember(chatModel.chatId) { mutableStateOf(listState.value.firstVisibleItemIndex == 0) } val nearBottomIndex = remember(chatModel.chatId) { mutableStateOf(if (isNearBottom.value) -1 else 0) } val showDate = remember(chatModel.chatId) { mutableStateOf(false) } val density = LocalDensity.current.density - val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() }) + val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() }) val fontSizeSqrtMultiplier = fontSizeSqrtMultiplier val lastVisibleItemDate = remember { derivedStateOf { @@ -1767,6 +1956,15 @@ private fun FloatingDate( } } +@Composable +private fun SaveReportsStateOnDispose(groupReports: State, listState: State) { + DisposableEffect(Unit) { + onDispose { + reportsListState = if (groupReports.value.reportsView && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null + } + } +} + @Composable private fun DownloadFilesButton( forwardConfirmation: ForwardConfirmation.FilesNotAccepted, @@ -1893,7 +2091,7 @@ private fun scrollToItem( reversedChatItems: State>, mergedItems: State, listState: State, - loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, + loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit, ): (Long) -> Unit = { itemId: Long -> withApi { try { @@ -1907,8 +2105,8 @@ private fun scrollToItem( val pagination = ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2) val oldSize = reversedChatItems.value.size withContext(Dispatchers.Default) { - loadMessages(chatInfo.value.id, pagination, chatModel.chatState) { - visibleItemIndexesNonReversed(mergedItems, listState.value) + loadMessages(chatInfo.value.id, pagination) { + visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) } } var repeatsLeft = 50 @@ -1939,14 +2137,18 @@ private fun findQuotedItemFromItem( rhId: State, chatInfo: State, scope: CoroutineScope, - scrollToItem: (Long) -> Unit + scrollToItem: (Long) -> Unit, + contentTag: MsgContentTag? ): (Long) -> Unit = { itemId: Long -> scope.launch(Dispatchers.Default) { - val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId) + val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId, contentTag) if (item != null) { withChats { updateChatItem(chatInfo.value, item) } + withReportsChatsIfOpen { + updateChatItem(chatInfo.value, item) + } if (item.quotedItem?.itemId != null) { scrollToItem(item.quotedItem.itemId) } else { @@ -2043,18 +2245,24 @@ private fun SelectedChatItem( ) } -private fun selectUnselectChatItem(select: Boolean, ci: ChatItem, revealed: State, selectedChatItems: MutableState?>) { +private fun selectUnselectChatItem( + select: Boolean, + ci: ChatItem, + revealed: State, + selectedChatItems: MutableState?>, + reversedChatItems: State> +) { val itemIds = mutableSetOf() if (!revealed.value) { - val currIndex = chatModel.getChatItemIndexOrNull(ci) + val currIndex = chatModel.getChatItemIndexOrNull(ci, reversedChatItems.value) val ciCategory = ci.mergeCategory if (currIndex != null && ciCategory != null) { - val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems.value) val range = chatViewItemsRange(currIndex, prevHidden) if (range != null) { - val reversedChatItems = chatModel.chatItems.asReversed() + val reversed = reversedChatItems.value for (i in range) { - itemIds.add(reversedChatItems[i].id) + itemIds.add(reversed[i].id) } } else { itemIds.add(ci.id) @@ -2098,10 +2306,28 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List?, - backgroundGraphicsLayer: GraphicsLayer? + backgroundGraphicsLayer: GraphicsLayer?, + drawWallpaper: Boolean ): Modifier { val wallpaperImage = wallpaper.type.image val wallpaperType = wallpaper.type - val backgroundColor = wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) + val backgroundColor = if (drawWallpaper) wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) else colors.background val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base) return this - .then(if (wallpaperImage != null) + .then(if (wallpaperImage != null && drawWallpaper) Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, backgroundGraphicsLayerSize, backgroundGraphicsLayer) } else Modifier.drawWithCache { onDrawBehind { copyBackgroundToAppBar(backgroundGraphicsLayerSize, backgroundGraphicsLayer) { drawRect(backgroundColor) } } } @@ -2303,7 +2530,7 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf private fun forwardContent(chatItemsIds: List, chatInfo: ChatInfo) { chatModel.chatId.value = null chatModel.sharedContent.value = SharedContent.Forward( - chatModel.chatItems.value.filter { chatItemsIds.contains(it.id) }, + chatModel.chatItemsForContent(null).value.filter { chatItemsIds.contains(it.id) }, chatInfo ) } @@ -2440,9 +2667,12 @@ fun PreviewChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, + reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, + groupReports = remember { mutableStateOf(GroupReports(0, false)) }, + scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), searchValue, @@ -2451,8 +2681,9 @@ fun PreviewChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, + showGroupReports = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _, _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, @@ -2513,9 +2744,12 @@ fun PreviewGroupChatLayout() { ChatLayout( remoteHostId = remember { mutableStateOf(null) }, chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) }, + reversedChatItems = remember { mutableStateOf(emptyList()) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, + groupReports = remember { mutableStateOf(GroupReports(0, false)) }, + scrollToItemId = remember { mutableStateOf(null) }, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), searchValue, @@ -2524,8 +2758,9 @@ fun PreviewGroupChatLayout() { selectedChatItems = remember { mutableStateOf(setOf()) }, back = {}, info = {}, + showGroupReports = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _, _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = {}, receiveFile = { _ -> }, 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 01c6faa573..7ca5c873bd 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 @@ -836,7 +836,7 @@ fun ComposeView( fun editPrevMessage() { if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return - val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable } + val lastEditable = chatModel.chatItemsForContent(null).value.findLast { it.meta.editable } if (lastEditable != null) { composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) } 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 582a981443..e449831ee0 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 @@ -21,11 +21,10 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @Composable -fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?>) { +fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?>, onTop: Boolean) { val onBackClicked = { selectedChatItems.value = null } BackHandler(onBack = onBackClicked) val count = selectedChatItems.value?.size ?: 0 - val oneHandUI = remember { appPrefs.oneHandUI.state } DefaultAppBar( navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) }, title = { @@ -41,7 +40,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?> ) }, onTitleClick = null, - onTop = !oneHandUI.value, + onTop = onTop, onSearchValueChanged = {}, ) } @@ -49,7 +48,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState?> @Composable fun SelectedItemsBottomToolbar( chatInfo: ChatInfo, - chatItems: List, + reversedChatItems: State>, selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible moderateItems: () -> Unit, @@ -108,8 +107,8 @@ fun SelectedItemsBottomToolbar( } Divider(Modifier.align(Alignment.TopStart)) } - LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) { - recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) + LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) { + recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited) } } 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 fc637fa381..abfb3895d9 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ChatInfoToolbarTitle import chat.simplex.common.views.helpers.* @@ -64,6 +65,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea withChats { upsertGroupMember(rhId, groupInfo, member) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, member) + } } else { break } 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 21d678ba50..d82352c5eb 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 @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -45,7 +46,7 @@ import kotlinx.coroutines.* const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 @Composable -fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { +fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { BackHandler(onBack = close) // TODO derivedStateOf? val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } @@ -70,6 +71,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin .sortedByDescending { it.memberRole }, developerTools, groupLink, + scrollToItemId, addMembers = { scope.launch(Dispatchers.Default) { setGroupMembers(rhId, groupInfo, chatModel) @@ -198,6 +200,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe withChats { upsertGroupMember(rhId, groupInfo, updatedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, updatedMember) + } } } }, @@ -282,6 +287,7 @@ fun ModalData.GroupChatInfoLayout( members: List, developerTools: Boolean, groupLink: String?, + scrollToItemId: MutableState, addMembers: () -> Unit, showMemberInfo: (GroupMember) -> Unit, editGroupProfile: () -> Unit, @@ -309,12 +315,12 @@ fun ModalData.GroupChatInfoLayout( Box { val oneHandUI = remember { appPrefs.oneHandUI.state } LazyColumnWithScrollBar( + state = listState, contentPadding = if (oneHandUI.value) { PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) } else { PaddingValues(top = topPaddingToContent(false)) - }, - state = listState + } ) { item { Row( @@ -358,6 +364,13 @@ 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) + } + } + } if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) } else { @@ -487,6 +500,15 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) ) } +@Composable +private fun GroupReportsButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_flag), + stringResource(MR.strings.group_reports_member_reports), + click = onClick + ) +} + @Composable private fun SendReceiptsOption(currentUser: User, state: State, onSelected: (SendReceipts) -> Unit) { val values = remember { @@ -737,6 +759,7 @@ fun PreviewGroupChatInfoLayout() { members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, groupLink = null, + scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, ) } 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 657c0923a5..ef1c69a5bb 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -65,6 +66,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -83,7 +87,7 @@ fun GroupMemberInfoView( getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { withBGApi { - apiLoadMessages(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) + apiLoadMessages(rhId, ChatType.Direct, it, null, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) if (chatModel.getContactChat(it) != null) { closeAll() } @@ -142,6 +146,9 @@ fun GroupMemberInfoView( withChats { upsertGroupMember(rhId, groupInfo, mem) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, mem) + } }.onFailure { newRole.value = prevValue } @@ -157,6 +164,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -171,6 +181,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -188,6 +201,9 @@ fun GroupMemberInfoView( withChats { updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) } + withReportsChatsIfOpen { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -203,16 +219,16 @@ fun GroupMemberInfoView( verify = { code -> chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> val (verified, existingCode) = r - withChats { - upsertGroupMember( - rhId, - groupInfo, - mem.copy( - activeConn = mem.activeConn?.copy( - connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null - ) - ) + val copy = mem.copy( + activeConn = mem.activeConn?.copy( + connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null ) + ) + withChats { + upsertGroupMember(rhId, groupInfo, copy) + } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, copy) } r } @@ -246,6 +262,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c withChats { upsertGroupMember(rhId, groupInfo, removedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, groupInfo, removedMember) + } } close?.invoke() } @@ -753,6 +772,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem withChats { upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + } } } } @@ -786,6 +808,9 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke withChats { upsertGroupMember(rhId, gInfo, updatedMember) } + withReportsChatsIfOpen { + upsertGroupMember(rhId, gInfo, updatedMember) + } } } 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 0a807e1d63..3d9f42f929 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 @@ -45,6 +45,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> updateGroup(rhId, g) currentPreferences = preferences } + withChats { + updateGroup(rhId, g) + } } afterSave() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index e81722f3f0..3163c109e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* 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 new file mode 100644 index 0000000000..a1ec3ec0a9 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.chat.group + +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +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 +import kotlinx.coroutines.flow.* + +val LocalContentTag: ProvidableCompositionLocal = staticCompositionLocalOf { null } + +data class GroupReports( + val reportsCount: Int, + val reportsView: Boolean, +) { + val showBar: Boolean = reportsCount > 0 && !reportsView + + fun toContentTag(): MsgContentTag? { + if (!reportsView) return null + return MsgContentTag.Report + } + + val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report +} + +@Composable +private fun GroupReportsView(staleChatId: State, scrollToItemId: MutableState) { + ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {}) +} + +@Composable +fun GroupReportsAppBar( + groupReports: State, + close: () -> Unit, + onSearchValueChanged: (String) -> Unit +) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val showSearch = rememberSaveable { mutableStateOf(false) } + val onBackClicked = { + if (!showSearch.value) { + close() + } else { + onSearchValueChanged("") + showSearch.value = false + } + } + BackHandler(onBack = onBackClicked) + DefaultAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + fixedTitleText = stringResource(MR.strings.group_reports_member_reports), + 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(groupReports) +} + +@Composable +private fun ItemsReload(groupReports: State) { + LaunchedEffect(Unit) { + snapshotFlow { chatModel.chatId.value } + .distinctUntilChanged() + .drop(1) + .filterNotNull() + .map { chatModel.getChat(it) } + .filterNotNull() + .filter { it.chatInfo is ChatInfo.Group } + .collect { chat -> + reloadItems(chat, groupReports) + } + } +} + +suspend fun showGroupReportsView(staleChatId: State, scrollToItemId: MutableState, chatInfo: ChatInfo) { + openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report) + ModalManager.end.showCustomModal(true, id = ModalViewId.GROUP_REPORTS) { close -> + ModalView({}, showAppBar = false) { + val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) { + GroupReportsView(staleChatId, scrollToItemId) + } else { + LaunchedEffect(Unit) { + close() + } + } + } + } +} + +private suspend fun reloadItems(chat: Chat, groupReports: State) { + val contentFilter = groupReports.value.toContentTag() + apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 6ebd4b13c3..703d74f225 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -27,6 +27,7 @@ import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatJsonLength diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 9bb3cef1d7..7711ee73af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.platform.onRightClick +import chat.simplex.common.views.chat.group.LocalContentTag @Composable fun CIChatFeatureView( @@ -75,9 +76,9 @@ private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List = arrayListOf() val icons: MutableSet = mutableSetOf() - var i = getChatItemIndexOrNull(chatItem) + val reversedChatItems = m.chatItemsForContent(LocalContentTag.current).value.asReversed() + var i = getChatItemIndexOrNull(chatItem, reversedChatItems) if (i != null) { - val reversedChatItems = m.chatItems.asReversed() while (i < reversedChatItems.size) { val f = featureInfo(reversedChatItems[i], chatInfo) ?: break if (!icons.contains(f.icon)) { 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 58e4a31840..7915d1adf7 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 @@ -30,6 +30,7 @@ import chat.simplex.common.model.ChatModel.currentUser import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -481,7 +482,7 @@ fun ChatItemView( fun ContentItem() { val mc = cItem.content.msgContent if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { @@ -516,8 +517,8 @@ fun ChatItemView( DeleteItemMenu() } - fun mergedGroupEventText(chatItem: ChatItem): String? { - val (count, ns) = chatModel.getConnectedMemberNames(chatItem) + fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): String? { + val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems) val members = when { ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) @@ -536,9 +537,9 @@ fun ChatItemView( } } - fun eventItemViewText(): AnnotatedString { + fun eventItemViewText(reversedChatItems: List): AnnotatedString { val memberDisplayName = cItem.memberDisplayName - val t = mergedGroupEventText(cItem) + val t = mergedGroupEventText(cItem, reversedChatItems) return if (!revealed.value && t != null) { chatEventText(t, cItem.timestampText) } else if (memberDisplayName != null) { @@ -552,12 +553,13 @@ fun ChatItemView( } @Composable fun EventItemView() { - CIEventView(eventItemViewText()) + val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + CIEventView(eventItemViewText(reversedChatItems)) } @Composable fun DeletedItem() { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) @@ -746,20 +748,21 @@ fun DeleteItemAction( deleteMessages: (List) -> Unit, buttonText: String = stringResource(MR.strings.delete_verb), ) { + val contentTag = LocalContentTag.current ItemAction( buttonText, painterResource(MR.images.ic_delete), onClick = { showMenu.value = false if (!revealed.value) { - val currIndex = chatModel.getChatItemIndexOrNull(cItem) + val reversedChatItems = chatModel.chatItemsForContent(contentTag).value.asReversed() + val currIndex = chatModel.getChatItemIndexOrNull(cItem, reversedChatItems) val ciCategory = cItem.mergeCategory if (currIndex != null && ciCategory != null) { - val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems) val range = chatViewItemsRange(currIndex, prevHidden) if (range != null) { val itemIds: ArrayList = arrayListOf() - val reversedChatItems = chatModel.chatItems.asReversed() for (i in range) { itemIds.add(reversedChatItems[i].id) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 2827a649b5..784563dbb2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -128,17 +128,6 @@ fun FramedItemView( Modifier .background(if (sent) sentColor else receivedColor) .fillMaxWidth() - .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = { - if (qi.itemId != null) { - scrollToItem(qi.itemId) - } else { - scrollToQuotedItemFromItem(ci.id) - } - } - ) - .onRightClick { showMenu.value = true } ) { when (qi.content) { is MsgContent.MCImage -> { @@ -216,39 +205,66 @@ fun FramedItemView( .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - if (ci.isReport) { - if (ci.meta.itemDeleted == null) { - FramedItemHeader( - stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), - true, - painterResource(MR.images.ic_flag), - iconColor = Color.Red - ) - } else { - FramedItemHeader(stringResource(MR.strings.report_item_archived), true, painterResource(MR.images.ic_flag)) + @Composable + fun Header() { + if (ci.isReport) { + if (ci.meta.itemDeleted == null) { + FramedItemHeader( + stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), + true, + painterResource(MR.images.ic_flag), + iconColor = Color.Red + ) + } else { + val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName) + } else { + stringResource(MR.strings.report_item_archived) + } + FramedItemHeader(text, true, painterResource(MR.images.ic_flag)) + } + } else if (ci.meta.itemDeleted != null) { + when (ci.meta.itemDeleted) { + is CIDeleted.Moderated -> { + FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + } + is CIDeleted.Blocked -> { + FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.BlockedByAdmin -> { + FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.Deleted -> { + FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } + } + } else if (ci.meta.isLive) { + FramedItemHeader(stringResource(MR.strings.live), false) } - } else if (ci.meta.itemDeleted != null) { - when (ci.meta.itemDeleted) { - is CIDeleted.Moderated -> { - FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) - } - is CIDeleted.Blocked -> { - FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.BlockedByAdmin -> { - FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.Deleted -> { - FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) - } - } - } else if (ci.meta.isLive) { - FramedItemHeader(stringResource(MR.strings.live), false) } if (ci.quotedItem != null) { - ciQuoteView(ci.quotedItem) - } else if (ci.meta.itemForwarded != null) { - FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + Column( + Modifier + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { + if (ci.quotedItem.itemId != null) { + scrollToItem(ci.quotedItem.itemId) + } else { + scrollToQuotedItemFromItem(ci.id) + } + } + ) + .onRightClick { showMenu.value = true } + ) { + Header() + ciQuoteView(ci.quotedItem) + } + } else { + Header() + if (ci.meta.itemForwarded != null) { + FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } } if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 410372fe96..d63094cd1d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -12,15 +12,17 @@ import androidx.compose.runtime.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.chatModel import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.group.LocalContentTag import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { +fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Surface( @@ -33,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State< verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f, false)) { - MergedMarkedDeletedText(ci, revealed) + MergedMarkedDeletedText(ci, chatInfo, revealed) } CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } @@ -41,11 +43,11 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State< } @Composable -private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State) { - var i = getChatItemIndexOrNull(chatItem) +private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State) { + val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + var i = getChatItemIndexOrNull(chatItem, reversedChatItems) val ciCategory = chatItem.mergeCategory val text = if (!revealed.value && ciCategory != null && i != null) { - val reversedChatItems = ChatModel.chatItems.asReversed() var moderated = 0 var blocked = 0 var blockedByAdmin = 0 @@ -67,7 +69,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State } val total = moderated + blocked + blockedByAdmin + deleted if (total <= 1) - markedDeletedText(chatItem) + markedDeletedText(chatItem, chatInfo) else if (total == moderated) stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) else if (total == blockedByAdmin) @@ -77,7 +79,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State else stringResource(MR.strings.marked_deleted_items_description).format(total) } else { - markedDeletedText(chatItem) + markedDeletedText(chatItem, chatInfo) } Text( @@ -91,8 +93,14 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State ) } -fun markedDeletedText(cItem: ChatItem): String = - if (cItem.meta.itemDeleted != null && cItem.isReport) generalGetString(MR.strings.report_item_archived) +fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String = + if (cItem.meta.itemDeleted != null && cItem.isReport) { + if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName) + } else { + generalGetString(MR.strings.report_item_archived) + } + } else when (cItem.meta.itemDeleted) { is CIDeleted.Moderated -> String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName) 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 994d56d1fc..b793955911 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 @@ -10,24 +10,17 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material.MaterialTheme.colors import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* -import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.model.ChatModel.markChatTagRead -import chat.simplex.common.model.ChatModel.updateChatTagRead import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* @@ -209,27 +202,27 @@ suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId) -suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(rhId, ChatType.Group, groupId) +suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? = null) = openChat(rhId, ChatType.Group, groupId, contentTag) -suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.chatType, chatInfo.apiId) +suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag) -private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) = - apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) +private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) = + apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) -suspend fun openLoadedChat(chat: Chat) { - withChats { - chatModel.chatItemStatuses.clear() +suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { + withChats(contentTag) { + chatItemStatuses.clear() chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id - chatModel.chatState.clear() + chatModel.chatStateForContent(contentTag).clear() } } -suspend fun apiFindMessages(ch: Chat, search: String) { - withChats { +suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?) { + withChats(contentTag) { chatItems.clearAndNotify() } - apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search) + apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search = search) } suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { @@ -255,7 +248,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { if (contact.activeConn != null) { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } @@ -295,7 +288,7 @@ fun GroupMenuItems( } else -> { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } @@ -316,7 +309,7 @@ fun GroupMenuItems( @Composable fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState, showMarkRead: Boolean) { if (showMarkRead) { - MarkReadChatAction(chat, chatModel, showMenu) + MarkReadChatAction(chat, showMenu) } else { MarkUnreadChatAction(chat, chatModel, showMenu) } @@ -324,12 +317,12 @@ fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState, showMarkRea } @Composable -fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { +fun MarkReadChatAction(chat: Chat, showMenu: MutableState) { ItemAction( stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), onClick = { - markChatRead(chat, chatModel) + markChatRead(chat) ntfManager.cancelNotificationsForChat(chat.id) showMenu.value = false } @@ -566,12 +559,15 @@ private fun InvalidDataView() { } } -fun markChatRead(c: Chat, chatModel: ChatModel) { +fun markChatRead(c: Chat) { var chat = c withApi { if (chat.chatStats.unreadCount > 0) { withChats { - markChatItemsRead(chat.remoteHostId, chat.chatInfo) + markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) + } + withReportsChatsIfOpen { + markChatItemsRead(chat.remoteHostId, chat.chatInfo.id) } chatModel.controller.apiChatRead( chat.remoteHostId, @@ -612,7 +608,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { if (success) { withChats { replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) - updateChatTagRead(chat, wasUnread) + updateChatTagReadNoContentTag(chat, wasUnread) } } } @@ -874,7 +870,9 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch } val updatedChat = chatModel.getChat(chatInfo.id) if (updatedChat != null) { - chatModel.updateChatTagRead(updatedChat, wasUnread) + withChats { + updateChatTagReadNoContentTag(updatedChat, wasUnread) + } } val current = currentState?.value if (current != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index b4a381809d..c1728d9021 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -49,7 +49,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds -enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } +enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } sealed class ActiveFilter { data class PresetTag(val tag: PresetTagKind) : ActiveFilter() @@ -152,7 +152,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow) { val userTags = remember { chatModel.userTags } val presetTags = remember { chatModel.presetTags } + val collapsiblePresetTags = presetTags.filter { presetCanBeCollapsed(it.key) && it.value > 0 } + val alwaysShownPresetTags = presetTags.filter { !presetCanBeCollapsed(it.key) && it.value > 0 } val activeFilter = remember { chatModel.activeChatTagFilter } val unreadTags = remember { chatModel.unreadTags } val rhId = chatModel.remoteHostId() @@ -935,13 +937,16 @@ private fun TagsView(searchText: MutableState) { val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) TagsRow { - if (presetTags.size > 1) { - if (presetTags.size + userTags.value.size <= 3) { + if (collapsiblePresetTags.size > 1) { + if (collapsiblePresetTags.size + alwaysShownPresetTags.size + userTags.value.size <= 3) { PresetTagKind.entries.filter { t -> (presetTags[t] ?: 0) > 0 }.forEach { tag -> ExpandedTagFilterView(tag) } } else { CollapsedTagsFilterView(searchText) + alwaysShownPresetTags.forEach { tag -> + ExpandedTagFilterView(tag.key) + } } } @@ -1106,7 +1111,7 @@ private fun CollapsedTagsFilterView(searchText: MutableState) { val showMenu = remember { mutableStateOf(false) } val selectedPresetTag = when (val af = activeFilter.value) { - is ActiveFilter.PresetTag -> af.tag + is ActiveFilter.PresetTag -> if (presetCanBeCollapsed(af.tag)) af.tag else null else -> null } @@ -1152,7 +1157,7 @@ private fun CollapsedTagsFilterView(searchText: MutableState) { ) } PresetTagKind.entries.forEach { tag -> - if ((presetTags[tag] ?: 0) > 0) { + if ((presetTags[tag] ?: 0) > 0 && presetCanBeCollapsed(tag)) { ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction) } } @@ -1214,14 +1219,15 @@ fun filteredChats( private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean = when (activeFilter) { - is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo) + is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats) is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 else -> true } -fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = +fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo, chatStats: Chat.ChatStats): Boolean = when (tag) { + PresetTagKind.GROUP_REPORTS -> chatStats.reportsCount > 0 PresetTagKind.FAVORITES -> chatInfo.chatSettings?.favorite == true PresetTagKind.CONTACTS -> when (chatInfo) { is ChatInfo.Direct -> !(chatInfo.contact.activeConn == null && chatInfo.contact.profile.contactLink != null && chatInfo.contact.active) && !chatInfo.contact.chatDeleted @@ -1246,6 +1252,7 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair = when (tag) { + PresetTagKind.GROUP_REPORTS -> (if (active) MR.images.ic_flag_filled else MR.images.ic_flag) to MR.strings.chat_list_group_reports PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups @@ -1253,6 +1260,11 @@ private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes } +private fun presetCanBeCollapsed(tag: PresetTagKind): Boolean = when (tag) { + PresetTagKind.GROUP_REPORTS -> false + else -> true +} + fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) { scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index d0e9b003e2..0b7054114f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -175,7 +175,7 @@ fun ChatPreviewView( val (text: CharSequence, inlineTextContent) = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) } ci.meta.itemDeleted == null -> ci.text to null - else -> markedDeletedText(ci) to null + else -> markedDeletedText(ci, chat.chatInfo) to null } val formattedText = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> null @@ -322,6 +322,8 @@ fun ChatPreviewView( } else if (cInfo is ChatInfo.Group) { if (progressByTimeout) { progressView() + } else if (chat.chatStats.reportsCount > 0) { + GroupReportsIcon() } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -469,6 +471,18 @@ fun IncognitoIcon(incognito: Boolean) { } } +@Composable +fun GroupReportsIcon() { + Icon( + painterResource(MR.images.ic_flag), + contentDescription = null, + tint = MaterialTheme.colors.error, + modifier = Modifier + .size(21.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) +} + @Composable private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String { return if (groupInfo.membership.memberIncognito) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index f8ddc16bde..1b563e6d02 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -32,6 +32,7 @@ import chat.simplex.common.model.ChatController.apiDeleteChatTag import chat.simplex.common.model.ChatController.apiSetChatTags import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction @@ -72,11 +73,11 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: LazyColumnWithScrollBar( modifier = if (reorderMode) Modifier.dragContainer(dragDropState) else Modifier, + state = listState, contentPadding = PaddingValues( top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent, bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp ), - state = listState, verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top, ) { @Composable fun CreateList() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 933bc0c93a..d951f1f812 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -21,6 +21,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -534,6 +535,11 @@ fun deleteChatDatabaseFilesAndState() { chats.clear() popChatCollector.clear() } + withReportsChatsIfOpen { + chatItems.clearAndNotify() + chats.clear() + popChatCollector.clear() + } } chatModel.users.clear() ntfManager.cancelAllNotifications() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 4bf20d2128..1c5f86c8b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -35,7 +35,8 @@ fun DefaultAppBar( // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier val modifier = if (!showSearch) { Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { }) - } else Modifier.imePadding() + } else if (!onTop) Modifier.imePadding() + else Modifier val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) val prefAlpha = remember { appPrefs.inAppBarsAlpha.state } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 819efcdd9a..3e24629ab1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -77,8 +77,19 @@ class ModalData(val keyboardCoversBar: Boolean = true) { val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar) } +enum class ModalViewId { + GROUP_REPORTS +} + class ModalManager(private val placement: ModalPlacement? = null) { - private val modalViews = arrayListOf Unit) -> Unit)>>() + data class ModalViewHolder( + val id: ModalViewId?, + val animated: Boolean, + val data: ModalData, + val modal: @Composable ModalData.(close: () -> Unit) -> Unit + ) + + private val modalViews = arrayListOf() private val _modalCount = mutableStateOf(0) val modalCount: State = _modalCount private val toRemove = mutableSetOf() @@ -88,19 +99,23 @@ class ModalManager(private val placement: ModalPlacement? = null) { private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null) private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null) - fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { - showCustomModal { close -> + fun hasModalOpen(id: ModalViewId): Boolean = modalViews.any { it.id == id } + + fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { + showCustomModal(id = id) { close -> ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() }) } } - fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { - showCustomModal { close -> + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { + showCustomModal(id = id) { close -> ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) }) } } - fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) { + fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, id: ModalViewId? = null, modal: @Composable ModalData.(close: () -> Unit) -> Unit) { Log.d(TAG, "ModalManager.showCustomModal") val data = ModalData(keyboardCoversBar = keyboardCoversBar) // Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen. @@ -111,7 +126,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { // Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0) // to prevent unneeded animation on different situations val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START) - modalViews.add(Triple(anim, data, modal)) + modalViews.add(ModalViewHolder(id, anim, data, modal)) _modalCount.value = modalViews.size - toRemove.size if (placement == ModalPlacement.CENTER) { @@ -139,7 +154,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun closeModal() { if (modalViews.isNotEmpty()) { - if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex) + if (modalViews.lastOrNull()?.animated == false) modalViews.removeAt(modalViews.lastIndex) else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) } } _modalCount.value = modalViews.size - toRemove.size @@ -161,10 +176,10 @@ class ModalManager(private val placement: ModalPlacement? = null) { @Composable fun showInView() { // Without animation - if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) { + if (modalCount.value > 0 && modalViews.lastOrNull()?.animated == false) { modalViews.lastOrNull()?.let { - CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) { - it.third(it.second, ::closeModal) + CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) { + it.modal(it.data, ::closeModal) } } return @@ -179,8 +194,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { } ) { modalViews.getOrNull(it - 1)?.let { - CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) { - it.third(it.second, ::closeModal) + CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) { + it.modal(it.data, ::closeModal) } } // This is needed because if we delete from modalViews immediately on request, animation will be bad diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index aa23eb355f..2380c64a4c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -45,7 +45,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c withChats { updateGroup(rhId = rhId, groupInfo) chatItems.clearAndNotify() - chatModel.chatItemStatuses.clear() + chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id } setGroupMembers(rhId, groupInfo, chatModel) 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 920e4b36e7..54a49bdf31 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -40,6 +40,7 @@ Only you and moderators see it Only sender and moderators see it archived report + archived report by %s blocked blocked by admin %d messages blocked @@ -440,8 +441,13 @@ Groups Businesses Notes + Reports All Add list + 1 report + %d reports + Member reports + Archived member reports Share message… diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg new file mode 100644 index 0000000000..a6f5a70618 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 221f1a1291..9d747206ab 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme @@ -61,6 +62,9 @@ fun showApp() { chatModel.chatId.value = null chatItems.clearAndNotify() } + withReportsChatsIfOpen { + chatItems.clearAndNotify() + } } } chatModel.activeCall.value?.let { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 785c3b40fa..3f5703365d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -36,6 +36,7 @@ actual fun LazyColumnWithScrollBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, fillMaxSize: Boolean, content: LazyListScope.() -> Unit @@ -93,7 +94,7 @@ actual fun LazyColumnWithScrollBar( val modifier = if (fillMaxSize) Modifier.fillMaxSize().then(modifier) else modifier Box(Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).nestedScroll(connection)) { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar) + ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) } } @@ -108,6 +109,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( flingBehavior: FlingBehavior, userScrollEnabled: Boolean, additionalBarOffset: State?, + additionalTopBar: State, chatBottomBar: State, content: LazyListScope.() -> Unit ) { @@ -135,7 +137,7 @@ actual fun LazyColumnWithScrollBarNoAppBar( val scrollBarDraggingState = remember { mutableStateOf(false) } Box { LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar) + ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar) } } @@ -147,11 +149,13 @@ private fun ScrollBar( scrollJob: MutableState, scrollBarDraggingState: MutableState, additionalBarHeight: State?, + additionalTopBar: State, chatBottomBar: State, ) { val oneHandUI = remember { appPrefs.oneHandUI.state } + val topBarPadding = if (additionalTopBar.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp val padding = if (additionalBarHeight != null) { - PaddingValues(top = if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value) + PaddingValues(top = topBarPadding + if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value) } else if (reverseLayout) { PaddingValues(bottom = AppBarHeight * fontSizeSqrtMultiplier) } else {