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 ae5966b20f..1a4d0b72e9 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 @@ -14,6 +14,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.clear import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* @@ -74,9 +75,13 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { if (ModalManager.start.hasModalsOpen()) { ModalManager.start.closeModal() } else if (chatModel.chatId.value != null) { - // Since no modals are open, the problem is probably in ChatView - chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() + withApi { + withChats { + // Since no modals are open, the problem is probably in ChatView + chatModel.chatId.value = null + chatItems.clearAndNotify() + } + } } else { // ChatList, nothing to do. Maybe to show other view except ChatList } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt index 7db39b7d3e..8c3b161a5c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -29,6 +29,19 @@ private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFF private val CALL_BOTTOM_ICON_OFFSET = (-15).dp private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET +@Composable +actual fun TagsRow(content: @Composable() (() -> Unit)) { + Row( + modifier = Modifier + .padding(horizontal = 14.dp) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + content() + } +} + @Composable actual fun ActiveCallInteractiveArea(call: Call) { val onClick = { platform.androidStartCallActivity(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 e2fe96e178..b7da45f2a5 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 @@ -61,7 +61,6 @@ object ChatModel { val incompleteInitializedDbRemoved = mutableStateOf(false) private val _chats = mutableStateOf(SnapshotStateList()) val chats: State> = _chats - private val chatsContext = ChatsContext() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) @@ -72,15 +71,18 @@ object ChatModel { * 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()) + 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() // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) val chatItemStatuses = mutableMapOf() - val groupMembers = mutableStateListOf() - val groupMembersIndexes = mutableStateMapOf() + val groupMembers = mutableStateOf>(emptyList()) + val groupMembersIndexes = mutableStateOf>(emptyMap()) // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -157,7 +159,6 @@ object ChatModel { val updatingProgress = mutableStateOf(null as Float?) var updatingRequest: Closeable? = null - private val updatingChatsMutex: Mutex = Mutex() val changingActiveUserMutex: Mutex = Mutex() val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null @@ -321,27 +322,31 @@ object ChatModel { fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } fun populateGroupMembersIndexes() { - groupMembersIndexes.clear() - groupMembers.forEachIndexed { i, member -> - groupMembersIndexes[member.groupMemberId] = i + groupMembersIndexes.value = emptyMap() + val gmIndexes = groupMembersIndexes.value.toMutableMap() + groupMembers.value.forEachIndexed { i, member -> + gmIndexes[member.groupMemberId] = i } + groupMembersIndexes.value = gmIndexes } fun getGroupMember(groupMemberId: Long): GroupMember? { - val memberIndex = groupMembersIndexes[groupMemberId] + val memberIndex = groupMembersIndexes.value[groupMemberId] return if (memberIndex != null) { - groupMembers[memberIndex] + groupMembers.value[memberIndex] } else { null } } - suspend fun withChats(action: suspend ChatsContext.() -> T): T = updatingChatsMutex.withLock { + // 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() } class ChatsContext { val chats = _chats + val chatItems = _chatItems suspend fun addChat(chat: Chat) { chats.add(index = 0, chat) @@ -694,7 +699,7 @@ object ChatModel { } // update current chat return if (chatId.value == groupInfo.id) { - val memberIndex = groupMembersIndexes[member.groupMemberId] + val memberIndex = groupMembersIndexes.value[member.groupMemberId] val updated = chatItems.value.map { // Take into account only specific changes, not all. Other member updates are not important and can be skipped if (it.chatDir is CIDirection.GroupRcv && it.chatDir.groupMember.groupMemberId == member.groupMemberId && @@ -710,12 +715,17 @@ object ChatModel { if (updated != chatItems.value) { chatItems.replaceAll(updated) } + val gMembers = groupMembers.value.toMutableList() if (memberIndex != null) { - groupMembers[memberIndex] = member + gMembers[memberIndex] = member + groupMembers.value = gMembers false } else { - groupMembers.add(member) - groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1 + gMembers.add(member) + groupMembers.value = gMembers + val gmIndexes = groupMembersIndexes.value.toMutableMap() + gmIndexes[member.groupMemberId] = groupMembers.size - 1 + groupMembersIndexes.value = gmIndexes true } } else { @@ -762,7 +772,7 @@ object ChatModel { suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) - withContext(Dispatchers.Main) { + withChats { chatItems.addAndNotify(cItem) } return cItem @@ -770,7 +780,11 @@ object ChatModel { fun removeLiveDummy() { if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.removeLastAndNotify() + withApi { + withChats { + chatItems.removeLastAndNotify() + } + } } } @@ -891,19 +905,25 @@ object ChatModel { fun replaceConnReqView(id: String, withId: String) { if (id == showingInvitation.value?.connId) { - showingInvitation.value = null - chatModel.chatItems.clearAndNotify() - chatModel.chatId.value = withId + withApi { + withChats { + showingInvitation.value = null + chatItems.clearAndNotify() + chatModel.chatId.value = withId + } + } ModalManager.start.closeModals() ModalManager.end.closeModals() } } - fun dismissConnReqView(id: String) { + fun dismissConnReqView(id: String) = withApi { if (id == showingInvitation.value?.connId) { - showingInvitation.value = null - chatModel.chatItems.clearAndNotify() - chatModel.chatId.value = null + withChats { + showingInvitation.value = null + chatItems.clearAndNotify() + chatModel.chatId.value = null + } // Close NewChatView ModalManager.start.closeModals() ModalManager.center.closeModals() @@ -1334,7 +1354,13 @@ sealed class ChatInfo: SomeChat, NamedChat { is Group -> groupInfo.chatTags else -> null } -} + + val contactCard: Boolean + get() = when (this) { + is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active + else -> false + } + } @Serializable sealed class NetworkStatus { 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 a86be622b9..f16c19fdb0 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 @@ -683,6 +683,8 @@ object ChatController { Log.d(TAG, "sendCmd: ${cmd.cmdType}") } val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c) + // coroutine was cancelled already, no need to process response (helps with apiListMembers - very heavy query in large groups) + interruptIfCancelled() val r = APIResponse.decodeStr(json) if (log) { Log.d(TAG, "sendCmd response type ${r.resp.responseType}") @@ -3044,8 +3046,8 @@ object ChatController { chatModel.users.addAll(users) chatModel.currentUser.value = user if (user == null) { - chatModel.chatItems.clearAndNotify() withChats { + chatItems.clearAndNotify() chats.clear() popChatCollector.clear() } 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 be09c04ec1..5cbc01271a 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 @@ -45,9 +45,9 @@ suspend fun apiLoadMessages( addChat(chat) } } - withContext(Dispatchers.Main) { + withChats { chatModel.chatItemStatuses.clear() - chatModel.chatItems.replaceAll(chat.chatItems) + chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { @@ -70,8 +70,8 @@ suspend fun apiLoadMessages( ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats { + chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) } @@ -89,8 +89,8 @@ suspend fun apiLoadMessages( val indexToAdd = min(indexInCurrentItems + 1, newItems.size) val indexToAddIsLast = indexToAdd == newItems.size newItems.addAll(indexToAdd, chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats { + chatItems.replaceAll(newItems) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) // loading clear bottom area, updating number of unread items after the newest loaded item @@ -104,8 +104,8 @@ 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) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats { + chatItems.replaceAll(newItems) splits.value = listOf(chat.chatItems.last().id) + newSplits unreadAfterItemId.value = chat.chatItems.last().id totalAfter.value = navInfo.afterTotal @@ -119,8 +119,8 @@ suspend fun apiLoadMessages( newItems.addAll(oldItems) removeDuplicates(newItems, chat) newItems.addAll(chat.chatItems) - withContext(Dispatchers.Main) { - chatModel.chatItems.replaceAll(newItems) + withChats { + chatItems.replaceAll(newItems) unreadAfterNewestLoaded.value = 0 } } 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 c58561718e..71e1a422a6 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 @@ -114,6 +114,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { + var groupMembersJob: Job = remember { Job() } val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null } 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) } @@ -220,8 +221,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - hideKeyboard(view) AudioPlayer.stop() chatModel.chatId.value = null - chatModel.groupMembers.clear() - chatModel.groupMembersIndexes.clear() + chatModel.groupMembers.value = emptyList() + chatModel.groupMembersIndexes.value = emptyMap() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -229,7 +230,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - return@ChatLayout } hideKeyboard(view) - withBGApi { + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { // The idea is to preload information before showing a modal because large groups can take time to load all members var preloadedContactInfo: Pair? = null var preloadedCode: String? = null @@ -241,6 +243,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId) } + if (!isActive) return@launch + ModalManager.end.showModalCloseable(true) { close -> val chatInfo = remember { activeChatInfo }.value if (chatInfo is ChatInfo.Direct) { @@ -276,7 +280,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - }, showMemberInfo = { groupInfo: GroupInfo, member: GroupMember -> hideKeyboard(view) - withBGApi { + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second val (_, code) = if (member.memberActive) { @@ -286,6 +291,8 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - member to null } setGroupMembers(chatRh, groupInfo, chatModel) + if (!isActive) return@launch + ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> @@ -431,7 +438,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - chatModel.getChat(chatId) }, findModelMember = { memberId -> - chatModel.groupMembers.find { it.id == memberId } + chatModel.groupMembers.value.find { it.id == memberId } }, setReaction = { cInfo, cItem, add, reaction -> withBGApi { @@ -451,17 +458,19 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } }, showItemDetails = { cInfo, cItem -> - suspend fun loadChatItemInfo(): ChatItemInfo? { + suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope { val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id) if (ciInfo != null) { if (chatInfo is ChatInfo.Group) { setGroupMembers(chatRh, chatInfo.groupInfo, chatModel) + if (!isActive) return@coroutineScope null } } - return ciInfo + ciInfo } - withBGApi { - var initialCiInfo = loadChatItemInfo() ?: return@withBGApi + groupMembersJob.cancel() + groupMembersJob = scope.launch(Dispatchers.Default) { + var initialCiInfo = loadChatItemInfo() ?: return@launch ModalManager.end.closeModals() ModalManager.end.showModalCloseable(endButtons = { ShareButton { @@ -550,7 +559,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - chatModel.chatItems.clearAndNotify() + withChats { + chatItems.clearAndNotify() + } } } is ChatInfo.InvalidJSON -> { @@ -561,7 +572,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - LaunchedEffect(chatInfo.id) { onComposed(chatInfo.id) ModalManager.end.closeModals() - chatModel.chatItems.clearAndNotify() + withChats { + chatItems.clearAndNotify() + } } } else -> {} 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 25661f00a0..6072abfc36 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 @@ -83,7 +83,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea fun getContactsToAdd(chatModel: ChatModel, search: String): List { val s = search.trim().lowercase() - val memberContactIds = chatModel.groupMembers + val memberContactIds = chatModel.groupMembers.value .filter { it.memberCurrent } .mapNotNull { it.memberContactId } return chatModel.chats.value 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 c92ac2ddc3..21d678ba50 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 @@ -40,7 +40,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource -import kotlinx.coroutines.launch +import kotlinx.coroutines.* const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 @@ -54,6 +54,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { val groupInfo = chat.chatInfo.groupInfo val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) } + val scope = rememberCoroutineScope() GroupChatInfoLayout( chat, groupInfo, @@ -64,14 +65,16 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel) sendReceipts.value = sendRcpts }, - members = chatModel.groupMembers + members = remember { chatModel.groupMembers }.value .filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved } .sortedByDescending { it.memberRole }, developerTools, groupLink, addMembers = { - withBGApi { + scope.launch(Dispatchers.Default) { setGroupMembers(rhId, groupInfo, chatModel) + if (!isActive) return@launch + ModalManager.end.showModalCloseable(true) { close -> AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } 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 e607efeddc..657c0923a5 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 @@ -98,8 +98,8 @@ fun GroupMemberInfoView( val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) withChats { addChat(memberChat) - openLoadedChat(memberChat) } + openLoadedChat(memberChat) closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } 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 2f0311b087..994d56d1fc 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 @@ -216,21 +216,26 @@ suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo. private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) = apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState) -fun openLoadedChat(chat: Chat) { - chatModel.chatItemStatuses.clear() - chatModel.chatItems.replaceAll(chat.chatItems) - chatModel.chatId.value = chat.chatInfo.id - chatModel.chatState.clear() +suspend fun openLoadedChat(chat: Chat) { + withChats { + chatModel.chatItemStatuses.clear() + chatItems.replaceAll(chat.chatItems) + chatModel.chatId.value = chat.chatInfo.id + chatModel.chatState.clear() + } } suspend fun apiFindMessages(ch: Chat, search: String) { - chatModel.chatItems.clearAndNotify() + withChats { + chatItems.clearAndNotify() + } apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search) } -suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { +suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { + // groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) - val currentMembers = chatModel.groupMembers + val currentMembers = chatModel.groupMembers.value val newMembers = groupMembers.map { newMember -> val currentMember = currentMembers.find { it.id == newMember.id } val currentMemberStats = currentMember?.activeConn?.connectionStats @@ -241,9 +246,8 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo newMember } } - chatModel.groupMembers.clear() - chatModel.groupMembersIndexes.clear() - chatModel.groupMembers.addAll(newMembers) + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.groupMembers.value = newMembers chatModel.populateGroupMembersIndexes() } @@ -351,14 +355,14 @@ fun TagListAction( ) { val userTags = remember { chatModel.userTags } ItemAction( - stringResource(MR.strings.list_menu), + stringResource(if (chat.chatInfo.chatTags.isNullOrEmpty()) MR.strings.add_to_list else MR.strings.change_list), painterResource(MR.images.ic_label), onClick = { ModalManager.start.showModalCloseable { close -> if (userTags.value.isEmpty()) { TagListEditor(rhId = chat.remoteHostId, chat = chat, close = close) } else { - TagListView(rhId = chat.remoteHostId, chat = chat, close = close) + TagListView(rhId = chat.remoteHostId, chat = chat, close = close, reorderMode = false) } } showMenu.value = false 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 4648ac5037..b4a381809d 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 } +enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } sealed class ActiveFilter { data class PresetTag(val tag: PresetTagKind) : ActiveFilter() @@ -815,13 +815,13 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat if (oneHandUI.value) { Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) { Divider() - TagsView() + TagsView(searchText) ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) } } else { ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink) - TagsView() + TagsView(searchText) Divider() } } @@ -925,25 +925,13 @@ private fun ChatListFeatureCards() { private val TAG_MIN_HEIGHT = 35.dp @Composable -private fun TagsView() { +private fun TagsView(searchText: MutableState) { val userTags = remember { chatModel.userTags } val presetTags = remember { chatModel.presetTags } val activeFilter = remember { chatModel.activeChatTagFilter } val unreadTags = remember { chatModel.unreadTags } val rhId = chatModel.remoteHostId() - fun showTagList() { - ModalManager.start.showCustomModal { close -> - val editMode = remember { stateGetOrPut("editMode") { false } } - ModalView(close, showClose = true, endButtons = { - TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = CircleShape)) { - Text(stringResource(if (editMode.value) MR.strings.cancel_verb else MR.strings.edit_verb)) - } - }) { - TagListView(rhId = rhId, close = close, editMode = editMode) - } - } - } val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) TagsRow { @@ -953,7 +941,7 @@ private fun TagsView() { ExpandedTagFilterView(tag) } } else { - CollapsedTagsFilterView() + CollapsedTagsFilterView(searchText) } } @@ -963,69 +951,75 @@ private fun TagsView() { else -> false } val interactionSource = remember { MutableInteractionSource() } - Row( - rowSizeModifier - .clip(shape = CircleShape) - .combinedClickable( - onClick = { - if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { - chatModel.activeChatTagFilter.value = null - } else { - chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) + val showMenu = rememberSaveable { mutableStateOf(false) } + val saving = remember { mutableStateOf(false) } + Box { + Row( + rowSizeModifier + .clip(shape = CircleShape) + .combinedClickable( + onClick = { + if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) { + chatModel.activeChatTagFilter.value = null + } else { + chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag) + } + }, + onLongClick = { showMenu.value = true }, + interactionSource = interactionSource, + indication = LocalIndication.current, + enabled = !saving.value + ) + .onRightClick { showMenu.value = true } + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (tag.chatTagEmoji != null) { + ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) + } else { + Icon( + painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), + null, + Modifier.size(18.sp.toDp()), + tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground + ) + } + Spacer(Modifier.width(4.dp)) + Box { + val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" + val invisibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { + append(badgeText) } - }, - onLongClick = { showTagList() }, - interactionSource = interactionSource, - indication = LocalIndication.current - ) - .onRightClick { showTagList() } - .padding(4.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - if (tag.chatTagEmoji != null) { - ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp) - } else { - Icon( - painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label), - null, - Modifier.size(18.sp.toDp()), - tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground - ) - } - Spacer(Modifier.width(4.dp)) - Box { - val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else "" - val invisibleText = buildAnnotatedString { - append(tag.chatTagText) - withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) { - append(badgeText) } - } - Text( - text = invisibleText, - fontWeight = FontWeight.Medium, - fontSize = 15.sp, - color = Color.Transparent, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - // Visible text with styles - val visibleText = buildAnnotatedString { - append(tag.chatTagText) - withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) { - append(badgeText) + Text( + text = invisibleText, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + color = Color.Transparent, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Visible text with styles + val visibleText = buildAnnotatedString { + append(tag.chatTagText) + withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) { + append(badgeText) + } } + Text( + text = visibleText, + fontWeight = if (current) FontWeight.Medium else FontWeight.Normal, + fontSize = 15.sp, + color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } - Text( - text = visibleText, - fontWeight = if (current) FontWeight.Medium else FontWeight.Normal, - fontSize = 15.sp, - color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) } + TagsDropdownMenu(rhId, tag, showMenu, saving) } } val plusClickModifier = Modifier @@ -1051,23 +1045,8 @@ private fun TagsView() { } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun TagsRow(content: @Composable() (() -> Unit)) { - if (appPlatform.isAndroid) { - Row( - modifier = Modifier - .padding(horizontal = 14.dp) - .horizontalScroll(rememberScrollState()), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - content() - } - } else { - FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() } - } -} +expect fun TagsRow(content: @Composable() (() -> Unit)) @Composable private fun ExpandedTagFilterView(tag: PresetTagKind) { @@ -1076,12 +1055,12 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { is ActiveFilter.PresetTag -> af.tag == tag else -> false } - val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) val (icon, text) = presetTagLabel(tag, active) val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Row( - modifier = rowSizeModifier + modifier = Modifier + .sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) .clip(shape = CircleShape) .clickable { if (activeFilter.value == ActiveFilter.PresetTag(tag)) { @@ -1121,7 +1100,7 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { @Composable -private fun CollapsedTagsFilterView() { +private fun CollapsedTagsFilterView(searchText: MutableState) { val activeFilter = remember { chatModel.activeChatTagFilter } val presetTags = remember { chatModel.presetTags } val showMenu = remember { mutableStateOf(false) } @@ -1145,7 +1124,7 @@ private fun CollapsedTagsFilterView() { painterResource(icon), stringResource(text), Modifier.size(18.sp.toDp()), - tint = MaterialTheme.colors.secondary + tint = MaterialTheme.colors.primary ) } else { Icon( @@ -1155,20 +1134,26 @@ private fun CollapsedTagsFilterView() { ) } - DefaultDropdownMenu(showMenu = showMenu) { - if (selectedPresetTag != null) { + val onCloseMenuAction = remember { mutableStateOf<(() -> Unit)>({}) } + + DefaultDropdownMenu(showMenu = showMenu, onClosed = onCloseMenuAction) { + if (activeFilter.value != null || searchText.value.text.isNotBlank()) { ItemAction( stringResource(MR.strings.chat_list_all), painterResource(MR.images.ic_menu), onClick = { - chatModel.activeChatTagFilter.value = null + onCloseMenuAction.value = { + searchText.value = TextFieldValue() + chatModel.activeChatTagFilter.value = null + onCloseMenuAction.value = {} + } showMenu.value = false } ) } PresetTagKind.entries.forEach { tag -> if ((presetTags[tag] ?: 0) > 0) { - ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu) + ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction) } } } @@ -1179,14 +1164,19 @@ private fun CollapsedTagsFilterView() { fun ItemPresetFilterAction( presetTag: PresetTagKind, active: Boolean, - showMenu: MutableState + showMenu: MutableState, + onCloseMenuAction: MutableState<(() -> Unit)> ) { val (icon, text) = presetTagLabel(presetTag, active) ItemAction( stringResource(text), painterResource(icon), + color = if (active) MaterialTheme.colors.primary else Color.Unspecified, onClick = { - chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + onCloseMenuAction.value = { + chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) + onCloseMenuAction.value = {} + } showMenu.value = false } ) @@ -1205,26 +1195,18 @@ fun filteredChats( } else { val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() if (s.isEmpty()) - chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD && filtered(chat, activeFilter) } + chats.filter { chat -> chat.id == chatModel.chatId.value || (!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat, activeFilter)) } else { chats.filter { chat -> - when (val cInfo = chat.chatInfo) { - is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && ( - if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat, activeFilter) - } else { - cInfo.anyNameContains(s) - }) - is ChatInfo.Group -> if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat, activeFilter) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited - } else { - cInfo.anyNameContains(s) + chat.id == chatModel.chatId.value || + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> !cInfo.contact.chatDeleted && !chat.chatInfo.contactCard && cInfo.anyNameContains(s) + is ChatInfo.Group -> cInfo.anyNameContains(s) + is ChatInfo.Local -> cInfo.anyNameContains(s) + is ChatInfo.ContactRequest -> cInfo.anyNameContains(s) + is ChatInfo.ContactConnection -> cInfo.contactConnection.localAlias.lowercase().contains(s) + is ChatInfo.InvalidJSON -> false } - is ChatInfo.Local -> s.isEmpty() || cInfo.anyNameContains(s) - is ChatInfo.ContactRequest -> s.isEmpty() || cInfo.anyNameContains(s) - is ChatInfo.ContactConnection -> (s.isNotEmpty() && cInfo.anyNameContains(s)) || (s.isEmpty() && chat.id == chatModel.chatId.value) - is ChatInfo.InvalidJSON -> chat.id == chatModel.chatId.value - } } } } @@ -1256,6 +1238,10 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean = is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business else -> false } + PresetTagKind.NOTES -> when (chatInfo) { + is ChatInfo.Local -> !chatInfo.noteFolder.chatDeleted + else -> false + } } private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair = @@ -1264,6 +1250,7 @@ private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair (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 PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses + PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes } fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) { 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 2cd0c953c7..f8ddc16bde 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 @@ -5,8 +5,7 @@ import SectionDivider import SectionItemView import TextIconSpaced import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.itemsIndexed @@ -44,12 +43,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @Composable -fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: MutableState = remember { mutableStateOf(false) }) { - if (remember { editMode }.value) { - BackHandler { - editMode.value = false - } - } +fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) { val userTags = remember { chatModel.userTags } val oneHandUI = remember { appPrefs.oneHandUI.state } val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState() @@ -77,7 +71,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu val topPaddingToContent = topPaddingToContent(false) LazyColumnWithScrollBar( - modifier = if (editMode.value) Modifier.dragContainer(dragDropState) else Modifier, + modifier = if (reorderMode) Modifier.dragContainer(dragDropState) else Modifier, 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 @@ -97,7 +91,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu } } - if (oneHandUI.value && !editMode.value) { + if (oneHandUI.value && !reorderMode) { item { CreateList() } @@ -111,15 +105,14 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu backgroundColor = if (isDragging) colors.surface else Color.Unspecified ) { Column { - val showMenu = remember { mutableStateOf(false) } val selected = chatTagIds.value.contains(tag.chatTagId) Row( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) - .combinedClickable( - enabled = !saving.value, + .clickable( + enabled = !saving.value && !reorderMode, onClick = { if (chat == null) { ModalManager.start.showModalCloseable { close -> @@ -139,13 +132,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu }) } }, - onLongClick = if (editMode.value) null else { - { showMenu.value = true } - }, - interactionSource = remember { MutableInteractionSource() }, - indication = LocalIndication.current ) - .onRightClick { showMenu.value = true } .padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)), verticalAlignment = Alignment.CenterVertically ) { @@ -163,21 +150,17 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu if (selected) { Spacer(Modifier.weight(1f)) Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) - } else if (editMode.value) { + } else if (reorderMode) { Spacer(Modifier.weight(1f)) Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) } - DefaultDropdownMenu(showMenu, dropdownMenuItems = { - EditTagAction(rhId, tag, showMenu) - DeleteTagAction(rhId, tag, showMenu, saving) - }) } SectionDivider() } } } } - if (!oneHandUI.value && !editMode.value) { + if (!oneHandUI.value && !reorderMode) { item { CreateList() } @@ -279,7 +262,7 @@ fun ModalData.TagListEditor( SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) { Text( - generalGetString(if (chat != null) MR.strings.add_to_list else if (tagId == null) MR.strings.create_list else MR.strings.save_list), + generalGetString(if (chat != null) MR.strings.add_to_list else MR.strings.save_list), color = if (disabled) colors.secondary else colors.primary ) } @@ -309,6 +292,15 @@ fun ModalData.TagListEditor( } } +@Composable +fun TagsDropdownMenu(rhId: Long?, tag: ChatTag, showMenu: MutableState, saving: MutableState) { + DefaultDropdownMenu(showMenu, dropdownMenuItems = { + EditTagAction(rhId, tag, showMenu) + DeleteTagAction(rhId, tag, showMenu, saving) + ChangeOrderTagAction(rhId, showMenu) + }) +} + @Composable private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState, saving: MutableState) { ItemAction( @@ -343,6 +335,21 @@ private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.change_order_chat_list_menu_action), + painterResource(MR.images.ic_drag_handle), + onClick = { + showMenu.value = false + ModalManager.start.showModalCloseable { close -> + TagListView(rhId = rhId, close = close, reorderMode = true) + } + }, + color = MenuTextColor + ) +} + @Composable expect fun ChatTagInput(name: MutableState, showError: State, emoji: MutableState) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 0af8e7ca38..da70aef621 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -21,7 +21,9 @@ fun onRequestAccepted(chat: Chat) { if (chatInfo is ChatInfo.Direct) { ModalManager.start.closeModals() if (chatInfo.contact.sndReady) { - openLoadedChat(chat) + withApi { + openLoadedChat(chat) + } } } } 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 28772f01d3..933bc0c93a 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 @@ -528,9 +528,9 @@ fun deleteChatDatabaseFilesAndState() { // Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() withLongRunningApi { withChats { + chatItems.clearAndNotify() chats.clear() popChatCollector.clear() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt index 1f00af2809..c6a566c6f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt @@ -5,8 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -16,6 +15,7 @@ fun DefaultDropdownMenu( showMenu: MutableState, modifier: Modifier = Modifier, offset: DpOffset = DpOffset(0.dp, 0.dp), + onClosed: State<() -> Unit> = remember { mutableStateOf({}) }, dropdownMenuItems: (@Composable () -> Unit)? ) { MaterialTheme( @@ -31,6 +31,11 @@ fun DefaultDropdownMenu( offset = offset, ) { dropdownMenuItems?.invoke() + DisposableEffect(Unit) { + onDispose { + onClosed.value() + } + } } } } 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 6cecbe4979..aa23eb355f 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 @@ -44,7 +44,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c if (groupInfo != null) { withChats { updateGroup(rhId = rhId, groupInfo) - chatModel.chatItems.clearAndNotify() + chatItems.clearAndNotify() chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id } 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 ffbe473df8..e45ba24822 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -421,6 +421,7 @@ Contacts Groups Businesses + Notes All Add list @@ -644,6 +645,7 @@ Create list Add to list + Change list Save list List name... List name and emoji should be different for all lists. @@ -651,6 +653,7 @@ Delete list? All chats will be removed from the list %s, and the list deleted Edit + Change order You invited a contact diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg new file mode 100644 index 0000000000..0f9889083d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg new file mode 100644 index 0000000000..6291f7ab8e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_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 2702862e47..221f1a1291 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 @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.LocalDensity 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.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme @@ -55,8 +56,12 @@ fun showApp() { // Better to not close fullscreen since it can contain passcode } else { // The last possible cause that can be closed - chatModel.chatId.value = null - chatModel.chatItems.clearAndNotify() + withApi { + withChats { + chatModel.chatId.value = null + chatItems.clearAndNotify() + } + } } chatModel.activeCall.value?.let { withBGApi { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index 3fa78bbbb5..e295144191 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -21,6 +21,12 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +@OptIn(ExperimentalLayoutApi::class) +@Composable +actual fun TagsRow(content: @Composable() (() -> Unit)) { + FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() } +} + @Composable actual fun ActiveCallInteractiveArea(call: Call) { val showMenu = remember { mutableStateOf(false) }